Author SHA1 Message Date
reya 9fa4f4f2ff add speech component 2026-10-27 09:52:28 +07:00
reya 6fdbedac14 clean up 2026-10-27 09:17:11 +07:00
reya b0121f8df2 chore: simplify import dialog & sidebar menu 2026-10-26 19:59:48 +07:00
reya bfa34959cf feat: add deploy to cloudflare script 2026-10-26 10:16:14 +07:00
reya 4d0e2f5050 feat: add syncing progress 2026-10-26 09:54:38 +07:00
reya 0824b1825b feat: make the web version fully working (#67)
Reviewed-on: #67
2026-10-25 13:10:51 +00:00
59 changed files with 3763 additions and 1232 deletions
+22
View File
@@ -30,3 +30,25 @@ jobs:
- name: Run tests
run: cargo test --verbose
check-wasm:
name: Check web target
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
targets: wasm32-unknown-unknown
components: llvm-tools-preview
- name: Install wasm C toolchain
run: sudo apt-get update && sudo apt-get install -y clang llvm
- name: Check web target
env:
CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
+1
View File
@@ -24,3 +24,4 @@ dist/
vendor/
wasm/
node_modules/
.wrangler/
Generated
+728 -50
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -32,7 +32,9 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

+5
View File
@@ -9,3 +9,8 @@ gpui.workspace = true
anyhow.workspace = true
rust-embed.workspace = true
gpui-kit.workspace = true
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
# disk loading returns nothing; embed the assets even in debug builds.
[target.'cfg(target_arch = "wasm32")'.dependencies]
rust-embed = { workspace = true, features = ["debug-embed"] }
+27
View File
@@ -20,12 +20,39 @@ impl AssetSource for CombinedAssets {
if let Ok(Some(data)) = Assets.load(path) {
return Ok(Some(data));
}
#[cfg(target_arch = "wasm32")]
{
// The kit bundle fetches icons over HTTP on the web; a single
// instance keeps its download cache alive across loads.
use std::sync::OnceLock;
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
KIT_ASSETS
.get_or_init(gpui_kit::assets::Assets::default)
.load(path)
}
#[cfg(not(target_arch = "wasm32"))]
gpui_kit::assets::Assets.load(path)
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
let mut paths = Assets.list(path).unwrap_or_default();
#[cfg(target_arch = "wasm32")]
{
use std::sync::OnceLock;
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
paths.extend(
KIT_ASSETS
.get_or_init(gpui_kit::assets::Assets::default)
.list(path)?,
);
}
#[cfg(not(target_arch = "wasm32"))]
paths.extend(gpui_kit::assets::Assets.list(path)?);
Ok(paths)
}
}
-39
View File
@@ -1,39 +0,0 @@
use gpui::AssetSource;
#[test]
fn icons_resolve_from_the_vendored_folder() {
// Icons coop's code and the kit components draw, served by the vendored
// folder (signed's set, filled with the kit bundle where coop needs more).
let vendored = [
"icons/search.svg",
"icons/close.svg",
"icons/panel-left.svg",
"icons/window-minimize.svg",
"icons/settings-2.svg",
"icons/x.svg",
"icons/compass.svg",
"icons/radio-tower.svg",
];
for path in vendored {
let own = assets::Assets
.load(path)
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
assert!(own.is_some(), "own embed is missing {path}");
let combined = assets::CombinedAssets.load(path).unwrap();
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
}
// Coop's own files still load through the combined source.
let own = [
"brand/coop.svg",
"brand/bg1.jpg",
"fonts/Inter/Inter-Medium.ttf",
];
for path in own {
assert!(
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
"CombinedAssets fails to serve coop asset {path}"
);
}
}
+1
View File
@@ -11,6 +11,7 @@ serde.workspace = true
serde_json.workspace = true
futures.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
gpui-updater-pre = "0.2"
[dev-dependencies]
+156 -65
View File
@@ -11,12 +11,12 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity,
};
use instant::Duration;
use instant::{Duration, Instant};
use nip4e::{DeviceEvent, DeviceRegistry};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
mod message;
mod room;
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
use crate::search::MessageSearchIndex;
/// A static keypair used only for signing locally-cached rumor events.
/// Upper bound on signals applied to the UI in a single update
const MAX_SIGNAL_BATCH: usize = 256;
/// Keypair used only for identifying locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(cx: &mut App) {
@@ -44,6 +47,7 @@ impl Global for GlobalChatRegistry {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChatEvent {
Ping,
Loaded,
InboxRelayNotFound,
Error(String),
}
@@ -74,6 +78,13 @@ impl Signal {
}
}
/// Which signer to attempt when unwrapping a gift wrap
enum UnwrapRoute {
User,
Device,
Both,
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
@@ -216,32 +227,34 @@ impl ChatRegistry {
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
const MAX_PROCESSED: usize = 10_000;
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
let mut user_key = signer.get_public_key_async().await.ok();
let device_key = match device_signer.as_ref() {
Some(device) => device.get_public_key_async().await.ok(),
None => None,
};
let mut last_yield = Instant::now();
while let Some(notification) = notifications.next().await {
if last_yield.elapsed() >= Duration::from_millis(8) {
executor.timer(Duration::from_millis(0)).await;
last_yield = Instant::now();
}
let ClientNotification::Message { message, relay_url } = notification else {
continue;
};
match *message {
RelayMessage::Event {
subscription_id,
event,
..
} => {
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
let device_sub = subscription_id.as_str() != sub_id2.as_str();
// Concord wraps are also kind 1059.
//
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
continue;
}
RelayMessage::Event { event, .. } => {
// Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED {
processed_events.clear();
@@ -253,8 +266,10 @@ impl ChatRegistry {
// Handle msg relays event to determine when the app is ready to subscribe
if event.kind == Kind::InboxRelays {
let current_user = signer.get_public_key_async().await?;
if event.pubkey == current_user {
if user_key.is_none() {
user_key = signer.get_public_key_async().await.ok();
}
if Some(event.pubkey) == user_key {
tx.send_async(Signal::InboxReady).await?;
}
}
@@ -270,15 +285,39 @@ impl ChatRegistry {
seen.entry(event.id).or_default().insert(relay_url);
}
// Route to the signer matching the recipient tag
// so a wrap is never decrypted twice.
let recipient = event.tags.public_keys().next();
let route = match recipient {
Some(key) if Some(key) == device_key => UnwrapRoute::Device,
Some(key) if Some(key) == user_key => UnwrapRoute::User,
Some(_) if user_key.is_some() && device_key.is_some() => continue,
_ => UnwrapRoute::Both,
};
// Extract the rumor from the gift wrap event
match extract_rumor(
let result = match route {
UnwrapRoute::User => {
extract_rumor(&client, &signer, None, event.as_ref()).await
}
UnwrapRoute::Device => match device_signer.as_ref() {
Some(device) => {
extract_rumor(&client, device, None, event.as_ref()).await
}
None => continue,
},
UnwrapRoute::Both => {
extract_rumor(
&client,
&signer,
device_signer.as_ref(),
event.as_ref(),
)
.await
{
}
};
match result {
Ok(rumor) => {
let Some(rumor_id) = rumor.id else {
log::error!("Rumor missing id after ensure_id");
@@ -317,35 +356,57 @@ impl ChatRegistry {
Ok(())
}));
let tracking = self.tracking.clone();
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
Signal::Message(message) => {
this.update(cx, |this, cx| {
this.new_message(message, cx);
})?;
while let Ok(first) = rx.recv_async().await {
// Batch whatever has already arrived so the UI updates once per burst
let mut messages = Vec::new();
let mut errors = Vec::new();
let mut inbox_ready = false;
let mut eose = false;
let mut pending = Some(first);
while let Some(signal) = pending.take() {
match signal {
Signal::Message(message) => messages.push(message),
Signal::InboxReady => inbox_ready = true,
Signal::Eose => eose = true,
Signal::Error(failed) => errors.push(failed),
}
Signal::InboxReady => {
if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
break;
}
pending = rx.try_recv().ok();
}
if inbox_ready {
this.update(cx, |this, cx| {
this.get_messages(cx);
})?;
}
Signal::Eose => {
this.update(cx, |this, _cx| {
this.tracking.store(false, Ordering::Release);
})?;
if !messages.is_empty() {
this.update(cx, |this, cx| {
this.get_rooms(cx);
this.new_messages(messages, cx);
})?;
}
Signal::Error(failed) => {
trash.update(cx, |this, cx| {
this.insert(failed);
if !errors.is_empty() {
trash.update(cx, |trash, cx| {
trash.extend(errors);
cx.notify();
})?;
}
};
if eose && tracking.swap(false, Ordering::AcqRel) {
this.update(cx, |this, cx| {
cx.emit(ChatEvent::Loaded);
this.get_rooms(cx);
})?;
}
}
Ok(())
@@ -669,6 +730,7 @@ impl ChatRegistry {
self.rooms.clear();
self.room_index.clear();
self.search.write().unwrap().clear();
self.tracking.store(true, Ordering::Release);
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -748,6 +810,7 @@ impl ChatRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let search = self.search.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
@@ -775,7 +838,15 @@ impl ChatRegistry {
let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Keep the single-threaded web event loop responsive during large rescans
let mut last_yield = Instant::now();
for raw in events.into_iter() {
if last_yield.elapsed() >= Duration::from_millis(8) {
executor.timer(Duration::from_millis(0)).await;
last_yield = Instant::now();
}
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some()
{
@@ -811,12 +882,17 @@ impl ChatRegistry {
})
}
/// Parse a nostr event into a message and push it to the belonging room
/// Parse nostr events into messages and push them to the belonging rooms
///
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.search.write().unwrap().insert(&message.rumor);
/// - If a room doesn't exist, it will be created.
/// - Room ordering is updated once after the whole batch.
pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
{
let mut search = self.search.write().unwrap();
for message in &messages {
search.insert(&message.rumor);
}
}
let nostr = NostrRegistry::global(cx);
@@ -824,17 +900,18 @@ impl ChatRegistry {
return;
};
for message in messages {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
&& (message.rumor.pubkey == public_key
|| this.auto_trusted(public_key, cx))
{
this.set_ongoing(cx);
}
this.push_message(message, cx);
});
self.sort(cx);
}
None => {
// Push the new room to the front of the list
@@ -842,19 +919,23 @@ impl ChatRegistry {
}
}
}
self.sort(cx);
}
/// Parse a nostr event into a message and push it to the belonging room
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.new_messages(vec![message], cx);
}
}
/// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor<S, D>(
async fn extract_rumor(
client: &Client,
signer: &S,
device_signer: Option<&D>,
signer: &UniversalSigner,
device_signer: Option<&UniversalSigner>,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error>
where
S: AsyncNip44 + ?Sized,
D: AsyncNip44 + ?Sized,
{
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
return Ok(rumor);
@@ -888,10 +969,10 @@ where
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
where
S: AsyncNip44 + ?Sized,
{
async fn try_unwrap_with(
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
// Get the sealed event
let seal = signer
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
@@ -926,17 +1007,27 @@ where
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
let tags = vec![
let tags = Tags::from_list(vec![
Tag::identifier(id),
Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]),
Tag::custom("k", [rumor.kind.to_string()]),
];
]);
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
.tags(tags)
.finalize_async(&*LOCAL_KEYS)
.await?;
let public_key = LOCAL_KEYS.public_key();
let created_at = Timestamp::now();
let kind = Kind::ApplicationSpecificData;
let content = rumor.as_json();
let event = Event::new(
EventId::compute(&public_key, &created_at, &kind, &tags, &content),
public_key,
created_at,
kind,
tags,
content,
Signature::from_byte_array([0u8; 64]),
);
client.database().save_event(&event).await?;
+1 -1
View File
@@ -7,7 +7,7 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
+48 -10
View File
@@ -1,32 +1,70 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use anyhow::Error;
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::{FileAttachment, LocalFile, PickedFile};
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` is kept around so the composer can preview
/// The `local` handle is kept around so the composer can preview
/// the file without downloading and decrypting it again.
pub(crate) struct PendingFile {
pub file: FileAttachment,
pub path: PathBuf,
pub local: LocalFile,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Ready(LocalFile),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
File(Box<UploadedFile>),
}
/// A `file://` url for a decrypted file, so it can be opened by the OS
pub(crate) fn file_url(path: &Path) -> String {
format!("file://{}", path.display())
/// An encrypted upload and the handle to preview it locally
pub(crate) struct UploadedFile {
pub file: FileAttachment,
pub local: LocalFile,
}
impl Uploaded {
/// Build the upload result for a picked file, encrypted when the
/// attachment is the whole message
pub(crate) async fn from_pick(
encrypted: bool,
server: Url,
picked: PickedFile,
cx: &gpui::AsyncApp,
) -> Result<Self, Error> {
if encrypted {
let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?;
Ok(Self::File(Box::new(UploadedFile { file, local })))
} else {
state::upload_file(server, picked, cx).await.map(Self::Url)
}
}
}
/// The pieces of an encrypted upload, split so the local handle survives the
/// picked file being consumed by the upload
struct UploadedFileParts {
file: FileAttachment,
local: LocalFile,
}
async fn upload_encrypted(
server: Url,
picked: PickedFile,
cx: &gpui::AsyncApp,
) -> Result<UploadedFileParts, Error> {
let local = picked.local.clone();
state::upload_encrypted_file(server, picked, cx)
.await
.map(|file| UploadedFileParts { file, local })
}
+106 -72
View File
@@ -1,5 +1,4 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*;
@@ -10,10 +9,9 @@ use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
Window, div, img, list, px, red, relative, retain_all, svg, white,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
@@ -22,6 +20,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, Textar
use gpui_kit::component::menu::DropdownMenu;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
use gpui_kit::component::tooltip::Tooltip;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use itertools::Itertools;
@@ -31,7 +30,7 @@ use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
@@ -93,6 +92,9 @@ pub struct ChatPanel {
/// Chat input state
input: Entity<TextareaState>,
/// Dictation session for the chat input
speech: Entity<SpeechState>,
/// Subject input state
subject_input: Entity<InputState>,
@@ -118,7 +120,7 @@ pub struct ChatPanel {
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
subscriptions: SmallVec<[Subscription; 3]>,
subscriptions: SmallVec<[Subscription; 4]>,
}
impl ChatPanel {
@@ -162,6 +164,9 @@ impl ChatPanel {
.clean_on_escape()
});
// Define dictation state
let speech = cx.new(SpeechState::new);
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
let subject_bar = cx.new(|_cx| false);
@@ -180,15 +185,28 @@ impl ChatPanel {
subscriptions.push(
// Subscribe the subject input event
cx.subscribe_in(
&subject_input,
window,
move |this, _input, event, window, cx| {
cx.subscribe_in(&subject_input, window, move |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_subject(window, cx);
};
},
),
}),
);
subscriptions.push(
// Insert the dictated text into the chat input
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
match event {
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
}
_ => {}
};
}),
);
// Define all functions that will run after the current cycle
@@ -208,6 +226,7 @@ impl ChatPanel {
directory,
list_state,
input,
speech,
subject_input,
subject_bar,
replies_to,
@@ -642,22 +661,17 @@ impl ChatPanel {
// Encrypt attachments which are not part of a message being written
let encrypted = self.input.read(cx).value().trim().is_empty();
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
// Ask the user for a file to upload
let picker = pick_file(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
let Some(picked) = picker.await? else {
return Ok(());
};
this.update_in(cx, |this, window, cx| {
this.upload_file(server, path, encrypted, window, cx);
this.upload_file(server, picked, encrypted, window, cx);
})?;
Ok(())
@@ -668,7 +682,7 @@ impl ChatPanel {
fn upload_file(
&mut self,
server: Url,
path: PathBuf,
picked: PickedFile,
encrypted: bool,
window: &mut Window,
cx: &mut Context<Self>,
@@ -676,25 +690,18 @@ impl ChatPanel {
self.set_uploading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = if encrypted {
upload_encrypted(server.clone(), path.clone(), cx)
.await
.map(|file| Uploaded::File(file, path.clone()))
} else {
upload(server.clone(), path.clone(), cx)
.await
.map(Uploaded::Url)
};
let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await;
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
match result {
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
Err(e) if encrypted => {
this.report_encrypted_upload_error(server, path, e, window, cx)
Ok(Uploaded::File(uploaded)) => {
let UploadedFile { file, local } = *uploaded;
this.add_pending_file(file, local, cx);
}
Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx),
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
@@ -711,8 +718,6 @@ impl ChatPanel {
/// Report a failed encrypted upload, offering to retry it without encryption
fn report_encrypted_upload_error(
&mut self,
server: Url,
path: PathBuf,
error: Error,
window: &mut Window,
cx: &mut Context<Self>,
@@ -724,14 +729,12 @@ impl ChatPanel {
.title("Encrypted upload failed")
.action(move |_this, _window, _cx| {
let view = view.clone();
let server = server.clone();
let path = path.clone();
Button::new("retry-without-encryption")
.label("Upload without encryption")
.label("Pick the file again without encryption")
.on_click(move |_ev, window, cx| {
view.update(cx, |this, cx| {
this.upload_file(server.clone(), path.clone(), false, window, cx);
this.upload(window, cx);
})
.ok();
})
@@ -761,9 +764,9 @@ impl ChatPanel {
});
}
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
this.push(PendingFile { file, path });
this.push(PendingFile { file, local });
cx.notify();
});
}
@@ -782,12 +785,13 @@ impl ChatPanel {
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
let result = download_and_decrypt_file(&file, cx).await;
this.update(cx, |this, cx| {
match result {
Ok(path) => {
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
Ok(downloaded) => {
this.decrypted_files
.insert(id, DecryptedFile::Ready(downloaded.local));
}
Err(e) => {
this.decrypted_files
@@ -802,7 +806,7 @@ impl ChatPanel {
}));
}
/// Decrypt the attachment of a file message and open it with the OS
/// Decrypt the attachment of a file message and open it with the platform handler
fn open_file(
&mut self,
id: EventId,
@@ -810,25 +814,30 @@ impl ChatPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.decrypted_files.get(&id) {
Some(DecryptedFile::Ready(path)) => {
cx.open_url(&file_url(path));
if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) {
let _ = local.open(cx).map_err(|e| {
window.push_notification(Notification::error(e.to_string()).autohide(false), cx);
});
return;
}
if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) {
return;
}
Some(DecryptedFile::Loading) => return,
_ => {}
};
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
let result = download_and_decrypt_file(&file, cx).await;
this.update_in(cx, |this, _window, cx| {
this.update_in(cx, |this, window, cx| {
match result {
Ok(path) => {
cx.open_url(&file_url(&path));
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
Ok(downloaded) => {
if let Err(e) = downloaded.open(cx) {
window.push_notification(Notification::error(e.to_string()), cx);
}
this.decrypted_files
.insert(id, DecryptedFile::Ready(downloaded.local));
}
Err(e) => {
this.decrypted_files
@@ -1402,18 +1411,23 @@ impl ChatPanel {
}
let reply_id = *id;
let copy_id = *id;
let dropdown_id = *id;
let public_key = *public_key;
actions
.reply(cx.listener(move |this, _event, _window, cx| {
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}))
.copy(cx.listener(move |this, _event, _window, cx| {
this.copy_message(&copy_id, cx);
}))
.dropdown(move |menu, _window, _cx| {
}));
let actions = if let Some(message) = self.message(id) {
actions.copy(
SharedString::from(format!("message-copy-{id}")),
message.preview(),
)
} else {
actions
};
actions.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
@@ -1475,16 +1489,16 @@ impl ChatPanel {
) -> AnyElement {
let state = self.decrypted_files.get(id);
if let Some(path) = state
if let Some(local) = state
.and_then(|state| match state {
DecryptedFile::Ready(path) => Some(path),
DecryptedFile::Ready(local) => Some(local),
_ => None,
})
.filter(|_| file.is_image())
{
return div()
.child(
img(path.clone())
img(local.image_source())
.border_1()
.border_color(cx.theme().border)
.h(px(250.))
@@ -1560,7 +1574,7 @@ impl ChatPanel {
.map(|this| {
if file.is_image() {
this.child(
img(pending.path.clone())
img(pending.local.image_source())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
@@ -1814,6 +1828,7 @@ impl Render for ChatPanel {
let is_typing = !self.input.read(cx).value().trim().is_empty();
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
let capturing = self.speech.read(cx).status().is_capturing();
v_flex()
.image_cache(retain_all(self.id.clone()))
@@ -1891,7 +1906,7 @@ impl Render for ChatPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Plus)
.icon(IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
@@ -1900,7 +1915,26 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
.child(
h_flex()
.items_end()
.gap_1()
.pl_1()
.when(capturing, |this| {
this.child(
SpeechWaveform::new(&self.speech).xsmall().w_12(),
)
})
.child(SpeechButton::new(&self.speech)),
)
.child(
h_flex()
.pl_1()
+4
View File
@@ -16,3 +16,7 @@ futures.workspace = true
dirs = "5.0"
regex = "1.10"
anyhow.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Window", "Storage"] }
+10 -7
View File
@@ -1,13 +1,16 @@
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
mod debounced_delay;
mod display;
mod event;
mod media_extractor;
mod parser;
mod paths;
#[cfg(target_arch = "wasm32")]
pub mod web_store;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
+32
View File
@@ -0,0 +1,32 @@
use anyhow::{Error, anyhow};
const LOCAL_STORAGE: &str = "localStorage";
fn storage() -> Result<web_sys::Storage, Error> {
web_sys::window()
.ok_or_else(|| anyhow!("no browser window"))?
.local_storage()
.map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))?
.ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable"))
}
/// Read a value previously written with [`set`]
pub fn get(key: &str) -> Result<Option<String>, Error> {
storage()?
.get_item(key)
.map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}"))
}
/// Write a value, replacing any previous one
pub fn set(key: &str, value: &str) -> Result<(), Error> {
storage()?
.set_item(key, value)
.map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}"))
}
/// Remove a value if it exists
pub fn remove(key: &str) -> Result<(), Error> {
storage()?
.remove_item(key)
.map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}"))
}
+1 -1
View File
@@ -12,7 +12,7 @@ nip29 = { path = "../nip29" }
person = { path = "../person" }
settings = { path = "../settings" }
state = { path = "../state" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
ui = { path = "../ui" }
gpui.workspace = true
+45 -16
View File
@@ -4,9 +4,8 @@ use anyhow::Result;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render,
SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -16,6 +15,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
@@ -23,7 +23,7 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
@@ -313,6 +313,8 @@ pub struct GroupPanel {
reply_to: Option<EventId>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
/// Dictation session for the composer input
speech: Entity<SpeechState>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -320,7 +322,7 @@ pub struct GroupPanel {
/// Whether the user closed the encryption notice this visit
notice_dismissed: bool,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl GroupPanel {
@@ -339,6 +341,8 @@ impl GroupPanel {
.clean_on_escape()
});
let speech = cx.new(SpeechState::new);
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -349,6 +353,22 @@ impl GroupPanel {
}),
);
subscriptions.push(
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
match event {
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
}
_ => {}
};
}),
);
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
if !matches!(event, GroupEvent::Sent(_)) {
this.sync(cx);
@@ -384,6 +404,7 @@ impl GroupPanel {
notice_dismissed: false,
rendered_texts_by_id: BTreeMap::new(),
input,
speech,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![],
@@ -553,22 +574,17 @@ impl GroupPanel {
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let picker = pick_file(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
let Some(picked) = picker.await? else {
return Ok(());
};
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
match upload(server, path, cx).await {
match upload_file(server, picked, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.attachments.push(url);
@@ -1061,8 +1077,8 @@ impl GroupPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Upload)
.tooltip("Upload file")
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
@@ -1070,12 +1086,25 @@ impl GroupPanel {
this.upload(window, cx);
})),
)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Textarea::new(&self.input)
.appearance(false)
.bg(cx.theme().input)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
.when(self.speech.read(cx).status().is_capturing(), |this| {
this.child(SpeechWaveform::new(&self.speech).xsmall().w_12())
})
.child(SpeechButton::new(&self.speech))
.child(
Button::new("send")
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.disabled(self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
+24 -12
View File
@@ -18,7 +18,7 @@ use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar;
const IDENTIFIER: &str = "coop:device";
@@ -221,26 +221,39 @@ impl DeviceRegistry {
cx.emit(DeviceEvent::Set);
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
/// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let keys = get_keys(&client, &signer).await?;
let content = keys.secret_key().to_bech32()?;
if cfg!(target_arch = "wasm32") {
return Err(anyhow!("Not supported"));
Ok(keys.secret_key().to_bech32()?)
})
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
let backup = self.backup_bytes(cx);
#[cfg(not(target_arch = "wasm32"))]
return cx.background_spawn(async move {
let content = backup.await?;
smol::fs::write(path, &content).await?;
Ok(())
});
#[cfg(target_arch = "wasm32")]
{
let _ = path;
cx.background_spawn(async move {
backup.await?;
Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead."))
})
}
}
/// Get device announcement for current user
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
@@ -827,10 +840,10 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = cx.read_credentials(CLIENT_NAME);
let read = credentials::read_app_keys(cx);
cx.spawn(async move |cx| {
if let Ok(Some((_, secret))) = read.await
if let Ok(Some(secret)) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
@@ -838,14 +851,13 @@ fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let user = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
let write = credentials::write_app_keys(cx, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Keyring not available or panic: {e}")
log::error!("Failed to persist app keys: {e}")
}
})
.detach();
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
common = { path = "../common" }
state = { path = "../state" }
futures.workspace = true
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
+36 -17
View File
@@ -3,7 +3,10 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow};
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use futures::{FutureExt, pin_mut};
use gpui::{
App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
@@ -98,8 +101,9 @@ impl PersonRegistry {
}));
let client3 = client.clone();
let executor = cx.background_executor().clone();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx).await;
Self::handle_requests(&client3, &metadata_rx, executor).await;
}));
tasks.push(cx.spawn(async move |this, cx| {
@@ -193,30 +197,45 @@ impl PersonRegistry {
}
/// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
async fn handle_requests(
client: &Client,
rx: &flume::Receiver<PublicKey>,
executor: BackgroundExecutor,
) {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(TIMEOUT))
{
Ok(Some(public_key)) => {
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
_ => {
let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
pin_mut!(timer);
let request = futures::select! {
result = rx.recv_async().fuse() => match result {
Ok(public_key) => public_key,
Err(_) => {
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
return;
}
},
() = timer => {
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
continue;
}
};
batch.insert(request);
// Process the batch if it's full
if batch.len() >= 20
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
}
+8 -1
View File
@@ -232,6 +232,7 @@ impl WebOfTrust {
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
self.syncing = true;
cx.notify();
self.refresh_task = Some(cx.spawn(async move |this, cx| {
// Scope the reconcile to the follow set known so far
@@ -265,12 +266,13 @@ impl WebOfTrust {
this.snapshot = Some(snapshot);
cx.emit(WoTEvent);
cx.notify();
if grew {
this.sync(cx);
}
}
cx.notify();
})
.ok();
}));
@@ -314,6 +316,11 @@ impl WebOfTrust {
.is_some_and(|snapshot| !snapshot.disabled)
}
/// Whether a reconciliation with the relays is in flight
pub fn is_syncing(&self) -> bool {
self.syncing
}
/// Whether the snapshot covers a follow set
fn has_data(&self) -> bool {
self.snapshot
+31 -17
View File
@@ -1,12 +1,17 @@
use std::path::PathBuf;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
/// Browser storage key for the settings JSON
#[cfg(target_arch = "wasm32")]
const SETTINGS_KEY: &str = "coop.settings";
/// The user's appearance preference, persisted with the rest of the settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode {
@@ -63,21 +68,21 @@ pub fn init(cx: &mut App) {
}
/// Read the persisted appearance mode synchronously.
///
/// The async load fills the global settings later; the native theme must be
/// applied before the first window opens, so boot reads the mode straight
/// from the settings file instead.
fn persisted_settings() -> Option<Settings> {
#[cfg(not(target_arch = "wasm32"))]
{
let content = persisted_content();
content.and_then(|content| serde_json::from_str(&content).ok())
}
/// The persisted settings JSON, from the config file or browser storage
#[cfg(not(target_arch = "wasm32"))]
fn persisted_content() -> Option<String> {
let path = config_dir().join(".settings");
if let Ok(content) = std::fs::read_to_string(path)
&& let Ok(settings) = serde_json::from_str::<Settings>(&content)
{
return Some(settings);
}
}
None
std::fs::read_to_string(path).ok()
}
#[cfg(target_arch = "wasm32")]
fn persisted_content() -> Option<String> {
common::web_store::get(SETTINGS_KEY).ok().flatten()
}
pub fn persisted_appearance_mode() -> AppearanceMode {
@@ -87,10 +92,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode {
}
/// Read the persisted theme tuning synchronously.
///
/// Like [`persisted_appearance_mode`], the native theme is applied before the
/// async settings load completes, so boot reads the tuning straight from the
/// settings file instead.
pub fn persisted_theme() -> ThemeSettings {
persisted_settings()
.map(|settings| settings.theme)
@@ -357,6 +358,14 @@ impl AppSettings {
return Ok(serde_json::from_str(&content)?);
}
}
#[cfg(target_arch = "wasm32")]
{
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
return Ok(serde_json::from_str(&content)?);
}
}
Err(anyhow!("Not found"))
});
@@ -389,6 +398,11 @@ impl AppSettings {
smol::fs::write(&path, content).await.ok();
})
.detach();
#[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}");
}
}
}
+18
View File
@@ -20,6 +20,7 @@ futures.workspace = true
anyhow.workspace = true
webbrowser.workspace = true
serde_json.workspace = true
log.workspace = true
mime_guess = "2.0.4"
@@ -28,7 +29,24 @@ sha2.workspace = true
data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
nostr-browser-signer.workspace = true
nostr-memory.workspace = true
nostr-sqlite.workspace = true
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Blob",
"BlobPropertyBag",
"File",
"FileList",
"HtmlAnchorElement",
"HtmlInputElement",
"Url",
"Window",
] }
sqlite-wasm-rs = "0.5"
sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
+21 -18
View File
@@ -1,17 +1,10 @@
use std::path::PathBuf;
use anyhow::{Error, anyhow};
use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::file::sha256_hex;
/// Upload a blob to a blossom server and return its URL
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn upload_blob(
@@ -41,16 +34,26 @@ pub(crate) async fn upload_blob(
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(&path).await?;
let sha256 = sha256_hex(&data);
upload_blob(&server, data, &content_type, &sha256, cx).await
}
/// Upload a blob to a blossom server and return its URL.
#[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
Err(anyhow!("File upload not supported on web"))
pub(crate) async fn upload_blob(
server: &Url,
data: Vec<u8>,
content_type: &str,
sha256: &str,
_cx: &AsyncApp,
) -> Result<Url, Error> {
let client = BlossomClient::new(server.clone());
let keys = Keys::generate();
let base = server.clone();
let hash = sha256.to_string();
match client
.upload_blob(data, Some(content_type.to_string()), None, Some(&keys))
.await
{
Ok(blob) => Ok(blob.url),
Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?),
Err(e) => Err(anyhow!(e.to_string())),
}
}
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Result;
#[cfg(target_arch = "wasm32")]
use common::web_store;
use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING;
/// The storage key for the NIP-4e app keys
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
/// Read the stored credential as `(username, secret)`
#[allow(clippy::type_complexity)]
pub(crate) fn read(cx: &App) -> Task<Result<Option<(String, Vec<u8>)>>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.read_credentials(USER_KEYRING);
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
Task::ready(
web_store::get(USER_KEYRING)
.map(|stored| stored.map(|content| (String::new(), content.into_bytes()))),
)
}
}
/// Write the stored credential
pub(crate) fn write(cx: &App, username: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.write_credentials(USER_KEYRING, username, secret);
#[cfg(target_arch = "wasm32")]
{
let _ = (cx, username);
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("credential is not UTF-8: {e}"))),
};
Task::ready(web_store::set(USER_KEYRING, content))
}
}
/// Delete the stored credential
pub(crate) fn delete(cx: &App) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
return cx.delete_credentials(USER_KEYRING);
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
Task::ready(web_store::remove(USER_KEYRING))
}
}
/// Read named bytes from the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn read_named(cx: &App, key: &str) -> Task<Result<Option<Vec<u8>>>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let read = cx.read_credentials(&key);
cx.background_spawn(async move { Ok(read.await?.map(|(_, secret)| secret)) })
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let stored = web_store::get(key).map(|content| content.map(|s| s.into_bytes()));
Task::ready(stored)
}
}
/// Write named bytes to the platform keyring,
/// falling back to browser storage on web where no keyring exists
fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
#[cfg(not(target_arch = "wasm32"))]
{
let key = key.to_owned();
let secret = secret.to_vec();
let write = cx.write_credentials(&key, "", &secret);
cx.background_spawn(write)
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
let content = match std::str::from_utf8(secret) {
Ok(content) => content,
Err(e) => return Task::ready(Err(anyhow::anyhow!("secret is not UTF-8: {e}"))),
};
Task::ready(web_store::set(key, content))
}
}
/// Load the NIP-4e app keys from storage, as raw secret bytes
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, APP_KEYS_KEY)
}
/// Persist the NIP-4e app keys, as raw secret bytes
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
write_named(cx, APP_KEYS_KEY, secret)
}
+194 -52
View File
@@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
use anyhow::{Error, anyhow, bail};
use data_encoding::HEXLOWER;
use futures::AsyncReadExt;
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString};
use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task};
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash;
@@ -115,6 +117,56 @@ impl FileAttachment {
}
}
/// A file the user picked, read into memory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickedFile {
pub name: String,
pub mime: String,
pub data: Vec<u8>,
pub local: LocalFile,
}
/// A handle to a file stored on the user's device
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalFile {
/// A path on disk (desktop)
Path(PathBuf),
/// An in-memory blob URL (web)
Url(String),
}
impl LocalFile {
/// The source an `img` element can render
pub fn image_source(&self) -> gpui::ImageSource {
match self {
LocalFile::Path(path) => path.clone().into(),
LocalFile::Url(url) => SharedUri::from(url.clone()).into(),
}
}
/// The URL the platform opener understands
pub fn open_url(&self) -> String {
match self {
LocalFile::Path(path) => format!("file://{}", path.display()),
LocalFile::Url(url) => url.clone(),
}
}
/// Open the file with the platform handler
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
match self {
LocalFile::Path(_) => {
cx.open_url(&self.open_url());
Ok(())
}
#[cfg(target_arch = "wasm32")]
LocalFile::Url(url) => crate::web_file::open_url(url),
#[cfg(not(target_arch = "wasm32"))]
LocalFile::Url(_) => Ok(()),
}
}
}
pub(crate) fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
@@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String {
Sha256Hash::from_byte_array(hash).to_hex()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(
server: Url,
path: PathBuf,
cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
/// Ask the user for a single file and read it into memory
pub fn pick_file(cx: &gpui::App) -> Task<Result<Option<PickedFile>, Error>> {
#[cfg(not(target_arch = "wasm32"))]
{
let dialog = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.background_spawn(async move {
let Some(path) = dialog.await?? else {
return Ok(None);
};
let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?;
let mime = from_path(&path).first_or_octet_stream().to_string();
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let data = smol::fs::read(&path).await?;
let encrypted = encrypt(&data)?;
Ok(Some(PickedFile {
name,
mime,
data,
local: LocalFile::Path(path),
}))
})
}
#[cfg(target_arch = "wasm32")]
{
let _ = cx;
cx.background_spawn(async move { crate::web_file::pick_file().await })
}
}
/// Upload a file unencrypted and return its URL
pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result<Url, Error> {
let PickedFile { mime, data, .. } = file;
let sha256 = sha256_hex(&data);
crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await
}
/// Upload a file encrypted as an opaque blob, returning the attachment metadata
pub async fn upload_encrypted_file(
server: Url,
file: PickedFile,
cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
let encrypted = encrypt(&file.data)?;
let sha256 = sha256_hex(&encrypted.data);
let original_sha256 = sha256_hex(&data);
let original_sha256 = sha256_hex(&file.data);
let size = encrypted.data.len() as u64;
let base_url = server.to_string();
@@ -207,26 +301,17 @@ pub async fn upload_encrypted(
Ok(FileAttachment {
url,
mime,
mime: file.mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256: Some(sha256),
original_sha256: Some(original_sha256),
size: Some(size),
dim: None,
name,
name: Some(file.name),
})
}
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
Err(anyhow!("File upload not supported on web"))
}
pub(crate) async fn download_and_decrypt(
url: &Url,
key: &str,
@@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt(
decrypt(&data, key, nonce)
}
/// Download and decrypt a file attachment into a temporary file.
///
/// The same attachment always maps to the same path, so callers can render the
/// result directly (e.g. with `img`) without downloading it more than once.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let name = file
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
/// A downloaded and decrypted attachment on the user's device
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadedFile {
pub local: LocalFile,
pub mime: String,
pub name: String,
/// The decrypted bytes, kept so the web can offer them as a download
#[cfg(target_arch = "wasm32")]
pub(crate) data: Vec<u8>,
}
let extension = mime_guess::get_mime_extensions_str(&file.mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{name}.{extension}"));
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
impl DownloadedFile {
pub fn open(&self, cx: &gpui::App) -> Result<(), Error> {
match &self.local {
LocalFile::Path(path) => {
cx.open_with_system(path);
Ok(())
}
#[cfg(target_arch = "wasm32")]
LocalFile::Url(url) => {
if self.mime.starts_with("image/") {
crate::web_file::open_url(url)
} else {
crate::web_file::download(&self.name, &self.mime, &self.data)
}
}
#[cfg(not(target_arch = "wasm32"))]
LocalFile::Url(_) => Ok(()),
}
}
/// Save the file to the user's downloads (web)
#[cfg(target_arch = "wasm32")]
pub fn save(&self) -> Result<(), Error> {
crate::web_file::download(&self.name, &self.mime, &self.data)
}
}
/// Download and decrypt a file attachment to the user's device
pub async fn download_and_decrypt_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<DownloadedFile, Error> {
let data = download_and_decrypt(
&file.url,
&file.key,
@@ -297,6 +398,37 @@ pub async fn download_and_decrypt_to_file(
)
.await?;
let name = file
.name
.clone()
.unwrap_or_else(|| fallback_name(&file.url));
let local = store_file(&name, &file.mime, &data).await?;
Ok(DownloadedFile {
local,
mime: file.mime.clone(),
name,
#[cfg(target_arch = "wasm32")]
data,
})
}
/// Persist decrypted bytes somewhere the platform can render or open them
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
#[cfg(not(target_arch = "wasm32"))]
{
let digest = sha256_hex(name.as_bytes());
let extension = mime_guess::get_mime_extensions_str(mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{digest}.{extension}"));
if smol::fs::metadata(&path).await.is_err() {
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
@@ -306,16 +438,26 @@ pub async fn download_and_decrypt_to_file(
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
}
Ok(path)
Ok(LocalFile::Path(path))
}
#[cfg(target_arch = "wasm32")]
{
let _ = name;
let url = crate::web_file::blob_url(mime, data)?;
Ok(LocalFile::Url(url))
}
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_file(
_file: &FileAttachment,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("File download not supported on web"))
/// A file name derived from the attachment URL's last path segment
fn fallback_name(url: &Url) -> String {
url.path()
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or("attachment")
.to_string()
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
+63 -18
View File
@@ -4,26 +4,33 @@ use std::sync::Arc;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
use instant::Duration;
#[cfg(target_arch = "wasm32")]
use nostr_browser_signer::BrowserSigner;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(target_arch = "wasm32")]
use nostr_memory::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
pub mod credentials;
mod file;
mod nip05;
mod nip4e;
mod signer;
pub use blossom::*;
#[cfg(target_arch = "wasm32")]
pub mod web_database;
#[cfg(target_arch = "wasm32")]
pub mod web_file;
pub use constants::*;
pub use file::*;
pub use nip4e::*;
@@ -107,7 +114,6 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
// Construct the nostr lmdb instance
#[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
@@ -116,7 +122,7 @@ impl NostrRegistry {
});
#[cfg(target_arch = "wasm32")]
let database = MemoryDatabase::unbounded();
let database = web_database::take();
// Construct the nostr client
let client = ClientBuilder::default()
@@ -137,9 +143,7 @@ impl NostrRegistry {
let _ = entity.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as restore_session
let keys = Keys::new(secret);
this.set_signer(keys, cx);
@@ -241,7 +245,7 @@ impl NostrRegistry {
/// Restore the signer from the stored credential
fn restore_session(&mut self, cx: &mut Context<Self>) {
let user_keyring = cx.read_credentials(USER_KEYRING);
let user_keyring = credentials::read(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = async {
@@ -278,6 +282,11 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
} else if content == "extension" {
#[cfg(target_arch = "wasm32")]
this.update(cx, |this, cx| {
this.connect_extension(cx);
})?;
} else {
this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner);
@@ -313,7 +322,7 @@ impl NostrRegistry {
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let password = password.to_owned();
let user_keyring = cx.read_credentials(USER_KEYRING);
let user_keyring = credentials::read(cx);
cx.spawn(async move |this, cx| {
let content = user_keyring
@@ -372,9 +381,8 @@ impl NostrRegistry {
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
let write =
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), ncryptsec.as_bytes()));
write.await?;
this.update(cx, |this, cx| {
@@ -411,9 +419,8 @@ impl NostrRegistry {
let keys = decrypt_task.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
});
let write =
cx.update(|cx| credentials::write(cx, &public_key.to_hex(), stored.as_bytes()));
write.await?;
this.update(cx, |this, cx| {
@@ -439,7 +446,7 @@ impl NostrRegistry {
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
let write = credentials::write(cx, "bunker", credential.as_bytes());
cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys)
@@ -463,12 +470,50 @@ impl NostrRegistry {
self.current_user = None;
self.passphrase_required = false;
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
credentials::delete(cx).detach_and_log_err(cx);
cx.emit(StateEvent::SignerChanged);
cx.emit(StateEvent::NoSigner);
cx.notify();
}
/// Connect the browser extension signer (NIP-07).
#[cfg(target_arch = "wasm32")]
pub fn connect_extension(&mut self, cx: &mut Context<Self>) {
let signer = match BrowserSigner::new() {
Ok(signer) => signer,
Err(e) => {
cx.emit(StateEvent::error(format!("No Nostr extension found: {e}.")));
cx.emit(StateEvent::NoSigner);
return;
}
};
self.tasks.push(cx.spawn(async move |this, cx| {
match signer.get_public_key_async().await {
Ok(public_key) => {
this.update(cx, |this, cx| {
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
this.signer.swap_inner(signer);
this.current_user = Some(public_key);
this.passphrase_required = false;
cx.emit(StateEvent::SignerChanged);
cx.notify();
})?;
}
Err(e) => {
this.update(cx, |_, cx| {
credentials::delete(cx).detach_and_log_err(cx);
cx.emit(StateEvent::error(e.to_string()));
cx.emit(StateEvent::NoSigner);
})?;
}
};
Ok(())
}));
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
+6
View File
@@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move {
#[cfg(not(target_arch = "wasm32"))]
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
#[cfg(target_arch = "wasm32")]
crate::web_file::open_url(auth_url.as_str())
.map_err(nostr_connect::error::Error::other)?;
Ok(())
})
}
+62
View File
@@ -0,0 +1,62 @@
use std::cell::RefCell;
use std::sync::Arc;
use nostr_memory::prelude::*;
use nostr_sqlite::prelude::NostrSqlite;
use sqlite_wasm_rs::WasmOsCallback;
use sqlite_wasm_vfs::relaxed_idb;
const DATABASE_PATH: &str = "coop.sqlite3";
const VFS_NAME: &str = "coop-relaxed-idb";
thread_local! {
static DATABASE: RefCell<Option<NostrSqlite>> = const { RefCell::new(None) };
}
/// Install the IndexedDB storage backend and open the database.
pub async fn open() {
match open_persistent().await {
Some(database) => DATABASE.with(|slot| *slot.borrow_mut() = Some(database)),
None => log::error!("Falling back to the in-memory nostr database"),
}
}
/// Hand the opened database to the client constructor
pub(crate) fn take() -> Arc<dyn NostrDatabase> {
let database = DATABASE.with(|slot| slot.borrow_mut().take());
match database {
Some(database) => Arc::new(database),
None => {
log::error!("Persistent web database was not opened, falling back to memory");
Arc::new(MemoryDatabase::unbounded())
}
}
}
/// Open the database on IndexedDB storage, or `None` when unavailable
async fn open_persistent() -> Option<NostrSqlite> {
let options = relaxed_idb::RelaxedIdbCfgBuilder::new()
.vfs_name(VFS_NAME)
.build();
if let Err(e) = relaxed_idb::install::<WasmOsCallback>(&options, true).await {
log::warn!("IndexedDB storage unavailable: {e:?}");
return None;
}
match NostrSqlite::builder()
.persistent_with_vfs(DATABASE_PATH, VFS_NAME)
.build()
.await
{
Ok(database) => {
log::info!("Nostr database opened on IndexedDB storage");
Some(database)
}
Err(e) => {
log::error!("Failed to open the nostr database on IndexedDB: {e:?}");
None
}
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Browser file interoperability: a persistent hidden `<input type="file">`
//! picker, blob URLs for rendering, and anchor-based downloads.
use std::cell::RefCell;
use anyhow::{Error, anyhow};
use flume::Sender;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window};
use crate::file::PickedFile;
type PickResult = Result<Option<PickedFile>, Error>;
thread_local! {
/// The picker input lives for the whole page so its change closure does
/// not have to be leaked or re-registered on every pick
static PICKER: RefCell<Option<Picker>> = const { RefCell::new(None) };
/// The sender the active pick delivers through
static PENDING: RefCell<Option<Sender<PickResult>>> = const { RefCell::new(None) };
}
struct Picker {
input: HtmlInputElement,
/// Keep the change handler alive for the life of the page
_on_change: Closure<dyn FnMut()>,
}
/// Ask the user for a single file and read its bytes
pub async fn pick_file() -> PickResult {
let (sender, receiver) = flume::bounded(1);
PICKER.with(|picker| -> Result<(), Error> {
let mut picker = picker.borrow_mut();
if picker.is_none() {
*picker = Some(init_picker()?);
}
let picker = picker.as_ref().expect("picker just initialized");
PENDING.with(|pending| *pending.borrow_mut() = Some(sender));
picker.input.click();
Ok(())
})?;
receiver
.recv_async()
.await
.map_err(|_| anyhow!("File picker closed"))?
}
fn init_picker() -> Result<Picker, Error> {
let document = js_window()
.ok_or_else(|| anyhow!("no browser window"))?
.document()
.ok_or_else(|| anyhow!("no browser document"))?;
let input = document
.create_element("input")
.map_err(|e| anyhow!("failed to create file input: {e:?}"))?
.dyn_into::<HtmlInputElement>()
.map_err(|_| anyhow!("created element is not an input"))?;
input.set_type("file");
input.style().set_property("display", "none").ok();
let on_change = Closure::wrap(Box::new(move || {
let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else {
return;
};
wasm_bindgen_futures::spawn_local(async move {
sender.send_async(read_selected_file().await).await.ok();
});
}) as Box<dyn FnMut()>);
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
body.append_child(&input)
.map_err(|e| anyhow!("failed to mount file input: {e:?}"))?;
Ok(Picker {
input,
_on_change: on_change,
})
}
/// Read the first file of the picker input, if one was chosen
async fn read_selected_file() -> PickResult {
let picker = PICKER
.with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone()))
.ok_or_else(|| anyhow!("file picker is not initialized"))?;
let Some(file) = picker.files().and_then(|files| files.get(0)) else {
// The dialog was dismissed without choosing a file
return Ok(None);
};
let mime = non_empty(file.type_());
let data = read_file_bytes(&file).await?;
let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?);
Ok(Some(PickedFile {
name: file.name(),
mime,
data,
local,
}))
}
/// Read a browser `File` into memory
async fn read_file_bytes(file: &File) -> Result<Vec<u8>, Error> {
let promise = file.array_buffer();
let buffer = wasm_bindgen_futures::JsFuture::from(promise)
.await
.map_err(|e| anyhow!("failed to read file: {e:?}"))?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
/// Create an object URL for bytes, so images and downloads render from memory
pub fn blob_url(mime: &str, data: &[u8]) -> Result<String, Error> {
let array = js_sys::Uint8Array::from(data);
let parts = js_sys::Array::new();
parts.push(&array);
let bag = web_sys::BlobPropertyBag::new();
bag.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag)
.map_err(|e| anyhow!("failed to create blob: {e:?}"))?;
DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}"))
}
/// Offer bytes to the user as a browser download
pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> {
let window = js_window().ok_or_else(|| anyhow!("no browser window"))?;
let document = window
.document()
.ok_or_else(|| anyhow!("no browser document"))?;
let url = blob_url(mime, data)?;
let anchor: HtmlAnchorElement = document
.create_element("a")
.map_err(|e| anyhow!("failed to create download link: {e:?}"))?
.dyn_into()
.map_err(|_| anyhow!("created element is not an anchor"))?;
anchor.set_href(&url);
anchor.set_download(name);
anchor.style().set_property("display", "none").ok();
let body = document.body().ok_or_else(|| anyhow!("no browser body"))?;
body.append_child(&anchor)
.map_err(|e| anyhow!("failed to mount download link: {e:?}"))?;
anchor.click();
body.remove_child(&anchor)
.map_err(|e| anyhow!("failed to remove download link: {e:?}"))?;
// Keep the blob alive briefly so the download is not cancelled
let callback = Closure::wrap(Box::new(move || {
DomUrl::revoke_object_url(&url).ok();
}) as Box<dyn FnMut()>);
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
10_000,
)
.map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?;
// The closure only runs once, after which it can be released
callback.forget();
Ok(())
}
/// Open a URL in a new browser tab
pub fn open_url(url: &str) -> Result<(), Error> {
js_window()
.ok_or_else(|| anyhow!("no browser window"))?
.open_with_url_and_target(url, "_blank")
.map_err(|e| anyhow!("failed to open url: {e:?}"))?
.ok_or_else(|| anyhow!("the browser blocked opening the url"))?;
Ok(())
}
fn non_empty(value: impl Into<String>) -> String {
let value: String = value.into();
if value.is_empty() {
"application/octet-stream".to_string()
} else {
value
}
}
+2
View File
@@ -20,6 +20,7 @@ pub enum IconName {
Link,
Lock,
Message,
Paperclip,
PaperPlaneFill,
Plus,
PlusChat,
@@ -51,6 +52,7 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Paperclip => "icons/paperclip.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
+8 -13
View File
@@ -10,6 +10,7 @@ use gpui::{
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
@@ -602,7 +603,7 @@ pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<SharedClickListener>,
copy: Option<(ElementId, SharedString)>,
dropdown: Option<DropdownBuilder>,
}
@@ -634,9 +635,10 @@ impl MessageActions {
self
}
/// Add the copy button.
pub fn copy(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.copy = Some(Rc::new(handler));
/// Add the copy button for the given value.
/// The id must be unique per bar, the clipboard component keys its copied state on it.
pub fn copy(mut self, id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
self.copy = Some((id.into(), value.into()));
self
}
@@ -707,15 +709,8 @@ impl RenderOnce for MessageActions {
.on_click(move |event, window, cx| reply(event, window, cx)),
)
})
.when_some(copy, |this, copy| {
this.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
.small()
.ghost()
.on_click(move |event, window, cx| copy(event, window, cx)),
)
.when_some(copy, |this, (id, value)| {
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
})
.when_some(dropdown, |this, dropdown| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
+1
View File
@@ -27,4 +27,5 @@ nostr-connect.workspace = true
anyhow.workspace = true
serde.workspace = true
log.workspace = true
rand.workspace = true
smallvec.workspace = true
+43 -9
View File
@@ -9,7 +9,7 @@ use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
use instant::Duration;
use nostr_connect::prelude::*;
use state::NostrRegistry;
use state::{NostrRegistry, StateEvent};
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -29,8 +29,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(450.))
.close_button(false)
this.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
@@ -73,12 +72,13 @@ impl ImportIdentity {
}
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let subscriptions = vec![
let mut subscriptions = vec![
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(cx);
@@ -96,6 +96,14 @@ impl ImportIdentity {
}),
];
subscriptions.push(
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
if let StateEvent::Error(error) = event {
this.set_error(error.clone(), cx);
}
}),
);
Self {
mode,
key_input,
@@ -188,6 +196,14 @@ impl ImportIdentity {
});
}
#[cfg(target_arch = "wasm32")]
fn extension(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.connect_extension(cx);
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
@@ -226,7 +242,6 @@ impl Render for ImportIdentity {
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
let is_wasm = cfg!(target_arch = "wasm32");
let is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string();
@@ -305,7 +320,8 @@ impl Render for ImportIdentity {
)
}),
)
.child(
.child({
let content = v_flex().gap_1().child(
Button::new("login")
.label(if is_unlock { "Unlock" } else { "Continue" })
.primary()
@@ -314,17 +330,35 @@ impl Render for ImportIdentity {
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
)
.when(!is_wasm && !is_unlock, |this| {
);
#[cfg(not(target_arch = "wasm32"))]
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
});
#[cfg(target_arch = "wasm32")]
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("extension")
.label("Continue with Browser Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.extension(cx);
})),
)
});
content
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
+2 -4
View File
@@ -10,7 +10,6 @@ use gpui::{
Window, div, px, uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState};
@@ -205,6 +204,7 @@ impl NewChat {
Ok(())
}));
return;
}
@@ -369,15 +369,13 @@ impl NewChat {
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.text_xs()
.font_medium()
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(div().max_w_32().truncate().child(profile.name()))
.child(div().max_w_32().truncate().text_xs().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx);
}))
+6 -11
View File
@@ -2,8 +2,8 @@ use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement,
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems,
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Subscription, Task, WeakEntity, Window, div, px, rems,
};
use gpui_base::{Popup, Select};
use gpui_kit::assets::IconName;
@@ -18,7 +18,7 @@ use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use state::upload;
use state::{pick_file, upload_file};
use ui::{Avatar, settings_row};
const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"];
@@ -297,16 +297,11 @@ impl NewGroup {
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let picker = pick_file(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
let Some(picked) = picker.await? else {
return Ok(());
};
@@ -314,7 +309,7 @@ impl NewGroup {
this.set_uploading(true, cx);
})?;
match upload(server, path, cx).await {
match upload_file(server, picked, cx).await {
Ok(url) => {
this.update(cx, |this, cx| {
this.picture = Some(url.to_string().into());
+16 -64
View File
@@ -4,21 +4,21 @@ use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Task, Window, div, retain_all,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::DockPlacement;
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
use state::{NostrRegistry, upload};
use state::{NostrRegistry, pick_file, upload_file};
use ui::Avatar;
use crate::Workspace;
@@ -49,8 +49,6 @@ struct ProfileDialog {
website_input: Entity<InputState>,
/// Uploading state
uploading: bool,
/// Copied states
copied: bool,
/// Updating state
updating: bool,
/// Tasks
@@ -92,7 +90,6 @@ impl ProfileDialog {
bio_input,
website_input,
uploading: false,
copied: false,
updating: false,
tasks: vec![],
}
@@ -141,35 +138,6 @@ impl ProfileDialog {
cx.notify();
}
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Reset the copied state after a delay
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
Ok(())
}));
}
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
@@ -179,17 +147,12 @@ impl ProfileDialog {
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
// Ask the user for an image to upload
let picker = pick_file(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
let Some(picked) = picker.await? else {
return Ok(());
};
@@ -198,7 +161,7 @@ impl ProfileDialog {
})?;
// Upload via blossom client
match upload(server, path, cx).await {
match upload_file(server, picked, cx).await {
Ok(url) => {
this.update_in(cx, |this, window, cx| {
this.avatar_input.update(cx, |this, cx| {
@@ -373,7 +336,8 @@ impl Render for ProfileDialog {
.w_full()
.items_center()
.justify_center()
.bg(cx.theme().sidebar)
.rounded(cx.theme().radius_lg)
.bg(cx.theme().muted)
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
.when(self.editing, |this| {
this.child(
@@ -398,26 +362,14 @@ impl Render for ProfileDialog {
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().sidebar)
.bg(cx.theme().muted)
.text_xs()
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CircleCheck
} else {
IconName::Copy
}
})
.xsmall()
.secondary()
.on_click(cx.listener(move |this, _ev, window, cx| {
let pk = this.public_key.to_bech32().unwrap();
this.copy(pk, window, cx)
.child(Clipboard::new("copy").value_fn({
let public_key = self.public_key;
move |_, _| public_key.to_bech32().unwrap_or_default().into()
})),
),
),
)
.when(self.editing, |this| {
this.child(
@@ -549,7 +501,7 @@ fn field(
};
v_flex()
.gap_1p5()
.gap_1()
.child(label(text, cx))
.child(div().text_xs().text_color(color).child(content))
.child(div().text_sm().text_color(color).child(content))
}
+1 -1
View File
@@ -264,7 +264,7 @@ impl RelayManager {
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
TabBar::new("relays-tabs")
.segmented()
.pill()
.selected_index(self.active_tab)
.on_click(cx.listener(|this, ix: &usize, _window, cx| {
this.active_tab = *ix;
+19 -5
View File
@@ -1,7 +1,9 @@
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
use gpui::http_client::Url;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
Styled, Subscription, Window, div,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription,
Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
@@ -150,7 +152,18 @@ impl Preferences {
}
/// Ask the user for an image to show behind chat and group panels
fn choose_background(cx: &mut App) {
fn choose_background(
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
cx: &mut App,
) {
#[cfg(target_arch = "wasm32")]
{
let note = Notification::warning("Background images are not supported on web yet.");
window.push_notification(note, cx);
}
#[cfg(not(target_arch = "wasm32"))]
{
let task = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
@@ -174,6 +187,7 @@ impl Preferences {
})
.detach();
}
}
}
impl Render for Preferences {
@@ -330,8 +344,8 @@ impl Render for Preferences {
.ghost()
.small()
.disabled(!panel_background)
.on_click(|_, _window, cx| {
Self::choose_background(cx);
.on_click(|_, window, cx| {
Self::choose_background(window, cx);
}),
)
.child(
+1
View File
@@ -54,6 +54,7 @@ impl Render for DragPanelPreview {
.cursor_grab()
.p_1()
.whitespace_nowrap()
.font_family(cx.theme().font_family.clone())
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
+32 -5
View File
@@ -2,8 +2,10 @@ use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode};
use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
#[cfg(not(target_arch = "wasm32"))]
use common::download_dir;
use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
@@ -337,7 +339,11 @@ impl Workspace {
}
Command::BackupEncryption => {
let device = DeviceRegistry::global(cx).downgrade();
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
#[cfg(not(target_arch = "wasm32"))]
{
let save_dialog =
cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
// Get the output path from the save dialog
@@ -346,7 +352,8 @@ impl Workspace {
Ok(Ok(None)) | Err(_) => return Ok(()),
Ok(Err(error)) => {
cx.update(|window, cx| {
let message = format!("Failed to pick save location: {error:#}");
let message =
format!("Failed to pick save location: {error:#}");
let note = Notification::error(message).autohide(false);
window.push_notification(note, cx);
})?;
@@ -355,8 +362,8 @@ impl Workspace {
};
// Get the backup task
let backup =
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
let backup = device
.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
// Run the backup task
backup.await?;
@@ -369,10 +376,30 @@ impl Workspace {
Ok(())
}));
}
#[cfg(target_arch = "wasm32")]
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?;
let content = backup.await?;
cx.update(|_window, _cx| {
state::web_file::download(
"encryption.txt",
"text/plain",
content.as_bytes(),
)
})
.map_err(|e| anyhow::anyhow!(e))??;
Ok(())
}));
}
Command::ImportEncryption => {
self.import_encryption(window, cx);
}
Command::Update => {
Command::Update =>
{
#[cfg(not(target_arch = "wasm32"))]
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.check(cx));
}
+10 -49
View File
@@ -1,16 +1,14 @@
use anyhow::Error;
use gpui::{
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window, div,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
use instant::Duration;
use gpui_kit::component::{ActiveTheme, v_flex};
use nostr_sdk::prelude::*;
use state::USER_KEYRING;
@@ -32,9 +30,6 @@ pub struct BackupPanel {
/// Secret key input
nsec_input: Entity<InputState>,
/// Copied status
copied: bool,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -54,7 +49,6 @@ impl BackupPanel {
focus_handle: cx.focus_handle(),
npub_input,
nsec_input,
copied: false,
tasks: vec![],
}
}
@@ -81,31 +75,6 @@ impl BackupPanel {
Ok(())
}));
}
fn copy_secret(&mut self, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).value();
let item = ClipboardItem::new_string(value.to_string());
cx.write_to_clipboard(item);
// Set the copied status to true
self.set_copied(true, cx);
}
fn set_copied(&mut self, status: bool, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Clear the error message after a delay
this.update(cx, |this, cx| {
this.set_copied(false, cx);
})?;
Ok(())
}));
}
}
impl gpui_kit::component::dock::BasePanel for BackupPanel {
@@ -174,20 +143,12 @@ impl Render for BackupPanel {
.child(Input::new(&self.nsec_input).disabled(true)),
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.label({
if self.copied {
"Copied"
} else {
"Copy secret key"
}
})
.primary()
.small()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.copy_secret(cx);
})),
Clipboard::new("copy-secret")
.accessibility_label("Copy secret key")
.value_fn({
let nsec_input = self.nsec_input.clone();
move |_, cx| nsec_input.read(cx).value()
}),
),
)
}
+30 -25
View File
@@ -1,17 +1,19 @@
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity, IntoElement};
use gpui::{
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
Stateful, Styled, Window, div, px,
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName};
use super::Sidebar;
@@ -25,6 +27,7 @@ impl Sidebar {
current_user: &PublicKey,
cx: &mut Context<Self>,
) -> Stateful<Div> {
#[cfg(not(target_arch = "wasm32"))]
let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
@@ -35,23 +38,20 @@ impl Sidebar {
.h(TAB_BAR_HEIGHT)
.flex_shrink_0()
.justify_between()
.px_2()
.px_2p5()
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
.child(
Button::new("current-user")
Button::new("user")
.text()
.dropdown_caret(true)
.min_w_0()
.flex_shrink_1()
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.dropdown_menu(move |this, _window, _cx| {
this.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon("Relays", IconName::Relay, Box::new(Command::ShowRelayList))
this.menu("Profile", Box::new(Command::ShowProfile))
.menu("Relays", Box::new(Command::ShowRelayList))
.separator()
.label(format!("{CLIENT_NAME} {}", env!("CARGO_PKG_VERSION")))
.item(
PopupMenuItem::element(|_, cx| {
div().text_color(cx.theme().danger).child("Log out")
@@ -62,12 +62,15 @@ impl Sidebar {
)
}),
)
.child(
h_flex()
.gap_1()
.when_some(updater, |this, updater| {
.child({
let actions = h_flex().gap_1();
#[cfg(not(target_arch = "wasm32"))]
let actions = actions.when_some(updater, |this, updater| {
this.child(self.render_updater(updater, cx))
})
});
actions
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
@@ -104,20 +107,20 @@ impl Sidebar {
this.hide_group(cx);
})),
)
}),
)
})
})
}
/// The bottom navigation, pinned below the list like signed's sidebar.
pub(super) fn render_footer(&self, _cx: &mut Context<Self>) -> Div {
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
.flex_shrink_0()
.gap_1()
.items_start()
.justify_start()
.child(
Nav::new("sidebar-settings")
.when(!loading, |this| {
this.child(
Nav::new("settings")
.prefix(Icon::new(IconName::Settings).small())
.center_prefix(true)
.label("Settings")
@@ -126,8 +129,10 @@ impl Sidebar {
window.dispatch_action(Box::new(Command::ShowSettings), cx)
}),
)
})
}
#[cfg(not(target_arch = "wasm32"))]
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date();
+151 -40
View File
@@ -5,20 +5,22 @@ use chat::{ChatEvent, ChatRegistry};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
Task, WeakEntity, Window, div, img, retain_all,
App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
StyledImage, Subscription, Task, WeakEntity, Window, div, img, retain_all,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::base::{Presence, Transition};
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
use gpui_kit::component::input::{InputEvent, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::spinner::Spinner;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use gpui_kit::component::progress::ProgressCircle;
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room;
use search::SearchResult;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers;
@@ -33,8 +35,8 @@ mod onboarding;
mod rows;
mod search;
pub(crate) use rows::open_room;
use search::SearchResult;
const PROGRESS_TICK_MS: u64 = 500;
const PROGRESS_CAP: f32 = 95.0;
pub struct Sidebar {
focus_handle: FocusHandle,
@@ -88,6 +90,11 @@ pub struct Sidebar {
/// The async global profile search.
global_task: Option<Task<()>>,
/// Simulated gift wrap processing progress, shown while the backlog loads
chat_progress: f32,
/// Simulated web of trust sync progress
sync_progress: f32,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -97,7 +104,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let find_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -105,20 +111,22 @@ impl Sidebar {
.clean_on_escape()
});
let mut tasks = smallvec![];
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
cx.subscribe_in(&chat, window, move |this, _, event, _, cx| {
match event {
ChatEvent::Ping => {
this.new_requests = true;
cx.notify();
};
}),
);
subscriptions.push(
cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| {
}
ChatEvent::Loaded => {
this.chat_progress = 0.0;
cx.notify();
}
_ => {}
};
}),
);
@@ -126,7 +134,7 @@ impl Sidebar {
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
match event {
GroupsEvent::Updated => {
// TODO: find a better way?
// TODO: we must not trigger re-render directly like this
cx.notify();
}
GroupsEvent::Error(error) => {
@@ -198,6 +206,16 @@ impl Sidebar {
},
));
tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS);
loop {
cx.background_executor().timer(timer).await;
this.update(cx, |this, cx| {
this.tick_progress(cx);
})?;
}
}));
Self {
focus_handle: cx.focus_handle(),
dock,
@@ -223,7 +241,9 @@ impl Sidebar {
global_finding: false,
global_results: None,
global_task: None,
tasks: smallvec![],
chat_progress: 0.0,
sync_progress: 0.0,
tasks,
_subscriptions: subscriptions,
}
}
@@ -251,6 +271,80 @@ impl Sidebar {
self.hide_group(cx);
}
}
/// Advance the simulated loading progress of any active background work.
fn tick_progress(&mut self, cx: &mut Context<Self>) {
let logged_in = NostrRegistry::global(cx).read(cx).current_user().is_some();
let chat = ChatRegistry::global(cx);
let wot = PersonRegistry::global(cx).read(cx).wot();
let chat_loading = logged_in && chat.read(cx).loading();
let syncing = logged_in && wot.read(cx).is_syncing();
let mut changed = false;
if chat_loading {
let next = self.advance_progress(self.chat_progress);
if next > self.chat_progress {
self.chat_progress = next;
changed = true;
}
} else if self.chat_progress != 0.0 {
self.chat_progress = 0.0;
changed = true;
}
if syncing {
let next = self.advance_progress(self.sync_progress);
if next > self.sync_progress {
self.sync_progress = next;
changed = true;
}
} else if self.sync_progress != 0.0 {
self.sync_progress = 0.0;
changed = true;
}
if changed {
cx.notify();
}
}
/// Bump simulated progress by a random step
fn advance_progress(&self, progress: f32) -> f32 {
(progress + rand::random::<f32>() * 8.0 + 1.0).min(PROGRESS_CAP)
}
/// A floating pill showing simulated progress for a background operation.
fn render_loading_pill(
&self,
id: &'static str,
label: &'static str,
progress: f32,
opacity: f32,
cx: &Context<Self>,
) -> Div {
h_flex()
.opacity(opacity)
.h_9()
.w_full()
.px_5()
.gap_2p5()
.border_t_1()
.border_color(cx.theme().border)
.child(
ProgressCircle::new(id)
.value(progress)
.color(cx.theme().primary)
.accessibility_label(label),
)
.child(
div()
.text_xs()
.text_color(cx.theme().sidebar_foreground)
.child(label),
)
}
}
impl gpui_kit::component::dock::BasePanel for Sidebar {
@@ -290,6 +384,9 @@ impl Render for Sidebar {
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let wot = PersonRegistry::global(cx).read(cx).wot();
let syncing = wot.read(cx).is_syncing();
let group = self.group.clone().and_then(|group| group.upgrade());
let searching = self.search_open && !self.find_input.read(cx).value().is_empty();
let picking_contacts = self.contacts_open && !self.requests_open;
@@ -343,32 +440,46 @@ impl Render for Sidebar {
}),
)
.when(logged_in && !picking_contacts, |this| {
this.child(self.render_footer(cx))
this.child(self.render_footer(loading || syncing, cx))
})
.when(loading && logged_in, |this| {
.when(logged_in, |this| {
let pill_motion = Transition::new(cx.theme().motion_tokens().duration_normal)
.easing(cx.theme().motion_tokens().easing_move.clone());
let chat_presence = Presence::new("chat-presence", loading)
.transition(pill_motion.clone())
.sample(window, cx);
let wot_presence = Presence::new("wot-presence", syncing)
.transition(pill_motion)
.sample(window, cx);
this.child(
div()
v_flex()
.absolute()
.bottom_4()
.bottom_0()
.left_0()
.h_9()
.w_full()
.px_8()
.child(
h_flex()
.gap_2()
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(Spinner::new().small().color(cx.theme().primary))
.child("Getting messages..."),
),
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.children(chat_presence.should_render().then(|| {
self.render_loading_pill(
"chat-sync",
"Syncing messages",
self.chat_progress,
chat_presence.progress,
cx,
)
}))
.children(wot_presence.should_render().then(|| {
self.render_loading_pill(
"wot-sync",
"Syncing contacts",
self.sync_progress,
wot_presence.progress,
cx,
)
})),
)
})
.into_any_element()
+2 -7
View File
@@ -1,8 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::v_flex;
use instant::Instant;
use crate::dialogs::import;
@@ -11,12 +10,8 @@ const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
/// Pick one of the signed-out backgrounds at random.
pub(super) fn pick_banner() -> SharedString {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or_default();
let nanos = Instant::now().elapsed().subsec_nanos();
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
SIGNED_OUT_BANNERS[index].into()
}
+5
View File
@@ -26,6 +26,11 @@ icons = [
"resources/icon.ico",
]
# Merged over the generated Info.plist; required for the speech input to
# request microphone and speech recognition access on macOS.
[package.metadata.packager.macos]
info_plist_path = "resources/Info.plist"
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Dictate text into messages.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Turn your speech into text.</string>
</dict>
</plist>
+3
View File
@@ -8,6 +8,7 @@ publish.workspace = true
crate-type = ["cdylib", "rlib"]
[dependencies]
assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
@@ -29,6 +30,8 @@ console_error_panic_hook = "0.1"
tracing-wasm = "0.2"
console_log = "1.0"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
universal-time = { git = "https://github.com/shadowylab/universal-time" }
[target.'cfg(target_arch = "wasm32")'.dependencies]
+8 -10
View File
@@ -1,4 +1,4 @@
.PHONY: help dev build-wasm build-web build clean install
.PHONY: help dev build-wasm build-wasm-dev build-web build-web-prod build build-prod preview clean install watch-wasm
help: ## Show help information
@echo "Coop - Available commands:"
@@ -7,11 +7,9 @@ help: ## Show help information
install: ## Install all dependencies
@echo "Checking Rust WASM target..."
@rustup target add wasm32-unknown-unknown || true
@echo "Checking wasm-bindgen-cli..."
@cargo install wasm-bindgen-cli || true
@rustup target add wasm32-unknown-unknown --toolchain nightly || true
@echo "Installing frontend dependencies..."
@cd www && deno install
@cd www && npm install
build-wasm: ## Build WASM (release mode)
@./script/build-wasm.sh --release
@@ -20,20 +18,20 @@ build-wasm-dev: ## Build WASM (debug mode)
@./script/build-wasm.sh
build-web: ## Build frontend
@cd www && deno task build
@cd www && npm run build
build-web-prod: ## Build frontend for production (GitHub Pages)
@cd www && deno install && NODE_ENV=production deno task build
@cd www && npm install && NODE_ENV=production npm run build
build: build-wasm build-web ## Build complete project (WASM + frontend)
build-prod: build-wasm build-web-prod ## Build complete project for production
dev: build-wasm-dev ## Start development server
@cd www && deno install && deno task dev
@cd www && npm install && npm run dev
preview: ## Preview production build
@cd www && deno task preview
@cd www && npm run preview
clean: ## Clean build artifacts
@echo "Cleaning build artifacts..."
@@ -43,4 +41,4 @@ clean: ## Clean build artifacts
watch-wasm: ## Watch Rust code changes and auto-rebuild WASM
@echo "Watching WASM changes..."
@cargo watch -x 'build --target wasm32-unknown-unknown' -s './script/build-wasm.sh'
@cargo watch -w ../crates -w ../Cargo.toml -w ../Cargo.lock -s './script/build-wasm.sh'
+39 -18
View File
@@ -11,7 +11,8 @@ echo -e "${GREEN}Building Coop Web...${NC}"
# Get the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$SCRIPT_DIR/.."
PROJECT_ROOT="$SCRIPT_DIR/../.."
WEB_ROOT="$SCRIPT_DIR/.."
# Parse arguments
RELEASE_FLAG=""
@@ -20,27 +21,47 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}"
fi
# macOS: ensure LLVM from Homebrew is available
# wasm_thread requires a nightly compiler for its atomics features
CARGO="cargo +nightly"
# Step 0: Toolchain prerequisites
echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
rustup target add wasm32-unknown-unknown --toolchain nightly
if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${GREEN}Detected macOS, setting up LLVM...${NC}"
if brew list llvm &>/dev/null; then
echo -e "${GREEN}LLVM already installed, skipping install${NC}"
else
echo -e "${YELLOW}LLVM not found, installing via Homebrew...${NC}"
brew install llvm
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
# llvm-ar for archives comes from the rustup llvm-tools component
if ! xcrun --find clang &>/dev/null; then
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
exit 1
fi
LLVM_PATH=$(brew --prefix llvm)
export AR="${LLVM_PATH}/bin/llvm-ar"
export CC="${LLVM_PATH}/bin/clang"
echo -e "${GREEN}LLVM path: ${LLVM_PATH}${NC}"
rustup component add llvm-tools-preview --toolchain nightly
RUSTUP_HOME=$(rustup show home)
if [[ "$(uname -m)" == "arm64" ]]; then
HOST_ARCH="aarch64-apple-darwin"
else
HOST_ARCH="x86_64-apple-darwin"
fi
LLVM_BIN="$RUSTUP_HOME/toolchains/nightly-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
export CC_wasm32_unknown_unknown=clang
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
echo -e "${GREEN}Using Apple clang + rustup llvm-ar for wasm32 C code${NC}"
fi
# Match wasm-bindgen-cli to the version in Cargo.lock
LOCKED_BINDGEN=$(grep -A1 '^name = "wasm-bindgen"$' "$PROJECT_ROOT/Cargo.lock" | grep version | cut -d'"' -f2)
if [[ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$LOCKED_BINDGEN" ]]; then
echo -e "${YELLOW}Installing wasm-bindgen-cli $LOCKED_BINDGEN to match Cargo.lock...${NC}"
cargo install -f wasm-bindgen-cli --version "$LOCKED_BINDGEN"
fi
# Step 1: Build WASM
echo -e "${GREEN}Step 1: Building WASM...${NC}"
cd "$PROJECT_ROOT"
export CARGO_TARGET_DIR="$PROJECT_ROOT/target"
RUSTFLAGS='-C target-feature=+bulk-memory -C link-arg=--max-memory=4294967296' \
cargo build --target wasm32-unknown-unknown $RELEASE_FLAG
$CARGO build -p coop_web --target wasm32-unknown-unknown $RELEASE_FLAG
# Determine the build directory
if [[ "$RELEASE_FLAG" == "--release" ]]; then
@@ -61,12 +82,12 @@ fi
# Step 2: Generate JavaScript bindings
echo -e "${GREEN}Step 2: Generating JavaScript bindings...${NC}"
wasm-bindgen "$WASM_PATH" \
--out-dir "$PROJECT_ROOT/www/wasm" \
--out-dir "$WEB_ROOT/www/wasm" \
--target web \
--no-typescript
echo -e "${GREEN}✓ Build completed successfully!${NC}"
echo -e "${YELLOW}Next steps:${NC}"
echo -e " cd www"
echo -e " deno install"
echo -e " deno run dev"
echo -e " cd web/www"
echo -e " npm install"
echo -e " npm run dev"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
set -e
# Deploy the web build to Cloudflare Pages (direct upload via wrangler).
# Auth: run `npx wrangler login` once, or export CLOUDFLARE_API_TOKEN
# (and CLOUDFLARE_ACCOUNT_ID if the token has access to multiple accounts).
# One-time project setup: npx wrangler pages project create coop-web --production-branch master
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$SCRIPT_DIR/../.."
WWW_DIR="$SCRIPT_DIR/../www"
DIST_DIR="$WWW_DIR/dist"
PROJECT_NAME="coop-web"
PRODUCTION_BRANCH="master"
BRANCH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--branch)
BRANCH="$2"
shift 2
;;
*)
echo -e "${RED}Unknown argument: $1${NC}"
echo "Usage: $0 [--branch <name>]"
exit 1
;;
esac
done
if [[ -z "$BRANCH" ]]; then
BRANCH="$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD)"
fi
echo -e "${GREEN}Building Coop Web for deploy (branch: $BRANCH)...${NC}"
"$SCRIPT_DIR/build-wasm.sh" --release
echo -e "${GREEN}Building frontend...${NC}"
(cd "$WWW_DIR" && npm install --no-fund --no-audit && npm run build)
# Cloudflare Pages rejects files over 25 MiB
MAX_FILE_BYTES=$((25 * 1024 * 1024))
while IFS= read -r -d '' file; do
SIZE=$(stat -f%z "$file")
if [[ "$SIZE" -gt "$MAX_FILE_BYTES" ]]; then
echo -e "${RED}Error: $file is $((SIZE / 1024 / 1024)) MiB, over Cloudflare's 25 MiB per-file limit.${NC}"
echo "Shrink the wasm (wasm-opt -Oz, font subsetting) before deploying."
exit 1
fi
done < <(find "$DIST_DIR" -type f -print0)
echo -e "${GREEN}Deploying to Cloudflare Pages project '$PROJECT_NAME'...${NC}"
npx wrangler pages deploy "$DIST_DIR" \
--project-name "$PROJECT_NAME" \
--branch "$BRANCH" \
--commit-dirty=true
if [[ "$BRANCH" == "$PRODUCTION_BRANCH" ]]; then
echo -e "${GREEN}✓ Deployed to production.${NC}"
else
echo -e "${YELLOW}✓ Deployed as preview for branch '$BRANCH'.${NC}"
echo -e "${YELLOW}Pass --branch $PRODUCTION_BRANCH to deploy to production.${NC}"
fi
+106 -23
View File
@@ -1,57 +1,94 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use gpui::*;
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
use wasm_bindgen::prelude::*;
struct CustomTimeProvider;
thread_local! {
/// Dropping the handle releases the app, so the page's lifetime must own it.
static APPLICATION: RefCell<Option<ApplicationHandle>> = const { RefCell::new(None) };
}
impl WallClock for CustomTimeProvider {
/// Serves `Date.now()` as the wall clock and `performance.now()` as the monotonic clock.
struct BrowserTimeProvider;
impl WallClock for BrowserTimeProvider {
fn system_time(&self) -> SystemTime {
SystemTime::from_unix_duration(instant::Duration::from_secs(0))
SystemTime::from_unix_duration(core::time::Duration::from_secs_f64(
js_sys::Date::now() / 1000.0,
))
}
}
impl MonotonicClock for CustomTimeProvider {
impl MonotonicClock for BrowserTimeProvider {
fn instant(&self) -> Instant {
Instant::from_ticks(instant::Duration::from_secs(0))
Instant::from_ticks(core::time::Duration::from_secs_f64(instant::now() / 1000.0))
}
}
define_time_provider!(CustomTimeProvider);
define_time_provider!(BrowserTimeProvider);
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
fn init_logging() {
// Called from both entry points; the first one wins
static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
ONCE.get_or_init(|| {
console_error_panic_hook::set_once();
// Initialize logging to browser console
console_log::init_with_level(log::Level::Info).expect("Failed to initialize logger");
console_log::init_with_level(log::Level::Info).ok();
// Also initialize tracing for WASM
tracing_wasm::set_as_global_default();
});
}
#[cfg(target_family = "wasm")]
/// Open the persistent nostr database before the app starts.
#[wasm_bindgen]
pub async fn open_database() -> Result<(), JsValue> {
init_logging();
state::web_database::open().await;
Ok(())
}
#[wasm_bindgen]
pub fn run() -> Result<(), JsValue> {
init_logging();
// Sets the panic hook and GPUI's console logger
gpui_platform::web_init();
#[cfg(not(target_family = "wasm"))]
let app = gpui_platform::application();
let platform = std::rc::Rc::new(gpui_web::WebPlatform::new_with_backend_and_font_fallback(
false,
gpui_web::WebBackendPreference::Auto,
gpui_web::CanvasFontFallback::EmojiAndCjk,
));
#[cfg(target_family = "wasm")]
let app = {
let app = gpui_platform::single_threaded_web();
let http_client = Arc::new(platform.fetch_http_client());
// Temporary fix: intentionally leak the `Rc<AppCell>` to keep the application alive
struct WasmApplication(std::rc::Rc<AppCell>);
let wasm_app = unsafe { std::mem::transmute::<Application, WasmApplication>(app) };
std::mem::forget(wasm_app.0.clone());
unsafe { std::mem::transmute::<WasmApplication, Application>(wasm_app) }
};
let app = Application::with_platform(platform)
.with_http_client(http_client)
.with_assets(assets::CombinedAssets);
app.run(|cx| {
// Initialize the framework before anything renders
APPLICATION.with(|application| {
*application.borrow_mut() = Some(app.run_embedded(|cx| {
gpui_kit::init(cx);
load_embedded_fonts(cx);
apply_web_fonts(cx);
// Initialize settings
settings::init(cx);
apply_appearance(settings::persisted_appearance_mode(), cx);
settings::AppSettings::apply_theme_style(cx);
// Initialize the nostr client
state::init(cx, None);
@@ -75,7 +112,53 @@ pub fn run() -> Result<(), JsValue> {
.expect("Failed to open window. Please restart the application.");
cx.activate(true);
}));
});
Ok(())
}
/// Register every bundled font with the text system before any text is laid out
fn load_embedded_fonts(cx: &mut App) {
let font_paths = assets::Assets::iter()
.filter(|path| path.starts_with("fonts/") && path.ends_with(".ttf"))
.collect::<Vec<_>>();
let fonts = font_paths
.iter()
.filter_map(|path| assets::Assets::get(path.as_ref()))
.map(|font| font.data)
.collect::<Vec<_>>();
cx.text_system()
.add_fonts(fonts)
.expect("Failed to load bundled fonts");
}
fn apply_web_fonts(cx: &mut App) {
use gpui_kit::component::Theme;
const UI_FONT: &str = "Inter";
Theme::update(cx, |theme| {
theme.font_family = UI_FONT.into();
theme.mono_font_family = UI_FONT.into();
for config in [&mut theme.light_theme, &mut theme.dark_theme] {
let config = Rc::make_mut(config);
config.font_family = Some(UI_FONT.into());
config.mono_font_family = Some(UI_FONT.into());
}
});
}
/// Honor the appearance mode against the default palettes
fn apply_appearance(appearance: settings::AppearanceMode, cx: &mut App) {
use gpui_kit::component::{Theme, ThemeMode};
match appearance {
settings::AppearanceMode::System => Theme::sync_system_appearance(None, cx),
settings::AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
settings::AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
}
-396
View File
@@ -1,396 +0,0 @@
{
"version": "5",
"specifiers": {
"npm:vite-plugin-static-copy@^3.2.0": "3.4.0_vite@8.1.5",
"npm:vite-plugin-wasm@^3.3.0": "3.6.0_vite@8.1.5",
"npm:vite@8": "8.1.5"
},
"npm": {
"@emnapi/core@1.11.1": {
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dependencies": [
"@emnapi/wasi-threads",
"tslib"
]
},
"@emnapi/runtime@1.11.1": {
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dependencies": [
"tslib"
]
},
"@emnapi/wasi-threads@1.2.2": {
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dependencies": [
"tslib"
]
},
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": {
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util"
]
},
"@oxc-project/types@0.139.0": {
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="
},
"@rolldown/binding-android-arm64@1.1.5": {
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-arm64@1.1.5": {
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-x64@1.1.5": {
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rolldown/binding-freebsd-x64@1.1.5": {
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-arm-gnueabihf@1.1.5": {
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rolldown/binding-linux-arm64-gnu@1.1.5": {
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-arm64-musl@1.1.5": {
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-ppc64-gnu@1.1.5": {
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rolldown/binding-linux-s390x-gnu@1.1.5": {
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rolldown/binding-linux-x64-gnu@1.1.5": {
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-x64-musl@1.1.5": {
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-openharmony-arm64@1.1.5": {
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rolldown/binding-wasm32-wasi@1.1.5": {
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
"@napi-rs/wasm-runtime"
],
"cpu": ["wasm32"]
},
"@rolldown/binding-win32-arm64-msvc@1.1.5": {
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-x64-msvc@1.1.5": {
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rolldown/pluginutils@1.0.1": {
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"@tybys/wasm-util@0.10.3": {
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dependencies": [
"tslib"
]
},
"anymatch@3.1.3": {
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dependencies": [
"normalize-path",
"picomatch@2.3.2"
]
},
"binary-extensions@2.3.0": {
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="
},
"braces@3.0.3": {
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": [
"fill-range"
]
},
"chokidar@3.6.0": {
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": [
"anymatch",
"braces",
"glob-parent",
"is-binary-path",
"is-glob",
"normalize-path",
"readdirp"
],
"optionalDependencies": [
"fsevents"
]
},
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
"fdir@6.5.0_picomatch@4.0.5": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"picomatch@4.0.5"
],
"optionalPeers": [
"picomatch@4.0.5"
]
},
"fill-range@7.1.1": {
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": [
"to-regex-range"
]
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
"scripts": true
},
"glob-parent@5.1.2": {
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": [
"is-glob"
]
},
"is-binary-path@2.1.0": {
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dependencies": [
"binary-extensions"
]
},
"is-extglob@2.1.1": {
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-glob@4.0.3": {
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": [
"is-extglob"
]
},
"is-number@7.0.0": {
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"lightningcss-android-arm64@1.33.0": {
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"os": ["android"],
"cpu": ["arm64"]
},
"lightningcss-darwin-arm64@1.33.0": {
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"lightningcss-darwin-x64@1.33.0": {
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"lightningcss-freebsd-x64@1.33.0": {
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"lightningcss-linux-arm-gnueabihf@1.33.0": {
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"lightningcss-linux-arm64-gnu@1.33.0": {
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-arm64-musl@1.33.0": {
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-x64-gnu@1.33.0": {
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-linux-x64-musl@1.33.0": {
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-win32-arm64-msvc@1.33.0": {
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"os": ["win32"],
"cpu": ["arm64"]
},
"lightningcss-win32-x64-msvc@1.33.0": {
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"os": ["win32"],
"cpu": ["x64"]
},
"lightningcss@1.33.0": {
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dependencies": [
"detect-libc"
],
"optionalDependencies": [
"lightningcss-android-arm64",
"lightningcss-darwin-arm64",
"lightningcss-darwin-x64",
"lightningcss-freebsd-x64",
"lightningcss-linux-arm-gnueabihf",
"lightningcss-linux-arm64-gnu",
"lightningcss-linux-arm64-musl",
"lightningcss-linux-x64-gnu",
"lightningcss-linux-x64-musl",
"lightningcss-win32-arm64-msvc",
"lightningcss-win32-x64-msvc"
]
},
"nanoid@3.3.16": {
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"bin": true
},
"normalize-path@3.0.0": {
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
"p-map@7.0.6": {
"integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@2.3.2": {
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="
},
"picomatch@4.0.5": {
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="
},
"postcss@8.5.23": {
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"readdirp@3.6.0": {
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dependencies": [
"picomatch@2.3.2"
]
},
"rolldown@1.1.5": {
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dependencies": [
"@oxc-project/types",
"@rolldown/pluginutils"
],
"optionalDependencies": [
"@rolldown/binding-android-arm64",
"@rolldown/binding-darwin-arm64",
"@rolldown/binding-darwin-x64",
"@rolldown/binding-freebsd-x64",
"@rolldown/binding-linux-arm-gnueabihf",
"@rolldown/binding-linux-arm64-gnu",
"@rolldown/binding-linux-arm64-musl",
"@rolldown/binding-linux-ppc64-gnu",
"@rolldown/binding-linux-s390x-gnu",
"@rolldown/binding-linux-x64-gnu",
"@rolldown/binding-linux-x64-musl",
"@rolldown/binding-openharmony-arm64",
"@rolldown/binding-wasm32-wasi",
"@rolldown/binding-win32-arm64-msvc",
"@rolldown/binding-win32-x64-msvc"
],
"bin": true
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
"fdir",
"picomatch@4.0.5"
]
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
"is-number"
]
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"vite-plugin-static-copy@3.4.0_vite@8.1.5": {
"integrity": "sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==",
"dependencies": [
"chokidar",
"p-map",
"picocolors",
"tinyglobby",
"vite"
]
},
"vite-plugin-wasm@3.6.0_vite@8.1.5": {
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dependencies": [
"vite"
]
},
"vite@8.1.5": {
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dependencies": [
"lightningcss",
"picomatch@4.0.5",
"postcss",
"rolldown",
"tinyglobby"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:vite-plugin-static-copy@^3.2.0",
"npm:vite-plugin-wasm@^3.3.0",
"npm:vite@8"
]
}
}
}
+3
View File
@@ -7,6 +7,9 @@ async function init() {
const wasm = await import('./wasm/coop_web.js');
await wasm.default();
// Open the persistent nostr database before the app starts
await wasm.open_database();
// Initialize the story gallery
await wasm.run();
+1075
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"wasm": "cargo build --target wasm32-unknown-unknown --release && wasm-bindgen ../../../target/wasm32-unknown-unknown/release/gpui_component_story_web.wasm --out-dir ./src/wasm --target web --no-typescript"
"wasm": "../script/build-wasm.sh --release"
},
"devDependencies": {
"vite": "^8",
+9
View File
@@ -0,0 +1,9 @@
/*
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
/index.html
Cache-Control: no-cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
+2 -6
View File
@@ -10,7 +10,7 @@ export default defineConfig({
viteStaticCopy({
targets: [
{
src: path.resolve(__dirname, "../../../assets/icons"),
src: path.resolve(__dirname, "../../assets/icons"),
dest: "assets",
},
],
@@ -21,7 +21,7 @@ export default defineConfig({
server.middlewares.use(
"/coop/assets",
(req, res, next) => {
const assetsPath = path.resolve(__dirname, "../../../assets");
const assetsPath = path.resolve(__dirname, "../../assets");
const filePath = path.join(
assetsPath,
req.url.replace("/assets", ""),
@@ -61,10 +61,6 @@ export default defineConfig({
strict: false,
allow: [".."],
},
headers: {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
},
optimizeDeps: {
exclude: ["./src/wasm"],