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
56 changed files with 1039 additions and 8763 deletions
+3
View File
@@ -20,6 +20,9 @@ jobs:
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
+2 -3
View File
@@ -39,9 +39,8 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@master
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-10-31
targets: wasm32-unknown-unknown
components: llvm-tools-preview
@@ -52,4 +51,4 @@ jobs:
env:
CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
run: cargo +nightly check -p coop_web --target wasm32-unknown-unknown
Generated
+285 -1999
View File
File diff suppressed because it is too large Load Diff
+16 -31
View File
@@ -4,28 +4,27 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "2.0.0-alpha3"
version = "2.0.0-alpha2"
edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.10" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.10" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.10" }
gpui_web = { package = "gpui-pre-web", version = "0.3.10" }
gpui_util = { package = "gpui-pre-util", version = "0.3.10" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.7.1"
gpui-kit = "0.7.3"
gpui-base = "0.8.1"
gpui-kit = "0.8.1"
native-theme = { version = "0.7", features = ["native", "watch"] }
native-theme-gpui = { version = "0.7", default-features = false, features = ["system-fonts"] }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "b75968ee0fecf78c3041248f1f6bfe8bfd85dee0", features = ["core", "data", "layout", "editor"] }
# Native OS theme reading; the connector is written against gpui-component 0.7.x
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
native-theme = { version = "0.6.1", features = ["watch"] }
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
@@ -36,7 +35,7 @@ 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", "nip60", "nip98" ] }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
@@ -50,16 +49,8 @@ hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
# (redirected to this repo by the [patch.crates-io] section below). redb
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
# Others
anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" }
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
@@ -75,12 +66,6 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
# git repo coop already uses, keeping a single copy of each in the graph.
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
[profile.release]
strip = true
opt-level = "z"
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 726 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 10.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V10.75M21.25 10.75H18.75M21.25 10.75V6.75C21.25 5.64543 20.3546 4.75 19.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V10.75M2.75 10.75H5.25M5.25 10.75H7.13177C7.53452 10.75 7.92789 10.8716 8.26039 11.0989L9.43406 11.9011C9.76655 12.1284 10.1599 12.25 10.5627 12.25H13.4373C13.8401 12.25 14.2334 12.1284 14.5659 11.9011L15.7396 11.0989C16.0721 10.8716 16.4655 10.75 16.8682 10.75H18.75M5.25 10.75V7.75C5.25 7.47386 5.47386 7.25 5.75 7.25H18.25C18.5261 7.25 18.75 7.47386 18.75 7.75V10.75" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 748 B

+3 -3
View File
@@ -37,8 +37,9 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`.
// cargo-packager names the disk images `aarch64`/`x64`.
("macos", "aarch64") => "aarch64",
("macos", "x86_64") => "x64",
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
("linux", "aarch64") => "aarch64",
("linux", "x86_64") => "x86_64",
@@ -242,7 +243,7 @@ mod tests {
#[test]
fn picks_the_artifact_matching_os_and_architecture() {
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
assert_eq!(selected("macos", "x86_64"), None);
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
assert_eq!(
selected("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
@@ -257,7 +258,6 @@ mod tests {
fn has_no_target_for_windows_or_unknown_platforms() {
assert_eq!(asset_filter_for("windows", "x86_64"), None);
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "riscv64"), None);
}
+1 -24
View File
@@ -947,11 +947,7 @@ async fn extract_rumor(
let Some(device_signer) = device_signer else {
return Err(error);
};
match try_unwrap_with(device_signer, gift_wrap).await {
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
try_unwrap_with(device_signer, gift_wrap).await?
}
};
let mut rumor = unwrapped.rumor;
@@ -1007,25 +1003,6 @@ async fn try_unwrap_with(
})
}
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
-24
View File
@@ -16,7 +16,6 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -554,7 +553,6 @@ impl Room {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
@@ -576,28 +574,6 @@ impl Room {
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| {
deliver(
-5
View File
@@ -6,7 +6,6 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
speech = { path = "../speech" }
ui = { path = "../ui" }
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
@@ -15,7 +14,6 @@ chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -23,6 +21,3 @@ smallvec.workspace = true
serde.workspace = true
regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nip60_ui = { path = "../nip60_ui" }
+1 -4
View File
@@ -1,4 +1,4 @@
use gpui::{Action, actions};
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
@@ -15,6 +15,3 @@ pub enum Command {
Njump(PublicKey),
Trace(EventId),
}
// Start or stop dictation in the focused composer
actions!(chat, [ToggleDictation]);
+52 -206
View File
@@ -1,6 +1,5 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration;
pub use actions::*;
use anyhow::Error;
@@ -30,14 +29,13 @@ use person::{Directory, Person};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use speech::speaker::{END_GRACE, Speaker};
use state::{
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
group_reactions, opens_run, rendered_text,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
};
use crate::file::*;
@@ -46,7 +44,8 @@ use crate::file::*;
const PRIVATE_NOTICE: &str =
"This conversation is private. Only members can see each other's messages.";
/// Regex matching strings that consist entirely of emoji characters.
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
@@ -65,49 +64,62 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
pub struct ChatPanel {
id: SharedString,
focus_handle: FocusHandle,
/// Chat Room
room: WeakEntity<Room>,
/// Person directory shared with the room
directory: Entity<Directory>,
/// Message list state
list_state: ListState,
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
/// Mapping message (rumor event) ids to their reports
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
/// Chat input state
input: Entity<TextareaState>,
/// Dictation session for the chat input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<(), Error>>>,
/// Subject input state
subject_input: Entity<InputState>,
/// Subject bar visibility
subject_bar: Entity<bool>,
/// Replies to
replies_to: Entity<HashSet<EventId>>,
/// Media Attachment
attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state
uploading: bool,
/// Async operations
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -153,17 +165,7 @@ impl ChatPanel {
});
// Define dictation state
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let speech = cx.new(SpeechState::new);
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
@@ -194,25 +196,14 @@ impl ChatPanel {
// Insert the dictated text into the chat input
cx.subscribe_in(&speech, window, move |this, _speech, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input
.update(cx, |input, cx| input.insert(text.clone(), window, cx));
}
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
@@ -225,20 +216,6 @@ impl ChatPanel {
this.get_messages(window, cx);
});
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
id,
@@ -250,10 +227,6 @@ impl ChatPanel {
list_state,
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
subject_input,
subject_bar,
replies_to,
@@ -264,7 +237,7 @@ impl ChatPanel {
reports_by_id,
uploading: false,
subscriptions,
tasks: vec![reveal_end_task],
tasks: vec![],
}
}
@@ -681,41 +654,6 @@ impl ChatPanel {
});
}
/// Open the nutzap dialog for the room's peer; direct chats only
fn open_nutzap(&mut self, window: &mut Window, cx: &mut Context<Self>) {
#[cfg(target_arch = "wasm32")]
let _ = (window, cx);
#[cfg(not(target_arch = "wasm32"))]
{
let Some(room) = self.room.upgrade() else {
return;
};
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let peers: Vec<PublicKey> = room
.read(cx)
.members()
.iter()
.copied()
.filter(|key| *key != me)
.collect();
if let [recipient] = peers[..] {
nip60_ui::open_nutzap(window, cx, recipient, None);
} else {
window.push_notification(
Notification::warning("Nutzaps can only be sent in direct chats"),
cx,
);
}
}
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
@@ -918,17 +856,6 @@ impl ChatPanel {
self.directory.read(cx).person(public_key, cx)
}
fn toggle_dictation(
&mut self,
_: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
@@ -1228,15 +1155,6 @@ impl ChatPanel {
nostr.read(cx).current_user(),
));
let body = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => rendered_text,
};
MessageRow::new(ix)
.show_author(show_author)
.hide_avatar(hide_avatar)
@@ -1261,7 +1179,7 @@ impl ChatPanel {
.when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx))
})
.when(message.file.is_none(), |this| this.child(body))
.when(message.file.is_none(), |this| this.child(rendered_text))
.child(MessageMedia::new(&message.media))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
@@ -1509,43 +1427,6 @@ impl ChatPanel {
actions
};
let speaking = self.speech_reveal.is_some();
let speech_id = *id;
let actions = actions.speech(
speaking,
cx.listener(move |this, _event, window, cx| {
let text = this.message(&speech_id).map(|message| message.preview());
if let Some(text) = text {
match this.speaker.speak(&text) {
Ok(duration) => {
this.speech_reveal = Some((speech_id, duration));
// Re-enable speaking when no end event ever arrives
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
}),
);
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)))
@@ -1814,12 +1695,7 @@ impl ChatPanel {
items
}
fn render_config_menu(
&self,
disabled: bool,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let (backup, signer_kind) = self
.room
.read_with(cx, |this, _cx| {
@@ -1832,7 +1708,6 @@ impl ChatPanel {
.icon(IconName::Settings2)
.tooltip("Configuration")
.ghost()
.disabled(disabled)
.dropdown_menu(move |this, _window, _cx| {
let auto = matches!(signer_kind, SignerKind::Auto);
let encryption = matches!(signer_kind, SignerKind::Encryption);
@@ -1863,12 +1738,7 @@ impl ChatPanel {
})
}
fn render_emoji_menu(
&self,
disabled: bool,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoElement {
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
@@ -1885,7 +1755,6 @@ impl ChatPanel {
Button::new("emoji")
.icon(IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1959,12 +1828,11 @@ 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 dictating = self.speech.read(cx).status().is_active();
let capturing = self.speech.read(cx).status().is_capturing();
v_flex()
.image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::toggle_dictation))
.size_full()
.when(*self.subject_bar.read(cx), |this| {
this.child(
@@ -2041,64 +1909,42 @@ impl Render for ChatPanel {
.icon(IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
)
.when(cfg!(not(target_arch = "wasm32")), |this| {
this.child(
Button::new("nutzap")
.icon(gpui_kit::assets::IconName::Coins)
.tooltip("Send eCash")
.disabled(dictating)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_nutzap(window, cx);
})),
)
})
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.pl_1()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.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()
.gap_1()
.child(self.render_emoji_menu(dictating, window, cx))
.child(self.render_config_menu(dictating, window, cx))
.child(self.render_emoji_menu(window, cx))
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
-2
View File
@@ -11,13 +11,11 @@ common = { path = "../common" }
nip29 = { path = "../nip29" }
person = { path = "../person" }
settings = { path = "../settings" }
speech = { path = "../speech" }
state = { path = "../state" }
gpui-kit = { workspace = true, features = ["speech"] }
ui = { path = "../ui" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
+46 -127
View File
@@ -1,14 +1,11 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Result;
use chat_ui::ToggleDictation;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState,
ParentElement, 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 _;
@@ -26,9 +23,8 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, RenderedText, RevealText, WelcomeMessage, rendered_text};
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -319,14 +315,6 @@ pub struct GroupPanel {
input: Entity<TextareaState>,
/// Dictation session for the composer input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<()>>>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -353,17 +341,7 @@ impl GroupPanel {
.clean_on_escape()
});
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let speech = cx.new(SpeechState::new);
let mut subscriptions = smallvec![];
@@ -378,26 +356,14 @@ impl GroupPanel {
subscriptions.push(
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Final(text) if !text.is_empty() => {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
@@ -426,24 +392,9 @@ impl GroupPanel {
}
},
));
this.track_participants(cx);
});
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
let mut this = Self {
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
@@ -454,18 +405,19 @@ impl GroupPanel {
rendered_texts_by_id: BTreeMap::new(),
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![reveal_end_task],
tasks: smallvec![],
_subscriptions: subscriptions,
}
};
this.track_participants(cx);
this
}
/// Track the keys rendered by this panel.
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
@@ -1043,29 +995,22 @@ impl GroupPanel {
let nostr = NostrRegistry::global(cx);
let directory = self.directory.clone();
let content = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == message.id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx),
};
let content = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx);
let admin = nostr
.read(cx)
@@ -1086,24 +1031,12 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
speaking: self.speech_reveal.is_some(),
persons: self.directory.clone(),
},
cx,
)
}
fn toggle_dictation(
&mut self,
_ev: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx);
@@ -1129,8 +1062,6 @@ impl GroupPanel {
return self.render_notice(&notice, cx);
}
let dictating = self.speech.read(cx).status().is_active();
v_flex()
.flex_shrink_0()
.w_full()
@@ -1149,34 +1080,23 @@ impl GroupPanel {
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.ghost()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
)
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.bg(cx.theme().input)
.small()
.min_h_8()
.py_1()
.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(
@@ -1184,7 +1104,7 @@ impl GroupPanel {
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.disabled(dictating || self.uploading)
.disabled(self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -1382,7 +1302,6 @@ impl Render for GroupPanel {
v_flex()
.size_full()
.min_w_0()
.on_action(cx.listener(Self::toggle_dictation))
.child(self.render_encryption_notice(cx))
.child(self.render_banner(cx))
.child(self.render_pins(cx))
+6 -44
View File
@@ -8,17 +8,15 @@ use gpui::{
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::{
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
group_reactions, react_menu,
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
};
use crate::GroupPanel;
@@ -28,8 +26,6 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
/// Whether a message is currently being spoken, disabling the speech buttons
pub speaking: bool,
pub persons: Entity<Directory>,
}
@@ -91,44 +87,10 @@ pub(crate) fn render(
let admin = actions.admin;
let pinned = actions.pinned;
let group = actions.group;
let speech_panel = actions.panel;
let panel = speech_panel.clone();
let speech_text = message.preview();
let speech_id = message.id;
let speaking = actions.speaking;
let row = row.overlay(
MessageActions::new().speech(speaking, move |_event, _window, cx| {
_ = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
});
}),
);
let panel = actions.panel;
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()
+165 -488
View File
@@ -1,10 +1,9 @@
use std::cell::Cell;
use std::collections::{HashSet, VecDeque};
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
@@ -17,14 +16,12 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
use ui::Avatar;
mod storage;
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
const IDENTIFIER: &str = "coop:device";
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
@@ -62,18 +59,10 @@ impl DeviceEvent {
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Ephemeral client key for the in-flight key request; never persisted
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Handled key-sync request ids, bounded and persisted
processed_requests: VecDeque<EventId>,
/// Whether there is a pending request for encryption key approval
pub(crate) pending_request: bool,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener
@@ -119,21 +108,6 @@ impl DeviceRegistry {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed);
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.handle_notifications(cx);
this.get_announcement(cx);
}
@@ -141,19 +115,15 @@ impl DeviceRegistry {
);
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
this.handle_notifications(cx);
});
entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
});
Self {
signer,
app_keys: None,
pending_request: None,
processed_requests: VecDeque::new(),
pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
@@ -171,8 +141,6 @@ impl DeviceRegistry {
let announcement_existed = self.announcement_existed.clone();
let (tx, rx) = flume::bounded::<Event>(100);
let read = credentials::read_processed_requests(cx);
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
@@ -207,21 +175,6 @@ impl DeviceRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
// Hydrate the processed-request log before routing any events
match read.await {
Ok(bytes) => {
let processed = bytes
.map(|bytes| parse_processed_requests(&bytes))
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.processed_requests = processed;
})
.ok();
}
Err(e) => log::error!("Failed to read processed key-sync requests: {e}"),
}
while let Ok(event) = rx.recv_async().await {
match event.kind {
Kind::Custom(10044) => {
@@ -238,7 +191,7 @@ impl DeviceRegistry {
// New response event from the master device
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.handle_transfer(event, cx);
this.extract_encryption(event, cx);
})?;
}
_ => {}
@@ -248,62 +201,12 @@ impl DeviceRegistry {
}));
}
/// Record a handled key-sync request id, persisting a bounded log
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
if self.processed_requests.contains(&event_id) {
return;
}
self.processed_requests.push_back(event_id);
while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT {
self.processed_requests.pop_front();
}
let bytes: Vec<u8> = self
.processed_requests
.iter()
.flat_map(|id| id.to_hex().into_bytes())
.collect();
let write = credentials::write_processed_requests(cx, &bytes);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist processed key-sync requests: {e}");
}
})
.detach();
}
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.pending_request.take() else {
return;
};
self.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
/// Set whether there is a pending request for encryption key approval
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.pending_request = pending;
cx.notify();
}
/// Route a key transfer.
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
let resolved = self
.pending_request
.as_ref()
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
if resolved {
self.resolve_pending_request(cx);
return;
}
self.extract_encryption(event, cx);
}
/// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone()
@@ -315,16 +218,9 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set);
}
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// 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);
@@ -427,88 +323,6 @@ impl DeviceRegistry {
}));
}
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it
if let Ok(old) = get_keys(&client, &signer).await {
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?;
}
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -517,7 +331,6 @@ impl DeviceRegistry {
let secret = keys.secret_key().to_secret_hex();
let n = keys.public_key();
let created_at = Timestamp::now();
cx.background_spawn(async move {
// Construct an announcement event
@@ -527,7 +340,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret, created_at).await?;
set_keys(&client, &signer, &secret).await?;
// Publish announcement
client
@@ -570,12 +383,10 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
this.update(cx, |this, cx| {
this.resync.store(true, Ordering::Release);
this.request(Some(device_pubkey), cx);
this.request(cx);
})?;
}
Ok(())
@@ -603,17 +414,15 @@ impl DeviceRegistry {
}
/// Request encryption keys from other device
pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
let app_keys_task = get_or_init_app_keys(cx);
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
@@ -625,7 +434,8 @@ impl DeviceRegistry {
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid
// Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
if decrypt_approval(&client, &app_keys, public_key, &event)
.await
.is_ok()
@@ -634,26 +444,9 @@ impl DeviceRegistry {
}
}
// No usable approval event found, construct a request event.
//
// The `pubkey` tag is a compatibility alias for the draft's `P`,
// `relay` hints tell other devices where the response is accepted,
// `n` names the exact key this device expects to be transferred
let mut tags = vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("pubkey", vec![app_pubkey]),
];
for url in write_relay_hints(&client, public_key).await? {
tags.push(Tag::custom("relay", vec![url.to_string()]));
}
if let Some(expected) = expected_encryption {
tags.push(Tag::custom("n", vec![expected]));
}
// No usable approval event found, construct a request event
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(tags)
.tags(vec![Tag::custom("P", vec![app_pubkey])])
.finalize_async(&signer)
.await?;
@@ -690,13 +483,19 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Cannot wait for approval without a client key");
return;
};
let app_keys_task = get_or_init_app_keys(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(),
@@ -706,7 +505,7 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Only approvals addressed to this device's client key matter
// Only approvals addressed to this device's app key matter
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
@@ -730,18 +529,15 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Ignoring key transfer addressed to another device");
return;
};
let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
@@ -753,14 +549,14 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
// The client key was consumed; start fresh on the next request
this.app_keys = None;
// Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx);
})?;
}
Err(e) => {
log::warn!("Ignoring key transfer: {e}");
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
}
}
Ok(())
@@ -814,58 +610,40 @@ impl DeviceRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
let request_id = event.id;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex();
let identity = signer.get_public_key_async().await?;
// Extract the target public key from the event tags
let target =
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
let target = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
// The requester may pin the exact key it expects
if let Some(requested) = requested_encryption_key(&event)
&& requested != keys.public_key()
{
return Err(anyhow!(
"The requesting device expects an encryption key this device no longer holds"
));
}
// Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key.
let sender = Keys::generate();
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
// Encrypt the device keys with the user's signer
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event
//
// P tag: the one-time sender key used for the encryption
// p tag: the account identity, for inbox routing
// p tag: the requester's client key
let response = EventBuilder::new(Kind::Custom(4455), payload)
// P tag: the current device's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom("P", vec![sender.public_key().to_hex()]),
Tag::public_key(identity),
Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::public_key(target),
])
.finalize_async(&signer)
.await?;
let relays = merge_relays(
write_relay_hints(&client, identity).await?,
request_relay_hints(&event),
);
let send = client.send_event(&response);
if relays.is_empty() {
send.to_nip65().await?;
} else {
send.to(relays).await?;
}
// Send the response event to the user's relay list
client.send_event(&event).to_nip65().await?;
Ok(())
});
@@ -874,11 +652,8 @@ impl DeviceRegistry {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Record the request as handled so it cannot resurface
this.mark_request_processed(request_id, cx);
// Allow future requests to surface again
this.pending_request = None;
cx.notify();
this.set_pending_request(false, cx);
})?;
cx.update(|window, cx| {
@@ -903,70 +678,21 @@ impl DeviceRegistry {
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
let Some(client_key) = client_pubkey_from_event(&event) else {
log::warn!("Ignoring invalid encryption key request: no valid requester key");
// Ignore if there is already a pending request
if self.pending_request {
return;
};
}
self.set_pending_request(true, cx);
let read = credentials::read_processed_requests(cx);
cx.spawn(async move |this, cx| {
let processed = match read.await {
Ok(bytes) => bytes
.map(|bytes| parse_processed_requests(&bytes).contains(&event.id))
.unwrap_or(false),
Err(e) => {
log::error!("Failed to read processed key-sync requests: {e}");
false
}
};
this.update(cx, |this, cx| {
if processed || this.processed_requests.contains(&event.id) {
return;
}
match this.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
this.mark_request_processed(event.id, cx);
pending.event = event;
this.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if let Some(pending) = other {
this.mark_request_processed(pending.event.id, cx);
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.show_approval(event, client_key, cx);
}
}
})
.ok();
})
.detach();
}
/// Build and surface the approval notification for a key request
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
let notification = match self.notification(event.clone(), cx) {
let notification = match self.notification(event, cx) {
Ok(notification) => notification,
Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return;
}
};
self.pending_request = Some(PendingRequest { event, client_key });
cx.notify();
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
@@ -989,16 +715,18 @@ impl DeviceRegistry {
compare the code with the one shown on the requesting \
device.";
let requester =
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
let requester = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let code_label = code.clone();
let directory = self.directory.clone();
directory.update(cx, |directory, cx| {
directory.track([requester], cx);
});
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone();
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
@@ -1103,111 +831,6 @@ impl DeviceRegistry {
struct DeviceNotification;
/// A key request awaiting user approval on this device
#[derive(Debug)]
struct PendingRequest {
event: Event,
client_key: PublicKey,
}
/// Keep the durable processed-request log bounded
const PROCESSED_REQUESTS_LIMIT: usize = 256;
/// Parse concatenated hex event ids
fn parse_processed_requests(bytes: &[u8]) -> VecDeque<EventId> {
let Ok(hex) = std::str::from_utf8(bytes) else {
return VecDeque::new();
};
hex.as_bytes()
.as_chunks::<64>()
.0
.iter()
.filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok())
.collect()
}
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok());
key.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
})
}
/// Whether the transfer event is p-tagged to the given client key
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
event.tags.iter().any(|tag| {
tag.kind() == "p"
&& tag
.content()
.and_then(|content| PublicKey::parse(content).ok())
.is_some_and(|key| key == client_key)
})
}
/// Parse the `relay` hints attached to a key request.
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "relay")
.filter_map(|tag| {
tag.content()
.and_then(|content| RelayUrl::parse(content).ok())
})
.collect()
}
/// Parse the optional `n` tag of a key request.
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
}
/// Collect the user's NIP-65 write relays.
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(identity)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
// Unmarked relays serve both reads and writes
Ok(nip65::extract_relay_list(&event)
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
.map(|(url, _)| url)
.collect())
}
/// Merge relay lists, dropping duplicates
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString {
let hex = public_key.to_hex();
@@ -1215,6 +838,35 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
}
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = credentials::read_app_keys(cx);
cx.spawn(async move |cx| {
if let Ok(Some(secret)) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = credentials::write_app_keys(cx, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Failed to persist app keys: {e}")
}
})
.detach();
});
Ok(keys)
})
}
/// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval(
client: &Client,
@@ -1222,7 +874,13 @@ async fn decrypt_approval(
identity: PublicKey,
event: &Event,
) -> Result<Keys, Error> {
let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str())
@@ -1231,52 +889,71 @@ async fn decrypt_approval(
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement
// Reject approvals carrying a key that no longer matches the published announcement.
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(identity)
.limit(1);
let announcement = client
.database()
.query(filter.clone())
.await?
.into_iter()
.next();
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
let announcement = match announcement {
Some(announcement) => Some(announcement),
None => {
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(3))
.await?;
stream
.next()
.await
.map(|(_url, result)| result)
.transpose()?
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
};
let Some(announcement) = announcement else {
return Err(anyhow!(
"No encryption announcement available to validate the approval"
));
};
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
Ok(keys)
}
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
-174
View File
@@ -1,174 +0,0 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
-26
View File
@@ -1,26 +0,0 @@
[package]
name = "nip60"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
flume.workspace = true
serde_json.workspace = true
futures.workspace = true
hkdf.workspace = true
sha2.workspace = true
tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true
cdk-redb.workspace = true
-369
View File
@@ -1,369 +0,0 @@
use anyhow::{Error, anyhow};
use hkdf::Hkdf;
use nostr::nips::nip60::TokenEvent;
use nostr_sdk::prelude::*;
use sha2::Sha256;
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
const SEED_INFO: &[u8] = b"coop cashu seed";
/// Parsed 17375 wallet event: the wallet key and its mint list.
#[derive(Clone)]
pub(crate) struct WalletData {
pub(crate) event_id: EventId,
pub(crate) created_at: Timestamp,
pub(crate) privkey: SecretKey,
pub(crate) mints: Vec<Url>,
}
impl WalletData {
/// A new wallet with a fresh key.
pub(crate) fn generate(first_mint: Url) -> Self {
Self {
event_id: EventId::from_byte_array([0; 32]),
created_at: Timestamp::from_secs(0),
privkey: SecretKey::generate(),
mints: vec![first_mint],
}
}
/// The cdk seed, derived deterministically from the wallet key.
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
let ikm = privkey.to_secret_bytes();
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
let mut seed = [0u8; 64];
hk.expand(SEED_INFO, &mut seed)
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
Ok(seed)
}
/// Decrypt and parse a 17375 event published by this account.
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("wallet event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let (privkey, mints) = parse_wallet_content(&content)?;
Ok(Self {
event_id: event.id,
created_at: event.created_at,
privkey,
mints,
})
}
/// Build the encrypted 17375 event for this wallet data.
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
for mint in &self.mints {
rows.push(vec!["mint".to_string(), mint.to_string()]);
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWallet, content)
.finalize_async(signer)
.await?)
}
}
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
let rows: Vec<Vec<String>> =
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
let mut privkey = None;
let mut mints = Vec::new();
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
"mint" => {
if let Ok(mint) = Url::parse(value) {
mints.push(mint);
}
}
_ => {}
}
}
let Some(privkey) = privkey else {
return Err(anyhow!("wallet event is missing the private key"));
};
if mints.is_empty() {
return Err(anyhow!("wallet event is missing mints"));
}
Ok((privkey, mints))
}
pub(crate) async fn decrypt_token_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<TokenEvent, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("token event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let token =
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
Ok(token)
}
pub(crate) async fn build_token_event<S>(
signer: &S,
me: &PublicKey,
token: &TokenEvent,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
.finalize_async(signer)
.await?)
}
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
tags.push(Tag::custom("k", ["7375".to_string()]));
Ok(EventBuilder::new(Kind::EventDeletion, "")
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Decrypted 7376 payload, enough for the wallet panel's history list.
pub(crate) struct HistoryPayload {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
}
pub(crate) async fn decrypt_history_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<HistoryPayload, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("history event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
.map_err(|error| anyhow!("invalid history event: {error}"))?;
let mut direction = None;
let mut amount = None;
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"direction" if direction.is_none() => direction = value.parse().ok(),
"amount" if amount.is_none() => amount = value.parse().ok(),
_ => {}
}
}
let Some(direction) = direction else {
return Err(anyhow!("history event is missing the direction"));
};
let Some(amount) = amount else {
return Err(anyhow!("history event is missing the amount"));
};
Ok(HistoryPayload { direction, amount })
}
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
pub(crate) async fn build_nutzap_event<S>(
signer: &S,
recipient: PublicKey,
mint: &str,
proofs: &[String],
target: Option<EventId>,
comment: &str,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = vec![
Tag::custom("p", [recipient.to_hex()]),
Tag::custom("u", [mint.to_string()]),
Tag::custom("unit", ["sat".to_string()]),
];
for proof in proofs {
tags.push(Tag::custom("proof", [proof.clone()]));
}
if let Some(target) = target {
tags.push(Tag::custom("e", [target.to_hex()]));
}
Ok(EventBuilder::new(Kind::Custom(9321), comment)
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Encrypted 7376 history payload for one state change.
pub(crate) struct History {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
/// NIP-61 nutzap events redeemed by this state change
pub(crate) redeemed: Vec<EventId>,
/// Author of the redeemed nutzap, for the unencrypted `p` tag
pub(crate) sender: Option<PublicKey>,
}
pub(crate) async fn build_history_event<S>(
signer: &S,
me: &PublicKey,
history: &History,
created: EventId,
destroyed: &[EventId],
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![
vec!["direction".to_string(), history.direction.to_string()],
vec!["amount".to_string(), history.amount.to_string()],
];
for id in destroyed {
rows.push(vec![
"e".to_string(),
id.to_hex(),
String::new(),
"destroyed".to_string(),
]);
}
rows.push(vec![
"e".to_string(),
created.to_hex(),
String::new(),
"created".to_string(),
]);
let mut tags: Vec<Tag> = history
.redeemed
.iter()
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
.collect();
if let Some(sender) = history.sender {
tags.push(Tag::custom("p", [sender.to_hex()]));
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
.tags(tags)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_nutzap_info_event<S>(
signer: &S,
mints: &[Url],
relays: &[RelayUrl],
wallet_pubkey: PublicKey,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom("relay", [relay.to_string()]))
.collect();
for mint in mints {
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
}
tags.push(Tag::custom(
"pubkey",
[format!("02{}", wallet_pubkey.to_hex())],
));
Ok(EventBuilder::new(Kind::Custom(10019), "")
.tags(tags)
.finalize_async(signer)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wallet_content() {
let privkey = SecretKey::generate();
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![
vec!["privkey".to_string(), privkey.to_secret_hex()],
vec!["mint".to_string(), mint.to_string()],
])
.expect("serialization cannot fail");
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
assert_eq!(mints, vec![mint]);
}
#[test]
fn wallet_content_rejects_missing_privkey() {
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
.expect("serialization cannot fail");
assert!(parse_wallet_content(&content).is_err());
}
#[test]
fn seed_is_deterministic_per_key() {
let key = SecretKey::generate();
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
assert_eq!(first, second);
let other =
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
assert_ne!(first, other);
}
}
File diff suppressed because it is too large Load Diff
-617
View File
@@ -1,617 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Error, anyhow};
use cdk::Amount;
use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
SecretKey, SpendingConditions, State,
};
use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo;
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
use futures::StreamExt;
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
use nostr_sdk::prelude::*;
use crate::keys;
/// How long to wait for relays when fetching a recipient's wallet info
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
/// A job mutating the wallet store or reconciling it with relays.
#[derive(Debug, Clone)]
pub(crate) enum Job {
Reconcile,
CreateQuote {
mint: MintUrl,
amount: u64,
},
ClaimQuote {
mint: MintUrl,
quote_id: String,
},
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
SendNutzap {
recipient: PublicKey,
amount: u64,
target: Option<EventId>,
comment: Option<String>,
},
Receive {
mint: MintUrl,
token: String,
},
RedeemNutzap {
mint: MintUrl,
proofs: Proofs,
nutzap_id: EventId,
sender: PublicKey,
},
}
impl Job {
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
{
match self {
Job::Reconcile => ctx.reconcile().await,
Job::CreateQuote { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.mint_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
Some(Amount::from(amount)),
None,
None,
)
.await?;
Ok(Outcome::QuoteCreated {
mint,
quote_id: quote.id,
request: quote.request,
amount,
})
}
Job::ClaimQuote { mint, quote_id } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet.check_mint_quote(&quote_id).await?;
if quote.state != MintQuoteState::Paid {
return Ok(Outcome::Idle);
}
let proofs = wallet.mint(&quote_id, SplitTarget::default(), None).await?;
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(quote.amount.unwrap_or_default()),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::QuoteMinted {
quote_id,
update,
amount,
})
}
Job::Melt { mint, invoice } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.melt_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
invoice,
None,
None,
)
.await?;
let finalized = wallet
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
if finalized.state() != MeltQuoteState::Paid {
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
}
let amount = u64::from(finalized.total_amount());
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Melted { update, amount })
}
Job::SendToken { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let prepared = wallet
.prepare_send(Amount::from(amount), SendOptions::default())
.await?;
let token = prepared.confirm(None).await?.to_string();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Sent { update, token })
}
Job::SendNutzap {
recipient,
amount,
target,
comment,
} => {
let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
let wallet = ctx.wallet(&mint_url)?;
let keysets = wallet
.keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
.await?;
let infos: Vec<KeySetInfo> = keysets
.iter()
.map(|keyset| KeySetInfo {
id: keyset.id,
unit: keyset.unit.clone(),
active: keyset.active.unwrap_or(true),
input_fee_ppk: keyset.input_fee_ppk,
final_expiry: keyset.final_expiry,
})
.collect();
let options = SendOptions {
conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
..Default::default()
};
let token = wallet
.prepare_send(Amount::from(amount), options)
.await?
.confirm(None)
.await?;
let proofs = token.proofs(&infos)?;
let mut tags = Vec::new();
for proof in &proofs {
tags.push(serde_json::to_string(proof)?);
}
let event = keys::build_nutzap_event(
&ctx.signer,
recipient,
&mint_tag,
&tags,
target,
comment.as_deref().unwrap_or(""),
)
.await?;
ctx.client.send_event(&event).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint_url)?,
&ctx.previous_ids(&mint_url),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::NutzapSent { update, amount })
}
Job::Receive { mint, token } => {
let wallet = ctx.wallet(&mint)?;
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Received {
update,
amount: u64::from(received),
})
}
Job::RedeemNutzap {
mint,
proofs,
nutzap_id,
sender,
} => {
let wallet = ctx.wallet(&mint)?;
let options = ReceiveOptions {
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
..Default::default()
};
let received = wallet.receive_proofs(proofs, options, None, None).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: vec![nutzap_id],
sender: Some(sender),
}),
)
.await?;
Ok(Outcome::NutzapRedeemed {
update,
nutzap_id,
amount: u64::from(received),
})
}
}
}
}
pub(crate) struct TokenUpdate {
pub(crate) event_id: EventId,
pub(crate) token: TokenEvent,
pub(crate) destroyed: Vec<EventId>,
}
pub(crate) enum Outcome {
/// Nothing changed (e.g. an unpaid quote)
Idle,
/// Token events published by a reconcile pass
Reconciled {
updates: Vec<TokenUpdate>,
},
QuoteCreated {
mint: MintUrl,
quote_id: String,
request: String,
amount: u64,
},
QuoteMinted {
quote_id: String,
update: TokenUpdate,
amount: u64,
},
Melted {
update: TokenUpdate,
amount: u64,
},
Sent {
update: TokenUpdate,
token: String,
},
NutzapSent {
update: TokenUpdate,
amount: u64,
},
Received {
update: TokenUpdate,
amount: u64,
},
NutzapRedeemed {
update: TokenUpdate,
nutzap_id: EventId,
amount: u64,
},
}
pub(crate) struct JobCtx<S> {
pub(crate) wallets: HashMap<MintUrl, Wallet>,
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
pub(crate) wallet_key: SecretKey,
pub(crate) signer: S,
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
}
impl<S> JobCtx<S>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
self.wallets
.get(mint)
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
}
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
previous_ids(&self.tokens, mint)
}
/// Publish the store's current unspent set as a fresh token event.
async fn sync_token_events(
&self,
wallet: &Wallet,
mint: Url,
previous: &[EventId],
history: Option<keys::History>,
) -> Result<TokenUpdate, Error> {
let me = self.signer.get_public_key_async().await?;
let unspent = wallet.get_unspent_proofs().await?;
let proofs = unspent
.iter()
.map(cashu_proof)
.collect::<Result<Vec<_>, _>>()?;
let mut token = TokenEvent::new(mint, proofs);
token.del = previous.iter().map(|id| id.to_hex()).collect();
let event = keys::build_token_event(&self.signer, &me, &token).await?;
self.client.send_event(&event).await?;
if !previous.is_empty() {
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
self.client.send_event(&deletion).await?;
}
if let Some(history) = history {
let history_event =
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
self.client.send_event(&history_event).await?;
}
Ok(TokenUpdate {
event_id: event.id,
token,
destroyed: previous.to_vec(),
})
}
/// Claim paid quotes, then converge each mint.
async fn reconcile(self) -> Result<Outcome, Error> {
let mut tokens = self.tokens.clone();
let mut updates = Vec::new();
for (mint_url, wallet) in &self.wallets {
if let Err(error) = wallet.recover_incomplete_sagas().await {
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
continue;
}
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
}
for (mint_url, wallet) in &self.wallets {
let mut relay_ys = HashSet::new();
let mut added = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
let unspent = wallet.get_unspent_proofs().await?;
let removed = unspent
.iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(added, removed).await?;
}
Ok(Outcome::Reconciled { updates })
}
}
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
tokens
.iter()
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
.map(|(id, _)| *id)
.collect()
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,
recipient: PublicKey,
) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
let filter = Filter::new()
.author(recipient)
.kind(Kind::Custom(10019))
.limit(1);
let info = match ctx
.client
.database()
.query(filter.clone())
.await?
.into_iter()
.next()
{
Some(event) => event,
None => {
let mut stream = ctx
.client
.stream_events(filter)
.timeout(FETCH_TIMEOUT)
.await?;
let mut found: Option<Event> = None;
while let Some((_url, result)) = stream.next().await {
if let Ok(event) = result {
let newer = found
.as_ref()
.is_none_or(|current| event.created_at > current.created_at);
if newer {
found = Some(event);
}
}
}
found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
}
};
let (p2pk, mints) = parse_nutzap_info(&info)?;
for mint in mints {
let Ok(mint_url) = MintUrl::from_str(&mint) else {
continue;
};
if ctx.wallets.contains_key(&mint_url) {
return Ok((mint_url, mint, p2pk));
}
}
Err(anyhow!("no shared mint with the recipient"))
}
fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec<String>), Error> {
let mut p2pk = None;
let mut mints = Vec::new();
for tag in event.tags.iter() {
let Some(value) = tag.content() else {
continue;
};
match tag.kind() {
"pubkey" if p2pk.is_none() => {
p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
}
"mint" => mints.push(value.to_string()),
_ => {}
}
}
let Some(p2pk) = p2pk else {
return Err(anyhow!("recipient has no wallet pubkey"));
};
Ok((p2pk, mints))
}
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
Ok(CashuProof {
id: proof.keyset_id.to_string(),
amount: u64::from(proof.amount),
secret: proof.secret.to_string(),
c: proof.c.to_string(),
})
}
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
}
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
let keyset_id = Id::from_str(&proof.id)?;
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
let info = ProofInfo::new(
Proof::new(
Amount::from(proof.amount),
keyset_id,
Secret::new(proof.secret.clone()),
c,
),
mint.clone(),
State::Unspent,
CurrencyUnit::Sat,
)?;
Ok(info)
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "nip60_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nip60 = { path = "../nip60" }
person = { path = "../person" }
settings = { path = "../settings" }
state = { path = "../state" }
ui = { path = "../ui" }
gpui.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
-77
View File
@@ -1,77 +0,0 @@
#![cfg(not(target_arch = "wasm32"))]
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use nip60::WalletRegistry;
use nostr_sdk::prelude::{EventId, PublicKey};
pub mod wallet;
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub fn open_nutzap(
window: &mut Window,
cx: &mut App,
recipient: PublicKey,
target: Option<EventId>,
) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let send: SubmitFn = {
let input = input.clone();
Rc::new(move |_, window, cx| {
let amount = match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => amount,
_ => {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
}
};
let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.))
.title("Send Nutzap")
.on_ok({
let send = send.clone();
move |event, window, cx| send(event, window, cx)
})
.child(
v_flex()
.gap_3()
.child(Input::new(&input).cleanable(true))
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(Button::new("send").label("Send").primary().on_click({
let send = send.clone();
move |event, window, cx| {
if send(event, window, cx) {
window.close_dialog(cx);
}
}
})),
),
)
});
}
-787
View File
@@ -1,787 +0,0 @@
use std::ops::Range;
use std::rc::Rc;
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use smallvec::SmallVec;
use state::NostrRegistry;
use ui::Avatar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx))
}
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let wallet = WalletRegistry::global(cx);
let mut subscriptions = SmallVec::new();
subscriptions.push(cx.subscribe_in(
&wallet,
window,
|_this, _wallet, event, window, cx| match event {
WalletEvent::Updated => cx.notify(),
WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
WalletEvent::TokenCreated(_) => {}
},
));
Self {
name: "Wallet".into(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
let balance = WalletRegistry::global(cx).read(cx).balance();
h_flex()
.justify_center()
.items_baseline()
.gap_2()
.child(
div()
.text_3xl()
.line_height(relative(1.0))
.child(SharedString::from(balance.to_string())),
)
.child(
div()
.italic()
.line_height(relative(1.0))
.text_color(cx.theme().muted_foreground)
.child("sats"),
)
}
fn render_actions(&self) -> impl IntoElement {
h_flex().w_full().justify_center().child(
h_flex()
.w_80()
.gap_2()
.child(
Button::new("send")
.label("Send")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_send_dialog(window, cx)),
)
.child(
Button::new("receive")
.label("Receive")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_receive_dialog(window, cx)),
),
)
}
fn render_history(
&mut self,
history: Vec<HistoryEntry>,
cx: &mut Context<Self>,
) -> impl IntoElement {
if history.is_empty() {
return v_flex()
.items_center()
.justify_center()
.h_32()
.w_full()
.text_xs()
.text_color(cx.theme().muted_foreground)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.child("No transactions yet.")
.into_any_element();
}
uniform_list(
"wallet-history",
history.len(),
cx.processor(move |this, range, _window, cx| {
this.render_history_rows(range, &history, cx)
}),
)
.flex_1()
.min_h_0()
.into_any_element()
}
fn render_history_rows(
&self,
range: std::ops::Range<usize>,
history: &[HistoryEntry],
cx: &Context<Self>,
) -> Vec<AnyElement> {
range
.map(|ix| {
self.render_history_entry(&history[ix], cx)
.into_any_element()
})
.collect()
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> impl IntoElement {
let (label, color) = match entry.direction {
TransactionDirection::In => ("Received", cx.theme().success),
TransactionDirection::Out => ("Sent", cx.theme().danger),
};
h_flex()
.justify_between()
.w_full()
.py_0p5()
.text_xs()
.child(
h_flex()
.gap_2()
.child(div().text_color(color).child(label))
.child(SharedString::from(format!("{} sats", entry.amount))),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(entry.created_at.to_human_time()),
)
}
}
/// Pick a contact and an amount, then send a NIP-61 nutzap
fn open_send_dialog(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| SendDialog::new(window, cx));
window.open_dialog(cx, {
let view = view.clone();
move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
});
}
struct SendDialog {
/// Search input, filters contacts or accepts an npub / NIP-05 address
input: Entity<InputState>,
amount_input: Entity<InputState>,
/// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Person directory for contact names and avatars
directory: Entity<Directory>,
/// The contact chosen to receive the nutzap
selected: Option<PublicKey>,
/// Whether a NIP-05 address is being resolved
resolving: bool,
error: Option<SharedString>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
_subscriptions: Vec<Subscription>,
}
impl SendDialog {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
});
let amount_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let subscriptions = vec![
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.submit(window, cx);
}
}),
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send(window, cx);
}
}),
];
// Load the contacts once the view is mounted
cx.defer_in(window, |this, window, cx| {
this.load_contacts(window, cx);
});
Self {
input,
amount_input,
contacts: None,
directory: Directory::new(cx),
selected: None,
resolving: false,
error: None,
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Load the signed-in user's contact list.
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_some() {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let contacts = task.await;
this.update_in(cx, |this, _window, cx| {
match contacts {
Ok(contacts) => {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
}
Err(error) => this.set_error(error.to_string(), cx),
}
cx.notify();
})?;
Ok(())
}));
}
/// The contacts matching the current input, or every contact when empty.
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
let Some(contacts) = self.contacts.as_ref() else {
return Vec::new();
};
let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return contacts.clone();
}
contacts
.iter()
.filter(|public_key| {
if public_key.to_hex().to_lowercase().contains(&query) {
return true;
}
let name = self.directory.read(cx).person(public_key, cx).name();
name.to_lowercase().contains(&query)
})
.cloned()
.collect()
}
/// Resolve the input into a contact selection.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.resolving {
return;
}
let query = self.input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
// A raw public key or an npub can be selected directly.
if let Ok(public_key) = PublicKey::parse(&query) {
self.select(public_key, cx);
self.clear_input(window, cx);
return;
}
// A NIP-05 address is resolved before being selected.
if let Ok(address) = Nip05Address::parse(&query) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).query_address(address, cx);
self.set_resolving(true, window, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
this.set_resolving(false, window, cx);
match result {
Ok(public_key) => {
this.select(public_key, cx);
this.clear_input(window, cx);
}
Err(error) => this.set_error(error.to_string(), cx),
}
})?;
Ok(())
}));
return;
}
// Fall back to a local search by contact name.
let matches = self.filtered_contacts(cx);
match matches.len() {
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
1 => {
let public_key = matches[0];
self.select(public_key, cx);
self.clear_input(window, cx);
}
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
}
}
/// Select a single recipient for the nutzap.
fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
if nostr.read(cx).current_user() == Some(public_key) {
self.set_error("You cannot send to yourself", cx);
return;
}
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.selected = Some(public_key);
self.error = None;
cx.notify();
}
/// Toggle a contact's selection.
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
if self.selected == Some(*public_key) {
self.selected = None;
cx.notify();
} else {
self.select(*public_key, cx);
}
}
/// Validate the selection and amount, then schedule the nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx);
return false;
};
let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx);
return false;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
true
}
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_loading(resolving, window, cx);
});
self.resolving = resolving;
cx.notify();
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn render_contacts(
&self,
range: Range<usize>,
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
contacts
.get(range)
.into_iter()
.flatten()
.map(|public_key| {
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
h_flex()
.id(id)
.w_full()
.h_8()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.text_sm()
.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(selected, |this| this.bg(cx.theme().list_active))
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_selection(&public_key, cx);
}))
.into_any_element()
})
.collect()
}
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
let Some(public_key) = self.selected else {
return div().into_any_element();
};
let profile = self.directory.read(cx).person(&public_key, cx);
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
h_flex()
.gap_1()
.child(
h_flex()
.id(id)
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(
div()
.max_w_32()
.truncate()
.text_xs()
.line_height(relative(1.0))
.child(profile.name()),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
)
.into_any_element()
}
}
impl Render for SendDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
let contacts = Rc::new(self.filtered_contacts(cx));
let contact_count = contacts.len();
v_flex()
.gap_3()
.child(NumberInput::new(&self.amount_input))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).flex_1().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add person")
.ghost()
.large()
.disabled(self.resolving)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
),
)
.when(self.selected.is_some(), |this| {
this.child(self.render_selected(cx))
})
.child(
uniform_list(
"send-contacts",
contact_count,
cx.processor(move |this, range, _window, cx| {
this.render_contacts(range, contacts.as_slice(), cx)
}),
)
.h_64(),
)
.map(|this| {
if let Some(error) = self.error.clone() {
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
} else {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(NOTICE),
)
}
})
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.secondary()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("send")
.label("Send")
.primary()
.on_click(cx.listener(move |this, _event, window, cx| {
if this.send(window, cx) {
window.close_dialog(cx);
}
})),
),
)
}
}
/// Create a lightning invoice or redeem a pasted cashu token
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
let create_invoice: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = amount_input.clone();
Rc::new(move |_, window, cx| {
let Some(amount) = parse_amount(&input, cx) else {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
};
wallet.update(cx, |registry, cx| {
registry.create_mint_quote(amount, cx);
});
true
})
};
let redeem: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = token_input.clone();
Rc::new(move |_, window, cx| {
let token = input.read(cx).value().trim().to_string();
if token.is_empty() {
window.push_notification(Notification::error("Paste a cashu token"), cx);
return false;
}
wallet.update(cx, |registry, cx| {
registry.receive_token(token, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(420.))
.title("Receive")
.on_ok({
let token_input = token_input.clone();
let create_invoice = create_invoice.clone();
let redeem = redeem.clone();
move |_event, window, cx| {
if !token_input.read(cx).value().trim().is_empty() {
redeem(_event, window, cx)
} else {
create_invoice(_event, window, cx)
}
}
})
.child(
v_flex()
.gap_3()
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Lightning invoice:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&amount_input).cleanable(true))
.child(
Button::new("invoice")
.label("Invoice")
.secondary()
.w_24()
.on_click({
let create_invoice = create_invoice.clone();
move |event, window, cx| {
if create_invoice(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
)
.child(Separator::horizontal())
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Cashu token:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&token_input).cleanable(true))
.child(
Button::new("redeem")
.label("Redeem")
.secondary()
.w_24()
.on_click({
let redeem = redeem.clone();
move |event, window, cx| {
if redeem(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
),
)
});
}
fn parse_amount(input: &Entity<InputState>, cx: &App) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => Some(amount),
_ => None,
}
}
impl gpui_kit::component::dock::BasePanel for WalletPanel {
fn panel_name(&self) -> &'static str {
"Wallet"
}
}
impl Panel for WalletPanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Wallet)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for WalletPanel {}
impl Focusable for WalletPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
v_flex()
.size_full()
.p_3()
.gap_4()
.text_xs()
.child(self.render_balance(cx))
.child(self.render_actions())
.child(
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("History"),
)
.child(self.render_history(history, cx)),
)
}
}
+3 -7
View File
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Box<Person>),
Person(Person),
Announcement(Event),
Relays(Event),
Emojis(Event),
@@ -111,7 +111,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(*person, cx);
this.insert(person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
@@ -175,11 +175,7 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
if tx
.send_async(Dispatch::Person(Box::new(person)))
.await
.is_err()
{
if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("Channel closed, dropping metadata event");
}
}
-7
View File
@@ -144,13 +144,6 @@ impl Person {
/// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
// A delayed or repeated announcement must not roll the key back
if let Some(current) = &self.announcement
&& !announcement.is_newer_than(current)
{
return;
}
self.announcement = Some(announcement);
}
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
publish.workspace = true
[dependencies]
gpui-kit.workspace = true
common = { path = "../common" }
nostr-sdk.workspace = true
+86 -16
View File
@@ -3,7 +3,7 @@ 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};
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
@@ -36,6 +36,33 @@ impl AppearanceMode {
}
}
/// The default base font size for the interface
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
/// User tuning applied on top of the native OS theme
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ThemeSettings {
/// The base UI font size in pixels
pub font_size: f32,
/// Draw a ring around focused controls
pub focus_ring: bool,
/// Corner radius for general elements; follows the OS theme when unset
pub radius: Option<f32>,
/// Corner radius for large elements like dialogs; follows the OS theme when unset
pub radius_lg: Option<f32>,
}
impl Default for ThemeSettings {
fn default() -> Self {
Self {
font_size: DEFAULT_FONT_SIZE,
focus_ring: false,
radius: None,
radius_lg: None,
}
}
}
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
@@ -64,14 +91,16 @@ pub fn persisted_appearance_mode() -> AppearanceMode {
.unwrap_or_default()
}
/// Read the persisted theme tuning synchronously.
pub fn persisted_theme() -> ThemeSettings {
persisted_settings()
.map(|settings| settings.theme)
.unwrap_or_default()
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
@@ -118,7 +147,6 @@ setting_accessors! {
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
@@ -126,6 +154,7 @@ setting_accessors! {
}
setting_getters! {
pub theme: ThemeSettings,
pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>,
}
@@ -193,12 +222,12 @@ pub struct Settings {
pub wot_auto_trust: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
/// Tuning applied on top of the native OS theme
#[serde(default)]
pub theme: ThemeSettings,
/// Show an image behind chat and group panels
#[serde(default)]
pub panel_background: bool,
@@ -225,8 +254,8 @@ impl Default for Settings {
screening: true,
wot_auto_trust: true,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true,
panel_background_path: None,
trusted_relays: vec![],
@@ -291,6 +320,34 @@ impl AppSettings {
});
}
/// Edit the theme tuning
pub fn update_theme(edit: impl FnOnce(&mut ThemeSettings), cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.inner.update(cx, |inner, cx| {
edit(&mut inner.theme);
cx.notify();
});
});
}
/// Apply the persisted theme tuning on top of the active theme
pub fn apply_theme_style(cx: &mut App) {
use gpui_kit::component::Theme;
let theme = Self::get_theme(cx);
let styled = Theme::global_mut(cx);
styled.font_size = px(theme.font_size);
styled.focus_ring = theme.focus_ring;
if let Some(radius) = theme.radius {
styled.radius = px(radius);
}
if let Some(radius_lg) = theme.radius_lg {
styled.radius_lg = px(radius_lg);
}
Theme::sync_base(cx);
cx.refresh_windows();
}
/// Load settings
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
@@ -323,6 +380,7 @@ impl AppSettings {
// Update settings
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
@@ -348,6 +406,19 @@ impl AppSettings {
}
}
/// Apply the persisted appearance mode to the app
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
use gpui_kit::component::Theme;
let mode = self.inner.read(cx).appearance_mode;
match mode {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
}
cx.refresh_windows();
}
/// Check if decoupling encryption key is enabled
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
self.inner.read(cx).nip4e
@@ -407,9 +478,9 @@ impl AppSettings {
mod tests {
use super::*;
/// Settings written by older versions, including theme tuning, must keep loading.
/// Settings written before theme tuning existed must keep loading.
#[test]
fn legacy_settings_keep_loading() {
fn settings_without_theme_keep_defaults() {
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
@@ -417,13 +488,12 @@ mod tests {
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"theme": { "font_size": 18.0, "focus_ring": true },
"trusted_relays": [],
"file_server": "https://nostr.download/"
}"#,
)
.unwrap();
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
assert_eq!(settings.theme, ThemeSettings::default());
}
}
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "speech"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
log.workspace = true
gpui.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
flume.workspace = true
instant.workspace = true
tts.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
common = { path = "../common" }
parakeet-rs = "=0.3.8"
ort = "=2.0.0-rc.13"
reqwest = { version = "0.12", default-features = false, features = [
"blocking",
"rustls-tls",
] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Window"] }
-59
View File
@@ -1,59 +0,0 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod model;
#[cfg(not(target_arch = "wasm32"))]
mod parakeet;
pub mod speaker;
#[cfg(target_arch = "wasm32")]
mod web;
use std::time::Duration;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, IntoElement as _, ParentElement as _,
Styled as _, div,
};
use gpui_kit::component::ActiveTheme as _;
use gpui_kit::component::speech::SpeechRecognizer;
/// A ticking `M:SS` counter for the dictation in progress.
pub fn elapsed(started: instant::Instant, cx: &App) -> AnyElement {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.with_animation(
"dictation-elapsed",
Animation::new(Duration::from_secs(1))
.repeat()
.with_max_fps(1.),
move |counter, _| counter.child(format_elapsed(started)),
)
.into_any_element()
}
fn format_elapsed(started: instant::Instant) -> String {
let seconds = started.elapsed().as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// The recognizer backing the composer's microphone button.
#[cfg(not(target_arch = "wasm32"))]
pub fn recognizer() -> impl SpeechRecognizer {
parakeet::ParakeetRecognizer
}
/// Initializes the old component kit that the retained speech widgets still use.
pub fn init_compat(cx: &mut App) {
gpui_kit::init(cx);
}
/// The recognizer backing the composer's microphone button.
#[cfg(target_arch = "wasm32")]
pub fn recognizer() -> impl SpeechRecognizer {
web::WebSpeechRecognizer
}
/// The audio source backing the composer's microphone button on the web.
#[cfg(target_arch = "wasm32")]
pub fn input() -> impl gpui_kit::component::speech::AudioInput {
web::SelfCapturedInput
}
-23
View File
@@ -1,23 +0,0 @@
{
"repository": "istupakov/parakeet-tdt-0.6b-v3-onnx",
"revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce",
"source": "nvidia/parakeet-tdt-0.6b-v3",
"license": "CC-BY-4.0",
"files": [
{
"name": "decoder_joint-model.int8.onnx",
"size": 18202004,
"sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70"
},
{
"name": "encoder-model.int8.onnx",
"size": 652183999,
"sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09"
},
{
"name": "vocab.txt",
"size": 93939,
"sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d"
}
]
}
-295
View File
@@ -1,295 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::{fs, io};
use anyhow::{Result, bail};
use serde::Deserialize;
/// How the model is currently available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
NotInstalled,
Downloading { downloaded: u64, total: u64 },
Installed,
}
impl Status {
pub fn is_downloading(self) -> bool {
matches!(self, Status::Downloading { .. })
}
}
#[derive(Debug, Deserialize)]
struct Manifest {
repository: String,
revision: String,
files: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Artifact {
name: String,
size: u64,
sha256: String,
}
fn manifest() -> &'static Manifest {
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
MANIFEST.get_or_init(|| {
serde_json::from_str(include_str!("model.json")).expect("pinned model manifest")
})
}
/// The directory the model artifacts are downloaded into.
pub fn dir() -> PathBuf {
common::config_dir()
.join("speech")
.join("parakeet-tdt-0.6b-v3")
}
/// Total download size in bytes.
pub fn total_size() -> u64 {
manifest().files.iter().map(|file| file.size).sum()
}
/// The revision of the installed model, if its artifacts are complete.
fn installed_revision() -> Option<String> {
let manifest = manifest();
let dir = dir();
let complete = manifest.files.iter().all(|file| {
fs::metadata(dir.join(&file.name)).is_ok_and(|metadata| metadata.len() == file.size)
});
if complete
&& fs::read_to_string(dir.join("verified"))
.is_ok_and(|revision| revision == manifest.revision)
{
Some(manifest.revision.clone())
} else {
None
}
}
/// Whether the model is ready to load.
pub fn installed() -> bool {
status() == Status::Installed
}
/// The current status, reconciling the download state with the disk.
pub fn status() -> Status {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running {
downloaded, total, ..
} = state.download
{
return Status::Downloading { downloaded, total };
}
if installed_revision().is_some() {
Status::Installed
} else {
Status::NotInstalled
}
}
/// Take the last download failure, if it has not been reported yet.
pub fn take_error() -> Option<String> {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.error.take()
}
/// Start downloading the model, unless a download is already running.
pub fn start_download() {
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if matches!(state.download, Download::Running { .. }) {
return;
}
let cancel = Arc::new(AtomicBool::new(false));
state.error = None;
state.download = Download::Running {
downloaded: 0,
total: total_size(),
cancel: cancel.clone(),
};
if let Err(error) = std::thread::Builder::new()
.name("speech-model-download".into())
.spawn(move || download(cancel))
{
state.download = Download::Idle;
state.error = Some(error.to_string());
}
}
/// Request cancellation of the running download.
pub fn cancel_download() {
let state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { cancel, .. } = &state.download {
cancel.store(true, Ordering::Release);
}
}
/// Delete the downloaded model and any partial download.
pub fn delete() {
super::parakeet::unload();
cancel_download();
if let Err(error) = fs::remove_dir_all(dir())
&& error.kind() != io::ErrorKind::NotFound
{
log::warn!("failed to remove the speech model: {error}");
}
}
fn download(cancel: Arc<AtomicBool>) {
let result = download_inner(&cancel);
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
state.download = Download::Idle;
// A cancelled download must not report a spurious failure.
if !cancelled(&cancel)
&& let Err(error) = result
{
state.error = Some(format!("{error:#}"));
}
}
fn download_inner(cancel: &AtomicBool) -> Result<()> {
let manifest = manifest();
let dir = dir();
let client = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.build()?;
fs::create_dir_all(&dir)?;
for file in &manifest.files {
if cancelled(cancel) {
bail!("Cancelled");
}
let staging = dir.join(format!("{}.part", file.name));
let result = download_file(&client, manifest, file, &staging, cancel);
if result.is_err() || cancelled(cancel) {
let _unused = fs::remove_file(&staging);
result?;
bail!("Cancelled");
}
}
fs::write(dir.join("verified"), &manifest.revision)?;
Ok(())
}
fn download_file(
client: &reqwest::blocking::Client,
manifest: &Manifest,
file: &Artifact,
staging: &Path,
cancel: &AtomicBool,
) -> Result<()> {
use std::io::{Read as _, Write as _};
use sha2::Digest as _;
let url = format!(
"https://huggingface.co/{}/resolve/{}/{}",
manifest.repository, manifest.revision, file.name
);
let mut response = client.get(url).send()?.error_for_status()?;
let mut out = fs::File::create(staging)?;
let mut hash = sha2::Sha256::new();
let mut size = 0u64;
loop {
if cancelled(cancel) {
bail!("Cancelled");
}
let mut chunk = [0u8; 64 * 1024];
let read = response.read(&mut chunk)?;
if read == 0 {
break;
}
size += read as u64;
if size > file.size {
bail!("Unexpected model size");
}
hash.update(&chunk[..read]);
out.write_all(&chunk[..read])?;
let mut state = state().lock().unwrap_or_else(|error| error.into_inner());
if let Download::Running { downloaded, .. } = &mut state.download {
*downloaded += read as u64;
}
}
if size != file.size || format!("{:x}", hash.finalize()) != file.sha256 {
bail!("Model checksum verification failed");
}
out.sync_all()?;
fs::rename(staging, dir().join(&file.name))?;
Ok(())
}
fn cancelled(cancel: &AtomicBool) -> bool {
cancel.load(Ordering::Acquire)
}
enum Download {
Idle,
Running {
downloaded: u64,
total: u64,
cancel: Arc<AtomicBool>,
},
}
struct State {
download: Download,
error: Option<String>,
}
fn state() -> &'static Mutex<State> {
static STATE: Mutex<State> = Mutex::new(State {
download: Download::Idle,
error: None,
});
&STATE
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_pins_v3_artifacts_with_exact_sizes() {
let manifest = manifest();
assert_eq!(manifest.repository, "istupakov/parakeet-tdt-0.6b-v3-onnx");
assert_eq!(manifest.revision.len(), 40);
assert_eq!(manifest.files.len(), 3);
assert_eq!(total_size(), 670_479_942);
assert!(
manifest
.files
.iter()
.all(|file| file.sha256.len() == 64 && !file.name.contains('/'))
);
}
}
-214
View File
@@ -1,214 +0,0 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use gpui::{App, Task};
use gpui_kit::component::speech::{
AudioFormat, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
};
use parakeet_rs::{ParakeetTDT, Transcriber as _};
const MODEL_RATE: u32 = 16_000;
const MAX_SECONDS: usize = 120;
/// The on-device recognizer.
pub struct ParakeetRecognizer;
impl SpeechRecognizer for ParakeetRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::new(MODEL_RATE, 1)
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir()));
} else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes."
)));
} else {
return Err(SpeechError::recognizer(anyhow!(
"No speech model is installed. Download it from Settings."
)));
}
// The session buffers everything it is pushed,
// so audio is accepted right away.
sink.ready(cx);
Ok(Box::new(ParakeetSession {
sink,
samples: Vec::new(),
_result: None,
}))
}
}
struct ParakeetSession {
sink: SpeechSink,
samples: Vec<f32>,
_result: Option<Task<()>>,
}
impl RecognitionSession for ParakeetSession {
fn push_audio(&mut self, samples: &[i16], _cx: &mut App) {
if self.samples.len() < MAX_SECONDS * MODEL_RATE as usize {
self.samples
.extend(samples.iter().map(|sample| f32::from(*sample) / 32_768.0));
}
}
fn finish(&mut self, cx: &mut App) {
let samples = std::mem::take(&mut self.samples);
let sink = self.sink.clone();
// Speech too short to transcribe ends the session at once.
let silent = samples.iter().all(|sample| sample.abs() < 0.0001);
if samples.len() < MODEL_RATE as usize / 5 || silent {
sink.finish(cx);
return;
}
let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await;
cx.update(|cx| match result {
Ok(Ok(text)) if !text.is_empty() => {
sink.phrase(text, cx);
sink.finish(cx);
}
Ok(Ok(_)) => sink.finish(cx),
Ok(Err(error)) => sink.error(SpeechError::recognizer(error), cx),
Err(_) => sink.error(
SpeechError::recognizer(anyhow!("The speech worker is unavailable")),
cx,
),
});
}));
}
}
enum Command {
Warm(PathBuf),
Transcribe {
samples: Vec<f32>,
sender: flume::Sender<Result<String, anyhow::Error>>,
},
Unload,
}
/// A handle to the single inference worker.
fn worker() -> &'static flume::Sender<Command> {
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
}
sender
})
}
fn run_worker(receiver: flume::Receiver<Command>) {
let mut cached: Option<(PathBuf, ParakeetTDT)> = None;
while let Ok(command) = receiver.recv_timeout(Duration::from_secs(30)) {
match command {
Command::Unload => {
cached = None;
}
Command::Warm(dir) => {
let loaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
load(&mut cached, &dir).map(|_| ())
}));
match flatten_panic(loaded) {
Ok(Ok(())) => {}
Ok(Err(error)) | Err(error) => {
log::warn!("failed to load the speech model: {error:#}")
}
}
}
Command::Transcribe { samples, sender } => {
let transcribed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
transcribe(&mut cached, samples)
}));
let result = match flatten_panic(transcribed) {
Ok(result) => result,
Err(_) => {
cached = None;
Err(anyhow!("Dictation stopped unexpectedly. Try again."))
}
};
if sender.send(result).is_err() {
log::debug!("speech session was cancelled before its result arrived");
}
}
}
}
}
fn load<'a>(
cached: &'a mut Option<(PathBuf, ParakeetTDT)>,
dir: &PathBuf,
) -> Result<&'a mut ParakeetTDT> {
if cached.as_ref().is_none_or(|(path, _)| path != dir) {
let model = ParakeetTDT::from_pretrained(dir, None)
.map_err(|error| anyhow!("Could not load the speech model: {error}"))?;
*cached = Some((dir.clone(), model));
}
let (_, model) = cached.as_mut().context("speech model was unloaded")?;
Ok(model)
}
fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) -> Result<String> {
let model = load(cached, &super::model::dir())?;
model
.transcribe_samples(samples, MODEL_RATE, 1, None)
.map(|result| result.text)
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
}
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() {
worker().send_or_log(Command::Unload);
}
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
result.map_err(|_| anyhow!("The speech worker panicked"))
}
-88
View File
@@ -1,88 +0,0 @@
use std::time::Duration;
use tts::Tts;
/// Words per minute of an engine at its normal rate, for estimating speech duration.
const NORMAL_WPM: f32 = 160.0;
/// Extra time past the estimate before speech is considered finished, for backends that
/// cannot report utterance end.
pub const END_GRACE: Duration = Duration::from_secs(5);
/// Reads text aloud through the platform's speech backend, created on first use.
pub struct Speaker {
tts: Option<Tts>,
end_tx: flume::Sender<()>,
end_rx: flume::Receiver<()>,
}
impl Speaker {
pub fn new() -> Self {
let (end_tx, end_rx) = flume::unbounded();
Self {
tts: None,
end_tx,
end_rx,
}
}
/// Cloneable stream of utterance-finish events.
pub fn end_events(&self) -> flume::Receiver<()> {
self.end_rx.clone()
}
/// Speak the text, interrupting what this speaker is already saying.
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
let end_tx = self.end_tx.clone();
let tts = match self.tts.as_ref() {
Some(tts) => tts,
None => self.tts.insert({
let tts = Tts::default()?;
if let Err(error) = tts.on_utterance_end(move |_| {
// A send error only means the panel dropped its event loop.
let _ = end_tx.send(());
}) {
log::warn!("utterance end events unavailable ({error})");
}
tts
}),
};
tts.speak(text, true)?;
Ok(Self::estimate(tts, text))
}
/// Stop speaking, a speaker that never spoke is already silent.
pub fn stop(&mut self) -> Result<(), tts::Error> {
match self.tts.as_ref() {
Some(tts) => tts.stop()?,
None => return Ok(()),
}
Ok(())
}
/// Best-effort speaking state, a speaker that never spoke is silent.
pub fn speaking(&self) -> bool {
self.tts
.as_ref()
.and_then(|tts| tts.is_speaking().ok())
.unwrap_or(false)
}
/// How long speaking the text is expected to take at the engine's current speed.
fn estimate(tts: &Tts, text: &str) -> Duration {
let words = text.split_whitespace().count().max(1) as f32;
let speed = match (tts.get_rate(), tts.normal_rate()) {
(Ok(rate), Ok(normal)) if normal > 0. => (rate / normal).clamp(0.2, 4.),
_ => 1.,
};
Duration::from_secs_f32(words / NORMAL_WPM * 60. / speed)
}
}
impl Default for Speaker {
fn default() -> Self {
Self::new()
}
}
-256
View File
@@ -1,256 +0,0 @@
use std::cell::RefCell;
use anyhow::anyhow;
use gpui::App;
use gpui_kit::Subscription;
use gpui_kit::component::speech::{
AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer,
SpeechSink,
};
use js_sys::{Array, Function, JsString, Reflect};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast as _, JsValue};
use web_sys::window;
/// The recognizer backed by `SpeechRecognition` (or its `webkit` prefix).
pub struct WebSpeechRecognizer;
impl SpeechRecognizer for WebSpeechRecognizer {
fn audio_format(&self) -> AudioFormat {
AudioFormat::default()
}
fn is_available(&self, _cx: &App) -> bool {
true
}
fn start(
&self,
sink: SpeechSink,
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
let recognition = Recognition::open(sink, cx).map_err(SpeechError::recognizer)?;
Ok(Box::new(recognition))
}
}
pub struct SelfCapturedInput;
impl AudioInput for SelfCapturedInput {
fn start(
&self,
_format: AudioFormat,
_sink: AudioSink,
_cx: &mut App,
) -> Result<Subscription, SpeechError> {
Ok(Subscription::new(|| {}))
}
}
enum Event {
Hypothesis(String),
Phrase(String),
Error(String),
End,
}
struct Recognition {
recognition: JsValue,
on_result: Option<Closure<dyn FnMut(JsValue)>>,
on_error: Option<Closure<dyn FnMut(JsValue)>>,
on_end: Option<Closure<dyn FnMut(JsValue)>>,
_task: gpui::Task<()>,
}
impl Recognition {
fn open(sink: SpeechSink, cx: &mut App) -> anyhow::Result<Self> {
let window = window().ok_or_else(|| anyhow!("No browser window"))?;
let constructor = Reflect::get(&window, &JsString::from("SpeechRecognition"))
.ok()
.filter(|value| value.is_function())
.or_else(|| {
Reflect::get(&window, &JsString::from("webkitSpeechRecognition"))
.ok()
.filter(|value| value.is_function())
})
.ok_or_else(|| anyhow!("This browser does not support speech recognition"))?;
let recognition =
Reflect::construct(constructor.unchecked_ref::<Function>(), &Array::new())
.map_err(|error| js_error("constructing the recognizer", error))?;
let (sender, receiver) = flume::unbounded::<Event>();
let error_sender = sender.clone();
let end_sender = sender.clone();
let finalized = RefCell::new(0usize);
let on_result = Closure::wrap(Box::new(move |event: JsValue| {
let read = |value: &JsValue, property: &str| -> Option<JsValue> {
Reflect::get(value, &JsString::from(property)).ok()
};
let Some(results) = read(&event, "results") else {
return;
};
let length = read(&results, "length")
.and_then(|length| length.as_f64())
.unwrap_or(0.0) as usize;
let mut finalized = finalized.borrow_mut();
let mut interim = String::new();
for index in *finalized..length {
let Some(result) = read(&results, &index.to_string()) else {
continue;
};
let Some(alternative) = read(&result, "0") else {
continue;
};
let Some(text) = read(&alternative, "transcript").and_then(|text| text.as_string())
else {
continue;
};
let is_final = read(&result, "isFinal")
.and_then(|final_value| final_value.as_bool())
.unwrap_or(false);
if is_final {
*finalized += 1;
if sender.send(Event::Phrase(text)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
} else {
interim.push_str(&text);
}
}
if !interim.is_empty() && sender.send(Event::Hypothesis(interim)).is_err() {
log::debug!("speech session was cancelled mid-utterance");
}
}) as Box<dyn FnMut(JsValue)>);
let on_error = Closure::wrap(Box::new(move |event: JsValue| {
let message = Reflect::get(&event, &JsString::from("error"))
.ok()
.and_then(|error| error.as_string())
.unwrap_or_else(|| "unknown speech error".into());
if error_sender.send(Event::Error(message)).is_err() {
log::debug!("speech session was cancelled before its error arrived");
}
}) as Box<dyn FnMut(JsValue)>);
let on_end = Closure::wrap(Box::new(move |_event: JsValue| {
if end_sender.send(Event::End).is_err() {
log::debug!("speech session was cancelled before its end arrived");
}
}) as Box<dyn FnMut(JsValue)>);
set_callback(&recognition, "onresult", on_result.as_ref().unchecked_ref())?;
set_callback(&recognition, "onerror", on_error.as_ref().unchecked_ref())?;
set_callback(&recognition, "onend", on_end.as_ref().unchecked_ref())?;
set_bool(&recognition, "continuous", true)?;
set_bool(&recognition, "interimResults", true)?;
call_method(&recognition, "start")?;
// The browser consumes audio as soon as it has permission
sink.ready(cx);
let task = cx.spawn(async move |cx| {
while let Ok(event) = receiver.recv_async().await {
let done = matches!(event, Event::End);
// The speech state ignores sink calls once its session ended,
// so a late event after a cancel is harmless.
cx.update(|cx| match event {
Event::Hypothesis(text) => sink.hypothesis(text, cx),
Event::Phrase(text) => sink.phrase(text, cx),
Event::Error(message) => {
sink.error(SpeechError::recognizer(anyhow!("{message}")), cx)
}
Event::End => sink.finish(cx),
});
if done {
break;
}
}
});
Ok(Self {
recognition,
on_result: Some(on_result),
on_error: Some(on_error),
on_end: Some(on_end),
_task: task,
})
}
fn stop(&mut self) {
self.on_result = None;
self.on_error = None;
self.on_end = None;
let _ignored = call_method(&self.recognition, "abort");
}
}
impl Drop for Recognition {
fn drop(&mut self) {
self.stop();
}
}
impl RecognitionSession for Recognition {
fn push_audio(&mut self, _samples: &[i16], _cx: &mut App) {
// The browser recognizer captures the microphone itself.
// the state still pushes audio through its input, which is a no-op here.
}
fn finish(&mut self, _cx: &mut App) {
// The end handler reports completion, so no result is fabricated here.
let _ignored = call_method(&self.recognition, "stop");
}
}
fn set_callback(target: &JsValue, property: &str, callback: &Function) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), callback)
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn set_bool(target: &JsValue, property: &str, value: bool) -> anyhow::Result<()> {
Reflect::set(target, &JsString::from(property), &JsValue::from(value))
.map_err(|error| js_error(property, error))?;
Ok(())
}
fn call_method(target: &JsValue, method: &str) -> anyhow::Result<()> {
let function = Reflect::get(target, &JsString::from(method))
.map_err(|error| js_error(method, error))?
.unchecked_into::<Function>();
function
.call0(target)
.map(|_| ())
.map_err(|error| js_error(method, error))
}
fn js_error(what: &str, error: JsValue) -> anyhow::Error {
let description = error
.as_string()
.or_else(|| {
Reflect::get(&error, &JsString::from("message"))
.ok()
.and_then(|message| message.as_string())
})
.unwrap_or_else(|| "unknown error".into());
anyhow!("{what} failed: {description}")
}
+8 -8
View File
@@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task};
use crate::USER_KEYRING;
/// The storage key for the log of handled NIP-4e key-sync request ids
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
/// 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)]
@@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task<Result<()>> {
}
}
/// Read the log of handled key-sync request ids, as concatenated hex ids
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
read_named(cx, PROCESSED_REQUESTS_KEY)
/// 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 log of handled key-sync request ids, as concatenated hex ids
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
/// 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)
}
+1 -14
View File
@@ -5,10 +5,6 @@ use nostr_sdk::prelude::*;
pub struct Announcement {
/// The public key of the device that created this announcement.
public_key: PublicKey,
/// The id of the announcement event, for ordering
event_id: EventId,
/// The creation time of the announcement event
created_at: Timestamp,
}
impl Announcement {
@@ -20,20 +16,11 @@ impl Announcement {
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())?;
Some(Self {
public_key,
event_id: event.id,
created_at: event.created_at,
})
Some(Self { public_key })
}
/// Returns the public key of the device that created this announcement.
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn is_newer_than(&self, other: &Self) -> bool {
self.created_at > other.created_at
|| (self.created_at == other.created_at && self.event_id < other.event_id)
}
}
-4
View File
@@ -26,11 +26,9 @@ pub enum IconName {
PlusChat,
Profile,
Relay,
Speech,
Search,
Settings,
Settings2,
Unlock,
Upload,
Warning,
}
@@ -60,11 +58,9 @@ impl IconNamed for IconName {
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Speech => "icons/speech.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
}
+7 -76
View File
@@ -1,21 +1,19 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
StyleRefinement, Styled, StyledImage, Window, div, img, px,
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
img, px,
};
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, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
v_flex,
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
};
use smallvec::SmallVec;
@@ -599,14 +597,13 @@ pub fn react_menu(
})
}
/// A hovered message's action bar: quick reactions, reply, copy, read aloud, and an overflow menu.
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
#[derive(IntoElement)]
pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<(ElementId, SharedString)>,
speech: Option<(bool, SharedClickListener)>,
dropdown: Option<DropdownBuilder>,
}
@@ -618,7 +615,6 @@ impl MessageActions {
reactions: SmallVec::new(),
reply: None,
copy: None,
speech: None,
dropdown: None,
}
}
@@ -646,16 +642,6 @@ impl MessageActions {
self
}
/// Add the read-aloud button, disabled while a message is already being spoken.
pub fn speech(
mut self,
disabled: bool,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.speech = Some((disabled, Rc::new(handler)));
self
}
/// Fill the bar's overflow menu.
pub fn dropdown(
mut self,
@@ -685,13 +671,10 @@ impl RenderOnce for MessageActions {
reactions,
reply,
copy,
speech,
dropdown,
} = self;
// The leading separator only separates reactions from the center buttons
let has_center =
!reactions.is_empty() && (reply.is_some() || copy.is_some() || speech.is_some());
let has_center = reply.is_some() || copy.is_some();
h_flex()
.p_0p5()
@@ -729,17 +712,6 @@ impl RenderOnce for MessageActions {
.when_some(copy, |this, (id, value)| {
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
})
.when_some(speech, |this, (disabled, speech)| {
this.child(
Button::new("speech")
.icon(crate::IconName::Speech)
.tooltip("Read aloud")
.small()
.ghost()
.disabled(disabled)
.on_click(move |event, window, cx| speech(event, window, cx)),
)
})
.when_some(dropdown, |this, dropdown| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
@@ -754,44 +726,3 @@ impl RenderOnce for MessageActions {
.refine_style(&style)
}
}
/// A message's text revealed word by word, paced by the estimated speech duration.
#[derive(IntoElement)]
pub struct RevealText {
text: SharedString,
duration: Duration,
}
impl RevealText {
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
Self {
text: text.into(),
duration,
}
}
}
impl RenderOnce for RevealText {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let words: Vec<SharedString> = self
.text
.split_whitespace()
.map(SharedString::from)
.collect();
let total = words.len().max(1) as f32;
div().flex().flex_wrap().gap_x_1().with_animation(
"speech-reveal",
Animation::new(self.duration).with_max_fps(30.),
move |this, progress| {
let visible = ((progress * total).ceil() as usize).min(words.len());
this.children(words.iter().enumerate().map(|(ix, word)| {
div()
.when(ix >= visible, |this| this.invisible())
.child(word.clone())
}))
},
)
}
}
+2 -8
View File
@@ -7,16 +7,14 @@ publish.workspace = true
[dependencies]
ui = { path = "../ui" }
gpui-kit.workspace = true
gpui-native-kit.workspace = true
common = { path = "../common" }
state = { path = "../state" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
nip4e = { path = "../nip4e" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
nip29 = { path = "../nip29" }
nip29_ui = { path = "../nip29_ui" }
settings = { path = "../settings" }
speech = { path = "../speech" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
@@ -31,7 +29,3 @@ serde.workspace = true
log.workspace = true
rand.workspace = true
smallvec.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nip60 = { path = "../nip60" }
nip60_ui = { path = "../nip60_ui" }
+44 -88
View File
@@ -1,18 +1,15 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, px, relative,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px, relative,
};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form};
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, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -35,6 +32,7 @@ pub fn open_unlock(window: &mut Window, cx: &mut App) {
this.close_button(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
.child(import.clone())
});
}
@@ -76,15 +74,7 @@ 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| {
let input = InputState::new(window, cx).masked(true);
if mode == Mode::Unlock {
input.placeholder("Passphrase to unlock your identity")
} else {
input
}
});
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);
@@ -245,59 +235,15 @@ impl ImportIdentity {
Ok(())
}));
}
fn render_unlock(&self, cx: &mut Context<Self>) -> Div {
v_flex()
.gap_4()
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&self.pass_input)),
),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_sm()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child(
DialogFooter::new().child(
Button::new("unlock")
.primary()
.icon(IconName::Unlock)
.label("Unlock")
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
),
)
}
}
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.mode == Mode::Unlock {
return self.render_unlock(cx);
}
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_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string();
let require_password = value.starts_with("ncryptsec1");
let require_encrypt = value.starts_with("nsec1");
@@ -311,19 +257,25 @@ impl Render for ImportIdentity {
.child(
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
.when(!is_unlock, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child("Decrypt Password:")
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
})
.when(is_unlock || require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().muted_foreground)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
"Decrypt Password:"
})
.child(Input::new(&self.pass_input)),
)
})
@@ -371,7 +323,7 @@ impl Render for ImportIdentity {
.child({
let content = v_flex().gap_1().child(
Button::new("login")
.label("Continue")
.label(if is_unlock { "Unlock" } else { "Continue" })
.primary()
.loading(self.loading)
.disabled(self.loading)
@@ -381,26 +333,30 @@ impl Render for ImportIdentity {
);
#[cfg(not(target_arch = "wasm32"))]
let content = content.child(
Button::new("proxy")
.label("Connect via Web Extension")
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
);
let content = content.when(!is_unlock, |this| {
this.child(
Button::new("proxy")
.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.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);
})),
);
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
})
+3 -3
View File
@@ -1,11 +1,11 @@
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
+140 -220
View File
@@ -1,42 +1,34 @@
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use chat_ui::ToggleDictation;
#[cfg(not(target_arch = "wasm32"))]
use gpui::PathPromptOptions;
#[cfg(not(target_arch = "wasm32"))]
use gpui::Task;
use gpui::http_client::Url;
use gpui::{
AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, 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};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::kbd::Kbd;
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification;
#[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::progress::Progress;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
#[cfg(not(target_arch = "wasm32"))]
use speech::model::{self as speech_model};
use settings::{AppSettings, AppearanceMode};
use ui::{settings_block, settings_row};
/// Bounds for the theme number inputs
const FONT_SIZE_RANGE: (f32, f32) = (10., 32.);
const RADIUS_RANGE: (f32, f32) = (0., 24.);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
}
pub struct Preferences {
file_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
mint_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec<Subscription>,
}
@@ -50,46 +42,93 @@ impl Preferences {
.placeholder("https://myblossom.com")
});
#[cfg(not(target_arch = "wasm32"))]
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
#[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500);
let mut last = speech_model::status();
loop {
cx.background_executor().timer(timer).await;
let status = speech_model::status();
let error = speech_model::take_error();
if status == last {
continue;
}
last = status;
this.update_in(cx, |_, window, cx| {
if let Some(error) = error {
window.push_notification(Notification::error(error).autohide(false), cx);
}
cx.notify();
})
.ok();
}
let theme = AppSettings::get_theme(cx);
let font_size_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(theme.font_size.to_string())
.step(1.)
.min(FONT_SIZE_RANGE.0 as f64)
.max(FONT_SIZE_RANGE.1 as f64)
});
let radius_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(
theme
.radius
.map(|value| value.to_string())
.unwrap_or_default(),
)
.placeholder("System")
.step(1.)
.min(RADIUS_RANGE.0 as f64)
.max(RADIUS_RANGE.1 as f64)
});
let radius_lg_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(
theme
.radius_lg
.map(|value| value.to_string())
.unwrap_or_default(),
)
.placeholder("System")
.step(1.)
.min(RADIUS_RANGE.0 as f64)
.max(RADIUS_RANGE.1 as f64)
});
let subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
if let Some(font_size) = value {
AppSettings::update_theme(|theme| theme.font_size = font_size, cx);
AppSettings::apply_theme_style(cx);
}
}),
Self::wire_number_input(&radius_input, RADIUS_RANGE, cx, |value, cx| {
AppSettings::update_theme(|theme| theme.radius = value, cx);
AppSettings::apply_theme_style(cx);
}),
Self::wire_number_input(&radius_lg_input, RADIUS_RANGE, cx, |value, cx| {
AppSettings::update_theme(|theme| theme.radius_lg = value, cx);
AppSettings::apply_theme_style(cx);
}),
];
Self {
file_input,
#[cfg(not(target_arch = "wasm32"))]
mint_input,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: Vec::new(),
font_size_input,
radius_input,
radius_lg_input,
_subscriptions: subscriptions,
}
}
/// Persist a theme number as it changes; an empty value stores `None`
fn wire_number_input(
state: &Entity<InputState>,
range: (f32, f32),
cx: &mut Context<Self>,
persist: impl Fn(Option<f32>, &mut App) + 'static,
) -> Subscription {
let (min, max) = range;
let state = state.clone();
cx.subscribe(&state, move |_, state, event: &InputEvent, cx| {
if !matches!(event, InputEvent::Change) {
return;
}
let value = state
.read(cx)
.value()
.trim()
.parse::<f32>()
.ok()
.map(|value| value.clamp(min, max));
persist(value, cx);
})
}
/// Update the file server (blossom) URL
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.file_input.read(cx).value();
@@ -104,60 +143,12 @@ impl Preferences {
}
}
/// Create the NIP-60 wallet or add a mint to it
#[cfg(not(target_arch = "wasm32"))]
fn setup_mint(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mint = self.mint_input.read(cx).value().trim().to_string();
if mint.is_empty() {
window.push_notification(Notification::error("Enter a mint URL"), cx);
return;
}
nip60::WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.setup(mint, cx);
});
}
/// The NIP-60 wallet block; absent from the web build
fn render_wallet(
&mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
) -> AnyElement {
#[cfg(target_arch = "wasm32")]
{
return div().into_any_element();
}
#[cfg(not(target_arch = "wasm32"))]
{
const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
settings_block(
"Wallet",
WALLET,
h_flex()
.gap_1()
.child(div().flex_1().child(Input::new(&self.mint_input)))
.child(
Button::new("setup-mint")
.label(if has_wallet { "Add" } else { "Set up" })
.ghost()
.size_8()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.setup_mint(window, cx);
})),
),
cx,
)
.into_any_element()
}
}
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
crate::apply_appearance_mode(mode, cx);
AppSettings::global(cx).update(cx, |settings, cx| {
settings.apply_theme(cx);
});
}
/// Ask the user for an image to show behind chat and group panels
@@ -197,109 +188,21 @@ impl Preferences {
.detach();
}
}
fn render_speech(
&mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
) -> AnyElement {
#[cfg(target_arch = "wasm32")]
{
return div().into_any_element();
}
#[cfg(not(target_arch = "wasm32"))]
{
let size = speech_model::total_size() / 1_000_000;
let status = speech_model::status();
let (description, control) = match &status {
speech_model::Status::NotInstalled => (
format!("{size} MB download"),
Button::new("download-model")
.label("Download")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::start_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Downloading { downloaded, total } => (
format!(
"Downloading · {} of {} MB",
downloaded / 1_000_000,
total / 1_000_000
),
Button::new("cancel-download")
.label("Cancel")
.ghost()
.small()
.on_click(|_, _window, cx| {
speech_model::cancel_download();
cx.refresh_windows();
})
.into_any_element(),
),
speech_model::Status::Installed => (
format!("Parakeet v3 · {size} MB"),
Button::new("remove-model")
.label("Remove")
.secondary()
.small()
.on_click(|_, _window, cx| {
speech_model::delete();
cx.refresh_windows();
})
.into_any_element(),
),
};
let mut block = v_flex().w_full().gap_3().child(settings_row(
"Speech model",
description,
control,
cx,
));
if let speech_model::Status::Downloading { downloaded, total } = &status {
let (downloaded, total) = (*downloaded, *total);
block = block.child(
Progress::new("speech-model-download")
.value(downloaded as f32 / total.max(1) as f32 * 100.)
.small(),
);
}
block.into_any_element()
}
}
/// Shows the keystroke actually bound to the dictation action at startup
fn render_dictation_shortcut(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let control = match Kbd::global_binding_for_action(&ToggleDictation, window) {
Some(kbd) => kbd.into_any_element(),
None => div().into_any_element(),
};
settings_row("Shortcut", "Press to start or stop dictation", control, cx)
}
}
impl Render for Preferences {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const APPEARANCE: &str =
"Choose whether the app follows the system theme or uses a light/dark theme.";
const FONT_SIZE: &str = "The base font size for the interface.";
const RADIUS: &str = "Corner radius for UI elements. Empty follows the OS theme.";
const RADIUS_LG: &str =
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
const FOCUS_RING: &str = "Draw a ring around focused controls.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
@@ -307,8 +210,8 @@ impl Render for Preferences {
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
let theme = AppSettings::get_theme(cx);
let panel_background = AppSettings::get_panel_background(cx);
let panel_background_path = AppSettings::get_panel_background_path(cx);
@@ -318,7 +221,7 @@ impl Render for Preferences {
};
v_flex()
.my_2()
.mt_2()
.gap_4()
.w_full()
.child(
@@ -376,6 +279,44 @@ impl Render for Preferences {
cx,
))
.child(Separator::horizontal())
.child(
v_flex()
.w_full()
.gap_3()
.child(settings_row(
"Font Size",
FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(),
cx,
))
.child(settings_row(
"Corner Radius",
RADIUS,
NumberInput::new(&self.radius_input).w_24(),
cx,
))
.child(settings_row(
"Large Corner Radius",
RADIUS_LG,
NumberInput::new(&self.radius_lg_input).w_24(),
cx,
))
.child(settings_row(
"Focus Ring",
FOCUS_RING,
Switch::new("focus-ring")
.checked(theme.focus_ring)
.on_click(move |_, _window, cx| {
AppSettings::update_theme(
|theme| theme.focus_ring = !theme.focus_ring,
cx,
);
AppSettings::apply_theme_style(cx);
}),
cx,
)),
)
.child(Separator::horizontal())
.child(
v_flex()
.w_full()
@@ -432,25 +373,6 @@ impl Render for Preferences {
}),
cx,
))
.child(settings_row(
"Rotate Encryption Key",
NIP4E_ROTATION,
Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some())
.on_click(move |_, _window, cx| {
let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some();
let days = if enabled {
None
} else {
Some(NIP4E_ROTATION_DEFAULT_DAYS)
};
AppSettings::update_nip4e_rotation_days(days, cx);
}),
cx,
))
.child(Separator::horizontal())
.child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal())
.child(settings_block(
"Media Upload Service",
@@ -469,7 +391,5 @@ impl Render for Preferences {
),
cx,
))
.child(Separator::horizontal())
.child(self.render_wallet(cx))
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
use std::cell::Cell;
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
@@ -165,7 +166,7 @@ impl CoopDockSkin {
.on_drag(ResizePanel, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing_dock().set(Some(placement));
cx.new(|_| info.clone())
cx.new(|_| info.deref().clone())
})
}
}
+9 -64
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use ::settings::AppearanceMode;
use ::settings::{AppSettings, AppearanceMode};
use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
@@ -14,7 +14,7 @@ use gpui::{
use gpui_kit::component::button::Button;
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, WindowExt, v_flex};
use gpui_kit::component::{ActiveTheme, Theme, WindowExt, v_flex};
use nip4e::{DeviceEvent, DeviceRegistry};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
@@ -42,31 +42,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
/// Apply the appearance mode to the kit and mirror it into the old theme
/// that the speech components still use.
pub fn apply_appearance_mode(mode: AppearanceMode, cx: &mut App) {
match mode {
AppearanceMode::System => {
gpui_native_kit::theme::follow_system(cx);
gpui_kit::component::Theme::sync_system_appearance(None, cx);
}
AppearanceMode::Light => {
gpui_native_kit::theme::set_mode_override(
Some(gpui_native_kit::theme::ThemeVariant::Light),
cx,
);
gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Light, None, cx);
}
AppearanceMode::Dark => {
gpui_native_kit::theme::set_mode_override(
Some(gpui_native_kit::theme::ThemeVariant::Dark),
cx,
);
gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx);
}
}
}
struct DeviceNotifcation;
struct MsgRelayNotification;
@@ -86,7 +61,6 @@ enum Command {
ShowContactList,
ShowInbox,
ShowBrowse,
ShowWallet,
NewChat,
NewGroup,
JoinGroup,
@@ -117,9 +91,11 @@ impl Workspace {
let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
}));
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}));
}
subscriptions.push(
// Subscribe to the nostr events
@@ -219,30 +195,6 @@ impl Workspace {
}),
);
#[cfg(not(target_arch = "wasm32"))]
{
let wallet = ::nip60::WalletRegistry::global(cx);
subscriptions.push(cx.subscribe_in(
&wallet,
window,
|_this, _wallet, event, window, cx| match event {
::nip60::WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
::nip60::WalletEvent::TokenCreated(_) => {
window.push_notification(
Notification::new()
.message("Cashu token created, copy it from the wallet panel")
.with_type(NotificationType::Success),
cx,
);
}
::nip60::WalletEvent::Updated => {}
},
));
}
App::on_action(cx, {
let workspace = workspace.clone();
move |action, cx| {
@@ -354,13 +306,6 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::ShowWallet => {
#[cfg(not(target_arch = "wasm32"))]
{
let panel = nip60_ui::wallet::init(window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
}
Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx);
}
@@ -558,8 +503,8 @@ impl Workspace {
impl Render for Workspace {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dialog, notification and tooltip layers previously came from the
// old kit's window root; they move to common dialogs and toasts.
// Dialog, notification and tooltip layers are owned by the framework
// Root that gpui_kit::open_window installed for this window.
div()
.id("workspace")
.on_action(cx.listener(Self::on_command))
+4 -103
View File
@@ -1,25 +1,18 @@
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px,
};
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::base::StyledExt as _;
use gpui::{AnyElement, Entity, IntoElement};
use gpui::{
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::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::shorten_pubkey;
use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName};
@@ -38,10 +31,6 @@ impl Sidebar {
let updater = AutoUpdater::try_global(cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
let encryption_key = profile
.announcement()
.map(|announcement| announcement.public_key());
let encryption_enabled = AppSettings::get_nip4e(cx);
h_flex()
.id("sidebar-user")
@@ -82,11 +71,6 @@ impl Sidebar {
});
actions
.when(encryption_enabled, |actions| {
actions.when_some(encryption_key, |actions, encryption_key| {
actions.child(self.render_encryption(encryption_key))
})
})
.child(
Button::new("sidebar-search")
.icon(IconName::Search)
@@ -127,89 +111,6 @@ impl Sidebar {
})
}
/// The encryption key popover: public key with copy, backup and reset
fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement {
Popover::new("sidebar-encryption")
.anchor(Anchor::TopRight)
.trigger(
Button::new("encryption-key")
.icon(IconName::Lock)
.tooltip("Encryption Key")
.ghost()
.small(),
)
.content(move |_, _, cx| {
let popover = cx.entity();
let Ok(npub) = encryption_key.to_bech32();
v_flex()
.w_64()
.gap_2()
.text_sm()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Public key"),
)
.child(
h_flex()
.items_center()
.gap_1()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(shorten_pubkey(encryption_key, 12))),
)
.child(
Clipboard::new("encryption-key-copy")
.tooltip("Copy public key")
.value(npub),
),
)
.child(Separator::horizontal())
.child(
h_flex()
.justify_end()
.gap_2()
.child(
Button::new("encryption-backup")
.label("Backup")
.outline()
.small()
.on_click({
let popover = popover.clone();
move |_, window, cx| {
popover
.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::BackupEncryption),
cx,
);
}
}),
)
.child(
Button::new("encryption-reset")
.label("Reset")
.ghost()
.small()
.on_click(move |_, window, cx| {
popover.update(cx, |state, cx| state.dismiss(window, cx));
window.dispatch_action(
Box::new(Command::ResetEncryption),
cx,
);
}),
),
)
})
}
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
v_flex()
.p_2()
+1 -13
View File
@@ -39,6 +39,7 @@ impl Sidebar {
let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open;
let rows = Rc::new(self.rows_for(cx));
// Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty();
@@ -59,25 +60,12 @@ impl Sidebar {
.flex_1()
.min_h_0()
.relative()
.border_l_1()
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.px_2()
.gap_1()
.items_start()
.justify_start()
.when(cfg!(not(target_arch = "wasm32")), |this| {
this.child(
Nav::new("wallet")
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
.label("Wallet")
.text_sm()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
}),
)
})
.child(
Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small())
+1 -9
View File
@@ -21,7 +21,6 @@ use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry};
pub(crate) use rows::open_room;
use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers;
@@ -105,8 +104,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -209,11 +206,6 @@ impl Sidebar {
},
));
// The user header reads settings and the tracked profiles on render, so
// the encryption button reacts to toggles and announcement arrivals
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS);
loop {
@@ -237,7 +229,7 @@ impl Sidebar {
chats_open: None,
contacts_open: false,
contacts: None,
directory,
directory: Directory::new(cx),
selected_contacts: HashSet::new(),
requests_open: false,
new_requests: false,
+5 -5
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "2.0.0-alpha3"
version = "2.0.0-alpha2"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -37,9 +37,7 @@ workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
nip60 = { path = "../crates/nip60" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" }
@@ -47,11 +45,13 @@ person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui-kit.workspace = true
gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
gpui-native-kit.workspace = true
speech = { path = "../crates/speech" }
native-theme.workspace = true
native-theme-gpui.workspace = true
flume.workspace = true
reqwest_client.workspace = true
log.workspace = true
+101 -37
View File
@@ -1,15 +1,18 @@
use std::sync::{Arc, Mutex};
use chat_ui::ToggleDictation;
use gpui::{
App, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, WindowBounds,
WindowDecorations, WindowKind, WindowOptions, actions, point, px, size,
App, Bounds, Global, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
actions, point, px, size,
};
use gpui_native_kit::theme::ActiveTheme;
use gpui_kit::component::{Theme, ThemeMode};
use gpui_platform::application;
use native_theme::watch::ThemeSubscription;
use native_theme_gpui::apply_system_theme;
use nostr_sdk::prelude::SecretKey;
use settings::{AppSettings, AppearanceMode};
use state::{APP_ID, CLIENT_NAME};
use workspace::{TAB_BAR_HEIGHT, apply_appearance_mode};
use workspace::TAB_BAR_HEIGHT;
actions!(coop, [Quit]);
@@ -30,9 +33,8 @@ fn main() {
.with_assets(assets::CombinedAssets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
.run(move |cx| {
// Initialize the frameworks before anything renders
gpui_native_kit::init(cx);
speech::init_compat(cx);
// Initialize the framework before anything renders
gpui_kit::init(cx);
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
@@ -43,19 +45,13 @@ fn main() {
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q (macOS) and dictation with CMD+D
// Register the `quit` function with CMD+Q (macOS)
#[cfg(target_os = "macos")]
cx.bind_keys([
KeyBinding::new("cmd-q", Quit, None),
KeyBinding::new("cmd-d", ToggleDictation, None),
]);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others) and dictation with Ctrl+D
// Register the `quit` function with Super+Q (others)
#[cfg(not(target_os = "macos"))]
cx.bind_keys([
KeyBinding::new("super-q", Quit, None),
KeyBinding::new("ctrl-d", ToggleDictation, None),
]);
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
@@ -68,7 +64,15 @@ fn main() {
settings::init(cx);
let appearance = settings::persisted_appearance_mode();
apply_appearance_mode(appearance, cx);
let font_size = settings::persisted_theme().font_size;
match apply_native_theme(appearance, font_size, cx) {
Ok(()) => start_theme_watcher(appearance, cx),
Err(error) => {
log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
}
}
// Initialize the nostr client
state::init(cx, cli_key);
@@ -81,11 +85,6 @@ fn main() {
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
nip4e::init(cx);
// Initialize cashu wallet registry
//
// NIP-60: https://github.com/nostr-protocol/nips/blob/master/60.md
nip60::init(cx);
// Initialize app registry
chat::init(cx);
@@ -100,7 +99,7 @@ fn main() {
// Set up the window options
let opts = WindowOptions {
window_background: cx.theme().window_background,
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
kind: WindowKind::Normal,
@@ -117,18 +116,8 @@ fn main() {
..Default::default()
};
cx.open_window(opts, |window, cx| {
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
gpui_native_kit::theme::Theme::sync_window_rem_size(window, cx);
window
.observe_window_appearance(|window, cx| {
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
cx.refresh_windows();
})
.detach();
workspace::init(window, cx)
})
.expect("Failed to open window. Please restart the application.");
gpui_kit::open_window(opts, cx, workspace::init)
.expect("Failed to open window. Please restart the application.");
cx.activate(true);
});
@@ -181,3 +170,78 @@ fn quit(_ev: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}
/// Apply the native theme to the app, scaled to the user's font size,
/// then layer the persisted theme tuning on top
fn apply_native_theme(
appearance: AppearanceMode,
font_size: f32,
cx: &mut App,
) -> native_theme::Result<()> {
let mut system = native_theme_gpui::SystemTheme::from_system()?;
let native = system.light.defaults.font.size;
if native > 0.0 {
system.accessibility.text_scaling_factor *= font_size / native;
}
apply_system_theme(&system, cx);
apply_appearance(appearance, cx);
// Applying the native theme replaces the whole theme, so the user's
// tuning must be layered on afterwards.
AppSettings::apply_theme_style(cx);
Ok(())
}
/// Honor the appearance mode against the installed (native) palettes
fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
match appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
}
/// Holds the watcher subscription so it lives as long as the app.
struct ThemeWatcher {
_subscription: ThemeSubscription,
/// The appearance mode the observer in `workspace` was registered with.
appearance: std::cell::Cell<AppearanceMode>,
}
impl Global for ThemeWatcher {}
/// Start a background task to watch for OS theme changes and re-apply the native theme.
fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
let (sender, receiver) = flume::unbounded();
match native_theme::watch::on_theme_change(move |event| {
if let Err(error) = sender.send(event) {
log::warn!("theme change dropped ({error})");
}
}) {
Ok(subscription) => {
cx.set_global(ThemeWatcher {
_subscription: subscription,
appearance: std::cell::Cell::new(initial_appearance),
});
cx.spawn(async move |cx| {
while receiver.recv_async().await.is_ok() {
log::info!("OS theme changed; re-applying");
cx.update(|cx| {
let appearance = AppSettings::get_appearance_mode(cx);
let font_size = AppSettings::get_theme(cx).font_size;
if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
watcher.appearance.set(appearance);
}
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
log::warn!("failed to apply updated OS theme ({error})");
}
});
}
})
.detach();
}
Err(error) => log::warn!("OS theme watching unavailable ({error})"),
}
}
-403
View File
@@ -1,403 +0,0 @@
# Migrate coop to gpui-native-kit
## Context
`coop` (workspace root `/Users/reya/Documents/Git/coop`) builds every screen on `gpui-kit` 0.8.1 (a facade over `gpui-component`/`gpui-base`) plus `native-theme` 0.7. The target is `gpui-native-kit` at `/Users/reya/Documents/Git/gpui-native-kit` (git `ssh://git@git.reya.info/reya/gpui-native-kit.git`), using its theme, icons, core components, data views, editor markdown, and layout dock. The app must end with one design system: delete local duplicates (`ui::Avatar`, `settings_row`, `settings_block`, `title_bar_drag_handlers`, the old dock skin/tab bar/window controls), keep app-specific product views (`MessageRow`, `ReactionBar`, `Nav`, etc.) rebased on kit primitives and tokens. `gpui-kit` remains only as the speech compatibility island because `gpui-native-kit` has no speech module.
Scope decisions taken: only design-system duplicates are deleted; product-specific views (`MessageRow`, `ReactionBar`, `Nav`, etc.) stay and are rebased on kit primitives/tokens. Speech/dictation stays on the old `gpui-kit` because the new kit has no speech module; that is the only remaining old-kit dependency.
The new kit uses gpui from zed git; coop uses the crates.io `gpui-pre` 0.3.10 package. Both must resolve to one `gpui` crate. The kit's workspace manifest is therefore the first edit.
## Step 0 — dependency wiring (prerequisite, outside coop)
0. Create the working branches before any edit:
- In `/Users/reya/Documents/Git/coop`: `git switch -c feat/gpui-native-kit-migration`.
- In `/Users/reya/Documents/Git/gpui-native-kit`: `git switch -c feat/gpui-pre`.
1. In `/Users/reya/Documents/Git/gpui-native-kit/Cargo.toml`, change `[workspace.dependencies]` to the same gpui family coop already uses:
```toml
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
```
2. In `/Users/reya/Documents/Git/gpui-native-kit/src/lib.rs`, add the missing editor re-export:
```rust
#[cfg(feature = "editor")]
pub use gpui_native_editor as editor;
```
3. Run `cargo check --workspace` in the kit; fix only API drift between zed rev `504a7f2` and `gpui-pre` 0.3.10. Commit and record the revision.
4. In coop's root `Cargo.toml`, add the dependency:
```toml
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "<new-rev>", features = ["core", "data", "layout", "editor"] }
```
For local iteration use `path = "../gpui-native-kit"`. Do not add a `[patch]` for `gpui`: Cargo cannot rename the `gpui-pre` package into the git `gpui` package through `[patch]`; the kit manifest change above is the supported route. The old `gpui-kit` stays in the workspace until Step 10.
Manifests that gain `gpui-native-kit = { workspace = true }`: `crates/assets` (icons only, may use `gpui-native-icons` directly), `crates/ui`, `crates/common`, `crates/chat_ui`, `crates/nip29_ui`, `crates/nip4e`, `crates/nip60_ui`, `crates/workspace`, `desktop`, `web`. `crates/speech` keeps `gpui-kit`. Remove `gpui-kit`, `gpui-base`, `native-theme`, and `native-theme-gpui` from every other manifest in Step 9.
## Step 1 — bootstrap and theme
Files: `desktop/src/main.rs`, `web/src/lib.rs`, `crates/settings/src/lib.rs`, `crates/workspace/src/lib.rs`.
1. Startup:
- Replace `gpui_kit::init(cx)` with `gpui_native_kit::init(cx);` followed by `speech::init_compat(cx);` (the latter keeps the old theme global alive for the speech components). Define it in `crates/speech`:
```rust
/// Initializes the old component kit that the retained speech widgets still use.
pub fn init_compat(cx: &mut gpui::App) {
gpui_kit::init(cx);
}
```
`desktop`/`web` add `speech = { path = "../crates/speech" }`; they do not depend on `gpui-kit` directly. Also call `common::dialogs::init(cx); common::toasts::init(cx);` once at startup (Step 5 defines them).
- Replace `gpui_kit::open_window(opts, cx, workspace::init)` with `cx.open_window(opts, |window, cx| { gpui_native_kit::theme::Theme::sync_from_window(window, cx); gpui_native_kit::theme::Theme::sync_window_rem_size(window, cx); window.observe_window_appearance(|window, cx| { gpui_native_kit::theme::Theme::sync_from_window(window, cx); cx.refresh_windows(); }).detach(); workspace::init(window, cx) })`. `workspace::init` already returns `Entity<Workspace>`.
- Set `WindowOptions.window_background = cx.theme().window_background` after `gpui_native_kit::init` (import `gpui_native_kit::theme::ActiveTheme`).
- Delete `apply_native_theme`, `ThemeWatcher`, `start_theme_watcher`, `native_theme`/`native_theme_gpui` imports, and the two dependencies from `desktop/Cargo.toml`. Delete the same from `web/Cargo.toml` and `web/src/lib.rs`.
- Delete `apply_web_fonts`; the kit paints `.SystemUIFont` and has no UI-font token.
2. Appearance (`desktop`, `web`, `settings`):
- `AppearanceMode::System` → `gpui_native_kit::theme::follow_system(cx)`.
- `Light`/`Dark` → `gpui_native_kit::theme::set_mode_override(Some(gpui_native_kit::theme::ThemeVariant::Light/Dark), cx)`.
- Mirror the mode into the old theme for speech: `gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Light/Dark, None, cx)`.
- In `crates/workspace/src/lib.rs`, replace the `observe_window_appearance` body with `gpui_native_kit::theme::Theme::sync_from_window(window, cx)`; remove `AppSettings::apply_theme_style(cx)` and the old `Theme` import.
3. Remove theme tuning from `settings`:
- Keep `AppearanceMode`; delete `ThemeSettings`, `theme` accessor, `apply_theme_style`, `apply_theme`, and `persisted_theme`. `settings` keeps its `gpui` dependency (it uses `App`/`Context`) but drops `gpui-kit`.
- In `crates/workspace/src/dialogs/settings.rs`, delete `font_size_input`, `radius_input`, `radius_lg_input`, `wire_number_input`, the font-size/radius/focus-ring rows, `RADIUS_RANGE`, `FONT_SIZE_RANGE`, and the `ThemeSettings` reads. Keep appearance, wallet, speech, and other non-theme rows.
- The kit owns radii, focus chrome, and metrics; there is no public override in `gpui-native_theme`. If preserving these settings is required later, add a token-override API to the kit, not app-side mutation.
Checkpoint: `cargo check -p coop -p coop_web -p settings`.
## Step 2 — icons and assets
1. `crates/assets/src/lib.rs`: replace every `gpui_kit::assets::Assets` with `gpui_native_kit::icons::IconAssets` (keep the wasm `OnceLock` caching). Add `gpui-native-kit = { workspace = true }` to `crates/assets/Cargo.toml`; remove `gpui-kit`.
2. Merge the two icon enums into one `ui::IconName` in `crates/ui/src/icon.rs`.
- Keep all current local variants and add every old-kit variant used by coop: `ArrowRight, Info, Plus, Link, X, CircleCheck, Compass, SquarePen, LogOut, LogIn, Wallet, Coins, UserRoundPlus, CircleX, Ban, Reply, Ellipsis, ChevronDown, MessageSquare, TriangleAlert, Check, PanelLeft, PanelLeftOpen, PanelRight, PanelRightOpen, PanelBottom, PanelBottomOpen, Close, WindowMinimize, WindowRestore, WindowMaximize, WindowClose, Book, Inbox`.
- Implement `gpui_native_icons::IconNamed`; new variants use `icons/<kebab-case>.svg` (e.g. `CircleCheck -> "icons/circle-check.svg"`, `X`/`WindowClose -> "icons/x.svg"`, `TriangleAlert -> "icons/triangle-alert.svg"`). Existing local variants keep their current string paths (`Close -> "icons/close.svg"`, `CheckCircle -> "icons/check-circle.svg"`).
- Delete the `gpui_kit::assets::IconNamed` import.
3. Replace every `gpui_kit::assets::IconName::Variant` with `ui::IconName::Variant` in `chat_ui`, `nip29_ui`, `nip60_ui`, `nip4e`, `workspace`.
4. Replace `Icon::new(x)` with `Icon::named(x)`; `.small()`/`.xsmall()` on icons become `.size(cx.theme().sizes.small.icon_size)` / `.size(cx.theme().sizes.small.dense_icon_size)` (or keep `Sizable` where the type implements it; `Icon` does not). `.text_color(color)` becomes `.color(color)`.
5. `CombinedAssets` falls back to `IconAssets`, which serves the one missing kit glyph (`square.svg`); no new vendored files are required.
Checkpoint: `cargo check -p assets -p ui`.
## Step 3 — theme token sweep (all UI files)
Import `gpui_native_kit::common::{h_flex, v_flex, StyledExt, Sizable}` and `gpui_native_kit::theme::{ActiveTheme, ThemeVariant}`. The old `StyledExt` font helpers do not exist: `.font_semibold()` → `.font_weight(FontWeight::SEMIBOLD)`, `.font_medium()` → `.font_weight(FontWeight::MEDIUM)`, `.font_normal()` → `.font_weight(FontWeight::NORMAL)`.
Apply this exact field mapping to every `cx.theme()` access (48 files):
| Old (`gpui-kit` Theme) | New (`gpui_native_theme::Theme`) |
|---|---|
| `.background` | `.colors.background` |
| `.foreground` | `.colors.foreground` |
| `.border` | `.colors.border` |
| `.primary` | `.ramps.primary.base` |
| `.secondary`, `.secondary_foreground` | `.colors.secondary`, `.colors.secondary_foreground` |
| `.secondary_hover`, `.secondary_active` | `.ramps.secondary.hover`, `.ramps.secondary.pressed` |
| `.danger`, `.danger_foreground`, `.danger_active` | `.colors.destructive`, `.colors.destructive_foreground`, `.ramps.destructive.pressed` |
| `.warning`, `.success` | `.colors.warning`, `.colors.success` |
| `.muted`, `.muted_foreground` | `.colors.muted`, `.colors.muted_foreground` |
| `.input`, `.input_background()` | `.colors.input` |
| `.list_hover`, `.list_active` | `.colors.muted_hover`, `.colors.selection` |
| `.sidebar`, `.sidebar_foreground`, `.sidebar_border` | `.colors.surface`, `.colors.foreground`, `.colors.border` |
| `.tab_foreground`, `.tab_active`, `.tab_active_foreground` | `.colors.muted_foreground`, `.colors.surface`, `.colors.foreground` |
| `.tokens.tab_bar`, `.tokens.background`, `.tokens.tab_active`, `.tokens.drop_target` | `.colors.background`, `.colors.background`, `.colors.surface`, `.colors.selection` |
| `.group_box`, `.group_box_foreground` | `.colors.surface`, `.colors.surface_foreground` |
| `.drag_border`, `.popover` | `.colors.accent`, `.colors.surface` |
| `.radius`, `.radius_lg` | `.radius.control`, `.radius.overlay` |
| `.font_family` | delete (use `.SystemUIFont` default) |
| `.motion_tokens().duration_normal` / `.easing_move` | `.motion.overlay` / `gpui::ease_out_quint()` |
| `.is_dark()` | `.variant == ThemeVariant::Dark` |
| `.shadow` bool | delete the conditional; use `.elevations.popover.shadows()` where a shadow is needed |
`Div::rounded(cx.theme().radius)` etc. become `rounded(cx.theme().radius.control)`.
Checkpoint: `cargo check --workspace` (expect old-component type errors only).
## Step 4 — leaf controls
| Old | New |
|---|---|
| `Button::new(id)` | `Button::new(id)` |
| `.label/.icon/.on_click/.disabled/.loading/.tab_stop` | same; `.icon` takes `Icon` (`Icon::named(app_icon)`) |
| `.ghost()` | `.variant(ButtonVariant::Ghost)` |
| `.primary()` / `.secondary()` | `.variant(ButtonVariant::Primary)` / `.variant(ButtonVariant::Secondary)` |
| `.danger()` / `ButtonVariant::Danger` | `.variant(ButtonVariant::Destructive)` |
| `.warning()` | `.variant(ButtonVariant::Destructive)` (no warning variant; noted gap) |
| `.outline()` | `.variant(ButtonVariant::Secondary)` |
| `.text()` | `.variant(ButtonVariant::Ghost)` (padding differs) |
| `.xsmall()` | `.small()` |
| `.size(impl Into<Size>)` | `.with_size(size)`; `.size(rems(2.))` becomes `.w(rems(2.)).h(rems(2.))` (styled) |
| `.selected(bool)` | no equivalent; use `if selected { Ghost } else { Secondary }` or the owner's explicit style |
| `.tooltip("x")` | `.tooltip(Tooltip::text("x"))` |
| `.dropdown_menu(...)`, `.dropdown_caret(...)`, `.dropdown_menu_with_anchor(...)` | use `DropdownMenu` (action menus) or `Menu` with a `trigger` (arbitrary trigger element); see Step 5 |
| `Switch::new(id).checked(v).on_click(\|&v,..\|)` | `.checked(v).on_change(\|v,..\|)` |
| `Separator::horizontal()` | `Separator::horizontal()` (same) |
| `Spinner::new().small()` | `Spinner::new(id).small()` (id required) |
| `Progress::new(id).value(v)` | `Progress::new(id, v)` |
| `ProgressCircle::new(id).value(v)` | `Spinner::new(id).value(v).small()` |
| `Clipboard::new(id).value(v).tooltip(t)` | `ClipboardButton::new(id, v).tooltip(Tooltip::text(t))` |
| `Tooltip::new(t).build(window,cx)` | `Tooltip::text(t)` (closure is the tooltip builder) |
| `Avatar` (`crates/ui`) | `gpui_native_kit::avatar::Avatar::new(id).name(name).picture(url).fully_rounded(true)` |
| local empty/notice views | `EmptyState::new(headline).hint(...).icon(...).action(...)` |
| local chips | `Badge::new(label)` / `StatusDot::new(id).variant(...)` |
Avatar specifics: old `Avatar::new(picture).seed(seed)` becomes `Avatar::new(seed).picture(picture).fully_rounded(true)`. The kit `Avatar` is not a `DropdownMenu`; for menus see Step 5. Delete `crates/ui/src/avatar.rs`, and replace all `ui::Avatar` imports with `gpui_native_kit::avatar::Avatar`.
`Separator`, `Spinner`, `Progress`, `ClipboardButton`, `Tooltip`, `Switch`, `EmptyState`, `Badge`, `StatusDot` come from `gpui_native_kit` root (`gpui_native_core::*` re-export).
Checkpoint: `cargo check -p chat_ui -p nip29_ui -p nip60_ui -p workspace`.
## Step 5 — overlays, menus, dialogs, notifications
### Menus
The kit has no extension traits. `Menu`/`DropdownMenu` are controlled elements: pass `open` and update it from `on_change`. Add one `bool` per menu to its owning view.
- `Button::dropdown_menu(|menu, window, cx| ...)` → `DropdownMenu::new(id).variant(ButtonVariant::Ghost).icon(...).open(self.field).on_open_change(cx.listener(|this, open, _, cx| { this.field = open; cx.notify(); })).items(move |window, cx| vec![...])`.
- `PopupMenu` builder → `Vec<MenuItem>`:
- `.menu(label, Box::new(action))` → `MenuItem::new(label).handler(move |window, cx| window.dispatch_action(action.clone(), cx))`
- `.menu_with_check(label, checked, action)` → `.checked(checked).handler(...)`
- `.label(text)` → `MenuItem::new(text).disabled(true)`
- `.separator()` → `MenuItem::separator()`
- `.item(PopupMenuItem::new(label).on_click(...))` → `MenuItem::new(label).handler(move |window, cx| ...)`
- `.item(PopupMenuItem::element(...))` (logout row) → `MenuItem::new("Log out").destructive(true).handler(...)`
- `.submenu("React", ...)` (one site, `nip29_ui/message.rs`) → flatten the emoji into sibling `MenuItem`s (label the first `"React"` as a disabled header if desired); the kit has no submenu.
- `.context_menu(...)` on a row → wrap the row in `ContextMenu::new(id).target(move |window, cx| row_builder(window, cx)).items(move |_, _| vec![...])`. `ContextMenu` owns its open state; no owner field.
- Avatar menu (`sidebar/header.rs`, `chat_ui` avatar): wrap the Avatar in `Menu::new(("avatar-menu", id)).open(self.avatar_menu == Some(id)).on_change(...).trigger(move |window, cx| avatar_element).items(...)`. Add `avatar_menu: Option<EntityId>` to `Sidebar`/`ChatPanel`/`GroupPanel`. For `chat_ui` message avatars, use a single `avatar_menu: Option<EventId>` on `ChatPanel`.
- `Popover` (`sidebar/header.rs`): new `Popover::new(id).open(self.encryption_open).on_change(...).trigger(...).content(...)`. The old `Anchor::TopRight` and `popover.update(...).dismiss(...)` are gone; the kit anchors below the trigger and closes on outside click/Escape. Add `encryption_open: bool` to `Sidebar`.
### Dialogs
The kit `Dialog` is an element, and its overlay is absolute within its parent; it must be mounted at the workspace root or it is clipped by panels. Add a host in `crates/common`:
```rust
// crates/common/src/dialogs.rs
pub struct Dialogs { views: Vec<AnyView> }
impl Dialogs {
pub fn open(&mut self, view: AnyView, cx: &mut Context<Self>) -> EntityId;
pub fn close(&mut self, id: EntityId, cx: &mut Context<Self>);
pub fn clear(&mut self, cx: &mut Context<Self>);
}
impl Render for Dialogs {
// An absolute, window-covering layer. Every dialog view inside renders its own
// `Dialog` element; the host only stacks them.
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().absolute().inset_0().children(self.views.iter().cloned())
}
}
pub fn init(cx: &mut App); // installs the global Entity<Dialogs>
pub fn open(view: AnyView, cx: &mut App); // pushes
pub fn close(view: EntityId, cx: &mut App); // removes one
pub fn clear(cx: &mut App); // close_all_dialogs
```
`crates/common/src/toasts.rs` wraps `gpui_native_kit::toast::{Toasts, Toast}`:
```rust
pub fn init(cx: &mut App);
pub fn notify(toast: Toast, cx: &mut App);
pub fn clear(cx: &mut App); // remove_notification/clear
```
`Workspace::render` mounts both at the root, outside the dock and sidebar:
```rust
deferred(div().absolute().inset_0()
.child(common::dialogs::global(cx).clone())
.child(ToastsView::new("toasts", common::toasts::global(cx))))
```
(Observe the two entities in `Workspace::new` with `cx.observe(&entity, |_,_,cx| cx.notify())`.)
Each dialog view becomes an overlay view:
- In its `Render`, wrap the existing body in `Dialog::new(("dialog", self.id)).open(true).title(self.title()).on_change(move |open, window, cx| if !open { common::dialogs::close(self_id, cx) }).content(move |window,cx| body).actions(...)`. `Dialog` centers itself over the host. Capture `self_id = cx.entity().entity_id()` before the closures.
- `window.open_dialog(cx, builder)` → create the entity (`cx.new(...)`), then `common::dialogs::open(view.clone().into(), cx)`. The view stores no host state.
- `window.close_dialog(cx)` → `common::dialogs::close(cx.entity().entity_id(), cx)` from inside the view; for child views that close a parent dialog, pass the parent dialog `EntityId` in the constructor or capture the parent's `WeakEntity` and call `close(parent.entity_id(), cx)`.
- `window.close_all_dialogs(cx)` → `common::dialogs::clear(cx)`.
- `AlertDialog` (5 sites) → `Dialog::new(id).alert(true).title(...).content(...).actions(move |window,cx| vec![cancel_button, confirm_button])`; explicit `Button`s replace `DialogButtonProps`. Default cancel/ok labels are written at each site (`Cancel`, `OK`, `Ignore`, `Response`, etc.). `Dialog::on_change` closes the host entry; `alert(true)` blocks Escape/backdrop close.
- The kit `Dialog` has no width builder and no title-bar close button. Preserve the old widths (380/420/460/650) by wrapping the content in `div().w(px(...))`; Escape and scrim click close, and alert dialogs require their action buttons.
- `DialogHeader`/`DialogTitle`/`DialogDescription`/`DialogFooter`/`DialogAction` (import dialog) → plain `v_flex()` with `.text_size(...)`/`.font_weight(SEMIBOLD)` and the kit `Separator`; no component.
Notification → toast mapping:
- `window.push_notification(Notification::error(msg), cx)` → `common::toasts::notify(Toast::new(msg).icon(IconRole::Warning), cx)`.
- `.warning(msg)` → `.icon(IconRole::Warning)`; `.success(msg)` → `.icon(IconRole::Check)`; `NotificationType::Info` → `.icon(IconRole::Info)`.
- `.autohide(false)` → `.duration(std::time::Duration::ZERO)`.
- `.action(|...| Button...)` → `.action(ToastAction::new("Retry", move |cx| { ... }))`.
- `.title(t).message(m)` → `Toast::new(format!("{t} · {m}"))`; the kit toast has no title/body/content slot.
- `.id::<T>()` and `window.remove_notification::<T>()` → `common::toasts::clear(cx)`; the kit store holds one toast at a time.
- `nip4e`'s rich content closure (`crates/nip4e/src/lib.rs:1006-1087`) → a one-line toast containing the code; avatar/rich body are a noted gap.
Checkpoint: `cargo check -p common -p workspace -p chat_ui -p nip29_ui -p nip60_ui -p nip4e`.
## Step 6 — inputs and forms
| Old | New |
|---|---|
| `InputState::new(window, cx)` | `InputState::new(cx)` (or `new_with_text(text, cx)` for `.default_value`) |
| `InputState::new(...).placeholder(p)` | `Input::new(id, state).placeholder(p)` |
| `.masked(true)` | `state.update(cx, \|s,cx\| s.set_password(true, cx))`; add an `IconRole::Eye`/`EyeOff` ghost `Button` suffix toggling `set_revealed` for the three password fields |
| `.clean_on_escape()` | delete; the kit blurs on Escape |
| `.clearable(true)` / `.cleanable(true)` | no equivalent; for the two search inputs add a `Button` suffix with `IconRole::Close` that calls `set_value("", window, cx)`; drop the flag elsewhere |
| `.appearance(false)` | delete; the kit field always paints its chrome |
| `TextareaState::new` + `.auto_grow(min,max)` | `InputState::new(cx)` + `set_multiline(true, cx)`; render `Input::new(id, state).rows(1)`; no auto-grow (noted gap) |
| `Textarea::new(&state)` | `Input::new(id, state.clone())` (multiline state) |
| `NumberInput::new(&state)` | `Input::new(id, state)`; parse/clamp in the `TextChanged` handler; no stepper buttons |
| `InputEvent::PressEnter { .. }` | `InputStateEvent::Submitted` |
| `InputEvent::Change` | `InputStateEvent::TextChanged` |
| `state.read(cx).insert(text, window, cx)` | `let mut value = state.read(cx).value().to_owned(); value.push_str(text); state.update(cx, \|s,cx\| s.set_value(value, window, cx));` |
| `state.update(cx, \|s,cx\| s.focus(window,cx))` | `window.focus(&state.read(cx).focus_handle(), cx)` |
| `.set_loading(bool, ...)` | delete (no loading state); show a `Spinner` prefix/suffix where the old spinner mattered |
| `Input::new(&state)` | `Input::new(id, state.clone())` (id required) |
Forms:
- `settings_row(label, desc, control, cx)` → `Field::new(id, label).description(desc).variant(FieldVariant::Row).child(control)`.
- `settings_block(...)` → `Field::new(id, label).description(desc).child(control)` (default `Block`).
- Call sites that need the old fixed control column: wrap the control in `h_flex().w_40().justify_end().child(control)`; otherwise let the kit field lay it out.
- Delete `crates/ui/src/form.rs`.
- `GroupBox` (`new_group.rs` two sites) → inline `v_flex().p_3().gap_2().rounded(cx.theme().radius.overlay).border_1().border_color(cx.theme().colors.border).bg(cx.theme().colors.surface)` with a title row; noted gap (no kit GroupBox).
- `Kbd` (`settings.rs` dictation shortcut) → inline shortcut label: `if cfg!(target_os="macos") { "⌘D" } else { "Ctrl+D" }` in a muted `div().px_1().rounded(cx.theme().radius.sm).bg(cx.theme().colors.muted)`; noted gap.
- `gpui_base::Select`/`Popup` in `new_group.rs`: replace with kit `Select<T>` (`Select::new(id).options(...).selected(value).on_change(...)`) and `Popover` for the content.
- `form::{field, v_form}` in `dialogs/import.rs` → kit `Field`.
Checkpoint: `cargo check -p workspace -p chat_ui -p nip29_ui -p nip60_ui`.
## Step 7 — data views and text
1. `ScrollableElement::overflow_y_scrollbar()` (4 sites) → wrap the content in `ScrollArea::new(id).axes(ScrollAxes::Vertical)`.
2. `Scrollbar::vertical(&list_state)` → `Scrollbar::vertical(("chat-scroll", id), list_state.clone())`; the kit trait is implemented for `ListState`/`UniformListScrollHandle`.
3. `gpui_base::{VirtualListScrollHandle, v_virtual_list}` (`panels/browse.rs`) → gpui `ListState::new(rows, ListAlignment::Top, px(100.))` + `list(state, move |this, ix, window, cx| ...)`; remove the per-item size vector and `track_scroll`. `scroll_to_item(0, Top)` → `ListState::scroll_to(ListOffset { item_ix: 0, offset_in_item: px(0.) })`. Noted gap: the kit has no generic virtual list (its checklist lists it as future work).
4. `TabBar` in `dialogs/relays.rs` → `Tabs::new(id).variant(TabsVariant::Pill).selected(self.selected).on_change(...).tabs(move |_,_| vec![Tab::new(key, label)...])`.
5. `Presence`/`Transition` progress pill (`sidebar/mod.rs`) → render the pill unconditionally; drop the enter/exit animation. Noted gap: no transition helper.
6. `ui::text`:
- Keep `InlineReplacement` and `rendered_text`.
- Store `Entity<MarkdownViewState>` per message: replace `BTreeMap<EventId, RenderedText>` with `BTreeMap<EventId, Entity<MarkdownViewState>>`. On first render of a message, create `cx.new(|_| MarkdownViewState::new(("message", message.id), source))`; render the entity. `MarkdownViewState` is immutable, so creation happens once per message.
- Preprocess `nostr:nevent1...` destinations into `https://njump.me/<bech32>` inside `rendered_text`/`InlineReplacement`; the kit's markdown view calls `cx.open_url` itself and exposes no link callback. Delete `is_web_url`/`nevent_bech32` if unused.
- NIP-30 custom emoji images become their alt text (kit markdown renders images as alt text). Noted gap.
- `RevealText` stays as-is (plain text, no kit dependency).
7. `WelcomeMessage` → `EmptyState::new(title).hint(message).icon(icon)` where the caller can supply a headline; otherwise keep the product view. Delete only if the mapping is lossless.
Checkpoint: `cargo check -p chat_ui -p nip29_ui -p workspace`.
## Step 8 — dock, shell, panels
Delete `crates/workspace/src/dock/{mod.rs, skin.rs, tab_panel.rs, invalid_panel.rs, window_controls.rs}` and replace with `crates/workspace/src/dock.rs`.
1. `Workspace` owns `dock: Entity<gpui_native_kit::layout::dock::DockState>` and `sidebar: Entity<Sidebar>`.
2. Root layout (manual, because `Shell::left_sidebar` only accepts the data `Sidebar`, not an arbitrary view):
```rust
div().flex().flex_row().size_full()
.when(self.sidebar_visible, |row| row
.child(div().w(self.sidebar_width).h_full().child(self.sidebar.clone()))
.child(ResizeHandle::new("sidebar-resize").on_resize(...)))
.child(div().flex_1().min_w_0().h_full().child(
Dock::new("coop-dock", self.dock.clone())
.window_controls(!cfg!(target_os = "macos"))
.traffic_light_pad(cfg!(target_os = "macos"))))
```
Add `sidebar_visible: bool` (initial `true`), `sidebar_width: Pixels` (default 240; clamp 160..480) to `Workspace`. Add `workspace::ToggleSidebar` action and a `Button` in the sidebar header with `IconRole::PanelLeft`; handle it in `Workspace`.
Initial content: create the dock state, then in `cx.defer_in` open `greeter::init(dock.downgrade(), window, cx)` into the dock exactly as the old center region did. Do not register every panel at startup; panels register on first open through `open_panel`.
3. Register/open panels with a helper:
```rust
pub fn open_panel<P: Render + 'static>(
dock: &WeakEntity<DockState>,
title: impl Into<SharedString>,
view: Entity<P>,
split: Option<SplitDirection>,
window: &mut Window,
cx: &mut App,
) {
let id = SharedString::from(view.entity_id().to_string());
dock.update(cx, |dock, cx| {
let view = view.clone();
dock.register_panel(PanelDescriptor::new(id.clone(), title, move |_, _| view.clone().into()));
dock.open_panel(&id, window, cx);
if let Some(direction) = split { dock.split_panel(&id, direction, cx); }
}).ok();
}
```
Call sites: `sidebar/rows.rs::open_room`, `sidebar/inbox.rs`, `dialogs/new_chat.rs`, `dialogs/new_group.rs`, `panels/browse.rs`, `workspace/src/lib.rs`. Replace `WeakEntity<DockArea>` with `WeakEntity<DockState>` in those signatures.
4. Remove `Panel`/`BasePanel`/`PanelEvent` impls from `ChatPanel`, `GroupPanel`, `WalletPanel`, `BackupPanel`, `BrowsePanel`, `ContactListPanel`, `GreeterPanel`, `InboxPanel`, `Sidebar`. Remove `EventEmitter<PanelEvent>`, `panel_name`, `title`, `toolbar_buttons`, `set_active`, `on_removed`, `closable`, `zoomable`. Panel titles/icons move to `PanelDescriptor` at registration (static; dynamic rename is a noted gap). Delete the now-unused `nip29_ui::{ShowGroupMeta, HideGroupMeta}` actions and their handlers in `crates/workspace/src/lib.rs`.
5. Replace `TAB_BAR_HEIGHT` with `cx.theme().titlebar_height`; use it for the sidebar header height and the window traffic-light position (`point(px(9.), px(theme.titlebar_height / 2. - 14. / 2.))`). Use `gpui_native_kit::layout::window_controls::{titlebar_strip, TRAFFIC_LIGHT_PAD}` in the sidebar header; delete `ui::title_bar_drag_handlers` and `crates/ui/src/window_drag.rs`.
6. Remove `ClosePanel`/`ToggleZoom` dispatch and handlers. The new dock's own close/zoom controls own those actions. In `screening.rs`, the `Ignore` button now only closes the dialog (no panel close).
7. `Sidebar` is no longer a dock panel; its `BasePanel::set_active` group-view dispatch is removed. Row clicks already call `Sidebar::show_group`; keep that path. Behavior change: activating an already-open group panel no longer opens the sidebar's group view, and closing a group panel no longer hides it; the back button still hides it.
Checkpoint: `cargo check -p workspace -p chat_ui -p nip29_ui -p nip60_ui`.
## Step 9 — remove the old design system
1. `crates/ui`: delete `avatar.rs`, `form.rs`, `window_drag.rs`. Keep `icon.rs`, `message.rs`, `text.rs`. Update `lib.rs`. Remove `gpui-base`, `gpui-kit`, and dead deps (`uuid`, `web-time`, `log`); add `gpui-native-kit`.
2. `crates/speech` is the only crate that keeps `gpui-kit`. Make it re-export the speech UI types so `chat_ui`/`nip29_ui` do not import `gpui_kit`:
```rust
pub use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
```
Change `chat_ui`/`nip29_ui` imports to `speech::{...}`. `speech::init_compat` calls `gpui_kit::init(cx)`; keep the old-theme mode mirror from Step 1. Remove `gpui-kit` from `chat_ui`, `nip29_ui`, `nip60_ui`, `nip4e`, `settings`, `assets`, `workspace`, `ui`, `desktop`, `web`.
3. Workspace `Cargo.toml`: remove `gpui-base`, `native-theme`, `native-theme-gpui`; keep `gpui-kit` (speech only). Remove `gpui-base` from all crate manifests.
4. Delete `crates/workspace/src/dock/{skin,tab_panel,invalid_panel,window_controls}.rs`; delete `CoopDockSkin`, `CoopTabGroupSkin`, `DragPanelPreview`, `DockResizeTracker`, `ResizePanel`, `ControlIcon`.
5. No crate other than `crates/speech` imports `gpui_kit`. `crates/speech` keeps the old speech types, `ActiveTheme`, `Subscription`, and `init_compat`.
Checkpoint: `cargo check --workspace`; `rg "gpui_kit::|gpui_base::" crates desktop web` must report only `crates/speech` (`init_compat` and the speech re-exports).
## Kit gaps (explicitly noted, no new components invented)
| Missing in `gpui-native-kit` | Handling in this plan |
|---|---|
| Window-level notifications with severity, title, rich content, ids, system delivery | Kit `Toast` only: one toast at a time, one line, optional icon/action/duration. Hosted in `common`; rich `nip4e` body reduced to text. |
| Speech (`SpeechState`, `SpeechButton`, `SpeechWaveform`, `SpeechRecognizer`, `AudioInput`) | Kept on old `gpui-kit` in `crates/speech`; the only old-kit dependency left. |
| Markdown inline images (NIP-30 emoji) and a link-click callback | Kit `MarkdownViewState` renders images as alt text and opens links through `cx.open_url`; `rendered_text` preprocesses `nostr:nevent` to a njump URL. |
| Menu submenus, arbitrary-element rows, non-action label rows, anchor override | Flatten the single React submenu; use `MenuItem::new(label).disabled(true)` for labels; `MenuItem::disabled(true)`/`destructive(true)` for custom rows; anchor below trigger. |
| Button text variant, child content, attached dropdown, selected state, warning variant, xsmall | Map to Ghost/Secondary/Destructive variants and `DropdownMenu`/`Menu`; avatar-as-trigger uses `Menu::trigger`. |
| Input clear button, mask toggle, loading state, auto-grow, number stepper, borderless appearance, escape-clear, `insert`/`focus` | Compose suffix buttons where needed; otherwise drop the affordance and note it. Fixed rows replace auto-grow. |
| `GroupBox`, `Kbd`, `NumberInput`, `ProgressCircle`, `Presence`/`Transition`, `Lerp`/easing helpers | Inline box/label compositions; `ProgressCircle` → determinate `Spinner`; transitions dropped. |
| Generic virtual list | Use gpui `list`/`uniform_list` with `ListState`; no kit component. |
| Window-level dialog/notification managers; dialog width/close button | `common::dialogs` host + explicit action buttons; width on content; no title-bar X. |
| Old theme tokens (`danger`, `list_*`, `sidebar*`, `tab_*`, `tokens.*`, `group_box`, `font_family`) and token overrides | Mapped in Step 3; font-size/radius/focus-ring settings removed (kit owns tokens). |
| Dock placements (left/right/bottom), panel lifecycle/toolbar/actions, dynamic tab titles | One pane tree; left panels open as tabs or split left; lifecycle hooks removed; titles static. |
| Generic rows with arbitrary prefix/suffix | `Nav` stays as product-specific code, rebased on kit tokens; kit `List`/`Sidebar` cannot express it. |
| Facade `editor` re-export | Added in Step 0; otherwise depend on `gpui-native-editor` directly. |
| `IconRole` coverage of app glyphs | App `ui::IconName` implements `IconNamed`; kit `IconAssets` supplies missing role glyphs. |
## Critical files
- `desktop/src/main.rs` and `web/src/lib.rs` — startup, theme, window.
- `crates/workspace/src/lib.rs` — dock root, sidebar layout, dialogs/toasts host, actions.
- `crates/workspace/src/dock.rs` (new) — `open_panel` helper; replaces the old dock module.
- `crates/ui/src/message.rs` — largest product-view rebase (buttons, menus, theme tokens).
- `crates/common/src/dialogs.rs`, `crates/common/src/toasts.rs` (new) — kit lacks window-level managers.
## Verification
1. `cargo check --workspace` in `/Users/reya/Documents/Git/coop`.
2. `cargo build -p coop` (desktop).
3. Run `cargo run -p coop -- --sec <throwaway nsec>` and check:
- Window opens with the kit titlebar; no old dock tab bar or double titlebar.
- Sidebar renders avatars, icons, hover/selected rows; sidebar resize and toggle work.
- User avatar menu opens and shows Profile/Relays/Log out; encryption popover opens and closes.
- `Cmd/Ctrl+,` Preferences opens; appearance Light/Dark/System changes the whole UI immediately; the speech shortcut row shows the platform shortcut.
- Open a chat from the sidebar; send `hello`; the message renders; right-click the message opens the context menu; the emoji menu opens; a toast appears for an empty send.
- A `nevent1...` mention opens `njump.me` on click.
- Drag a dock tab to an edge; the pane splits; close/close-others work.
- Open Browse; scrolling uses the list; open New Group and New Chat dialogs; cancel closes them.
4. `cargo check -p coop_web --target wasm32-unknown-unknown` (requires the wasm target and `gpui_web`); verify the web bundle opens a window and settings appearance works.
5. `rg -n "gpui_kit::|gpui_base::|native_theme" crates desktop web` — only `crates/speech`, the compat `gpui_kit::init` lines, and the speech imports should remain.
## Assumptions and contingencies
- The kit's `gpui` dependency moves to `gpui-pre` 0.3.10 as a committed prerequisite. If the kit does not compile against 0.3.10, bump coop's `gpui`, `gpui_platform`, `gpui_web`, and `gpui-kit` to the newest 0.3.x that compiles both, together; never keep two `gpui` crates.
- The kit repo is modifiable. If it must stay untouched, vendor it under `vendor/gpui-native-kit` and apply the same two manifest edits there.
- Theme tuning (font size, focus ring, radii) is removed because the new theme has no public override. If it must be preserved, add a token-override API to the kit; do not mutate the global.
- Speech is retained through the old kit. If old theme/keybinding initialization conflicts with the new kit, replace `gpui_kit::init(cx)` with the minimal old-component theme init (`gpui_kit::component::Theme::change` after new theme init) and verify `SpeechButton`/`SpeechWaveform` render.
- Dock placements are not preserved 1:1: the new dock has one pane tree. Left-placed panels open as tabs, optionally split left via `split_panel`; bottom/right placements are not used by coop. Panel tab titles are static after registration.
-387
View File
@@ -1,387 +0,0 @@
# NIP-60: cashu wallet — implementation plan
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
NIP-60 stores a user's cashu wallet state on relays: the wallet config
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending
history (kind `7376`). All content is NIP-44 encrypted to self. This plan
implements it inside coop using the CDK for all cashu/mint operations and
the app's existing nostr client/signer for events.
## Analysis
| Kind | Replaceable | Content (NIP-44 to self) | Purpose |
| ------ | ----------- | ----------------------------------------------- | -------------------------------- |
| 17375 | yes `[[privkey, hex], [mint, url], ...]` | wallet config: P2PK key + mints |
| 7375 | no `{mint, unit, proofs[], del[]}` | unspent proofs (per mint) |
| 7376 | no `[[direction], [amount], [unit], [e, ...]]` | spend/receive history (optional) |
| 7374 | no `quote-id` + `mint`/`expiration` tags | pending mint quote (optional) |
| 10019 | yes empty, `relay` tags | wallet relay list |
Key mechanics:
- Wallet state transitions are rollover: spending part of a token event
publishes a new `7375` with the unspent proofs + `del: [old-event-id]`,
then NIP-09 deletes the old event (the delete MUST carry `["k", "7375"]`).
- The `privkey` in `17375` is a wallet-only key (never the nostr identity
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
nutzaps.
- NIP-60 itself does not move money to/from others. Receiving ecash from
peers is NIP-61 (nutzaps): public `kind:9321` events published by the
sender, p-tagging the recipient's nostr identity pubkey, carrying
P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
is `receive_proofs` with the wallet signing key + a `7376` whose
unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
- NIP-61 discovery runs through a `kind:10019` informational event
(relays, mints, wallet pubkey) published alongside the wallet event.
- History (7376) and quote events (7374) are optional; the spec recommends
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
its local DB).
## How it fits coop
Follows the existing crate patterns exactly; no new architecture.
- One protocol crate: `crates/nip60`, mirroring `nip4e`/`nip29` — a
registry entity set as GPUI global by `init(cx)`, `EventEmitter` for
UI updates, `SmallVec<Task>` + `SmallVec<Subscription>` fields, flume
signal channel for background results, reset/reload on
`NostrRegistry::signer_changed` (see `crates/nip29/src/lib.rs`).
- Desktop-only: crate-level `#![cfg(not(target_arch = "wasm32"))]` like
`crates/auto_update/src/lib.rs`. Wallet UI is only wired on desktop.
- Tokio bridge: all cdk calls run through `gpui_tokio::Tokio::spawn(cx, ...)`
(runtime is already initialized by `state::init`). The `cdk-nostr`
inbox listener also runs on that runtime.
- Signer-agnostic encryption: the signer may be a bunker/extension, so we
never use `nostr::nips::nip60` prepare helpers (they need an owned
`SecretKey`).
We build payloads as plain JSON, encrypt with
`signer.nip44_encrypt(self_pubkey, json)` and sign with
`EventBuilder::new(kind, content).finalize_async(signer)` — the same
pattern as `crates/nip4e/src/storage.rs`. Parsing is decrypt-then-parse:
`TokenEvent` derives `Deserialize`; `17375` content is a trivial
`Vec<Vec<String>>` parse.
- Existing facts we build on:
- rust-nostr (git, already a dependency) ships `nostr::nip60` with the
event data types (`WalletEvent`, `TokenEvent`, `SpendingHistory`,
`QuoteEvent`, `CashuProof`) and kinds
(`Kind::CashuWallet` = 17375, `CashuWalletUnspentProof` = 7375,
`CashuWalletSpendingHistory` = 7376, `CashuWalletQuote` = 7374).
It is behind the `nip60` feature, which coop does not enable yet.
- `state::NostrRegistry` gives the `Client` (LMDB cache + bootstrap
relays + gossip) and the `UniversalSigner`.
- Local file storage precedent: `NostrLmdb::open(config_dir().join("nostr"))`
in `crates/state/src/lib.rs`; cdk's sqlite store goes to
`config_dir().join("cashu")`.
## Dependencies
Workspace `Cargo.toml` (final state after the phase 0 spike):
```toml
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
# Cashu (NIP-60). From git: the published 0.18.x line targets nostr 0.44 and
# cannot unify with the git rust-nostr used here, while main targets 0.45.x
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
```
Spike findings (all resolved and verified with `cargo check`):
- The cdk family hard-pins `cdk-common` with `=` per release, so all crates
must come from one release train. crates.io 0.18.3 pins nostr 0.44 and
cannot coexist with coop's git nostr 0.45.x; the git main line pins
nostr ^0.45.3, which the `[patch.crates-io]` block redirects onto the
same git rust-nostr checkout. The lockfile ends with exactly one `nostr`
and one `nostr-sdk`, proven by compile-time type-identity checks between
`nostr` and `cdk_nostr::nostr`.
- Storage is `cdk-redb` (`WalletRedbDatabase::new(&Path)`, pure Rust), not
`cdk-sqlite`: published cdk-sqlite pins rusqlite ^0.31, whose
`libsqlite3-sys` clashes at the `links` level with the `nostr-sqlite`
wasm dependency of the `state` crate.
- rust-nostr 0.45.x has no root re-exports: the module is
`nostr::nips::nip60`, and cdk-nostr re-exports the SDK as
`cdk_nostr::nostr` / `cdk_nostr::nostr_sdk`.
- The `nip60` crate exists (`crates/nip60`) with a compile-only spike in
`lib.rs` and a no-op `init(cx)` wired into `desktop/src/main.rs`; both
are replaced in phase 1.
## Design
### Key material
- On first `setup(mint_url)` (explicit user action, no auto-creation):
generate `SecretKey::generate()` as the wallet key, publish `17375`
containing it + the mint list (encrypted to self, signed by the user).
- The cdk seed is derived deterministically from the wallet key
(`HKDF-SHA256(wallet_privkey, info = "coop cashu seed")` -> `[u8; 64]`).
No extra local storage, and a fresh device recovers the full wallet from
the relay state alone. Tradeoff: the derivation is coop-specific (NIP-60
does not standardize seed derivation), so it does not restore into other
apps — acceptable, see open questions.
- Proof-level secrets/counters live in cdk's sqlite store; NUT-13 restore
from mints works because the seed is deterministic.
### Local state
- cdk redb store at `config_dir().join("cashu")` (one `WalletDatabase`
shared by all mint wallets).
- `WalletRegistry` holds: parsed `17375` (`WalletEvent` + event id), the
active `7375` events per mint (event id + `TokenEvent`), cdk `Wallet`
per mint (`CurrencyUnit::Sat` only in v1), and the subscription handle.
### Relay sync
- One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus
kind `5` with `k: 7375`, on the existing client (bootstrap relays).
Notifications flow through the nip29-style flume channel.
- Published relays: the `10019` nutzap info event advertises the user's
NIP-65 relay list (queried from the local database), never the bootstrap
relays; subscriptions and publishes ride the SDK's NIP-65 handling.
Reading `10019` from other clients is skipped in v1 (coop always uses
its own relay set).
### Operations
Each op runs on tokio and ends with the same `sync` tail:
read unspent proofs from cdk -> publish one `7375` per changed mint
(with `del` set to the previous event ids) -> NIP-09 delete old events with
a `k:7375` tag -> publish `7376` history -> update registry state and emit.
- `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk
`Wallet`, open subscription.
- `create_mint_quote(amount)`: a job in the queue — `wallet.mint_quote` ->
invoice recorded in `mint_quotes()` for the UI; poll /
`claim_mint_quote(id)`, then `wallet.mint(quote_id)` -> sync.
On load, `mint_unissued_quotes` claims quotes paid elsewhere.
- `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over).
- `send_token(amount)`: `wallet.send` -> return token string for the user
to share -> sync. (No in-chat token delivery in v1.)
- `receive_token(token)`: `wallet.receive` -> sync.
- Redeem nutzap (NIP-61): verify the proofs are P2PK-locked to
the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions {
p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted
`e <nutzap-id> "" redeemed` tag -> sync.
- Remote `7375` from another device: import its unspent proofs into the
cdk store as the new local truth (cdk proof insertion + state check via
`check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09.
- On every reconcile: `recover_incomplete_sagas` and
`mint_unissued_quotes` (required by cdk to clear interrupted operations),
then converge the unspent set from `7375` minus deletes.
### Crate layout (3 files, kept small on purpose)
- `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`),
subscription/notification handling, `reset` on signer change.
- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer,
`7375`/deletion/`7376` event builders, the `History` payload.
- `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes,
history), NIP-61 redemption.
Wiring: `nip60::init(cx)` in `desktop/src/main.rs` after `nip4e::init(cx)`;
`nip60 = { path = "../crates/nip60" }` added to `desktop/Cargo.toml` (and
the UI crate when Phase 3 lands).
## Phases
### Phase 0 — dependency spike (done)
Outcome recorded in the Dependencies section: cdk family on git main,
`[patch.crates-io]` unification, cdk-redb storage, `nip60` crate skeleton
wired into desktop. `cargo check -p nip60` and the full desktop check pass.
The remaining spike code in `crates/nip60/src/lib.rs` is deleted in
phase 1.
### Phase 1 — bootstrap + load + balance (done)
Implemented in `crates/nip60` (`lib.rs`, `keys.rs`):
- `WalletRegistry` follows the nip29/nip4e pattern: global entity, signer
reset, flume notification listener, task + subscription SmallVecs.
- `keys.rs`: wallet key generation, HKDF seed derivation, `17375`
encrypt/parse through the signer (works with bunkers), unit tests.
- Load: subscribe + query cache for 17375/7375/7376 and kind 5 `k:7375`,
decrypt via signer, prune deleted (`k:7375` deletes) and superseded
(`del` chains) token events, expose `has_wallet()`/`balance()`.
- cdk: redb store per wallet pubkey under `config_dir()/cashu`, one `Wallet`
per mint (`sat`), unspent proofs imported/diffed against the store on
every sync, `recover_incomplete_sagas` + `mint_unissued_quotes` fire on
first store open.
- `setup(mint)` creates the wallet (first mint) or adds a mint, publishing
`17375` and saving it to the local cache.
Deferred to later phases: token rollover publishing (phase 2), remote
proof validation at the mint (phase 2 reconciliation), replacing proofs
reserved by local in-flight operations (phase 2 guard).
### Phase 2 — moving money (done)
Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
- All store mutations go through a serialized job queue
(`schedule_job`): one job at a time on the background executor,
snapshots taken at schedule time, one queued job slot. This keeps ops
and relay-driven imports from interleaving on the store, which a loose
sync tail would not guarantee.
- Jobs: `Reconcile` (claim paid quotes via `mint_unissued_quotes`, then
converge each mint — publish the store's unspent set when it diverges
from the relay view, and import relay-only proofs into the store),
`ClaimQuote`, `Melt`, `SendToken`, `Receive`.
- The sync tail (`sync_token_events`) publishes a fresh `7375` with the
store's unspent proofs, NIP-09-deletes the previous token events with
the required `k: 7375` tag, and publishes a `7376` history event with
`created`/`destroyed` markers.
- Registry API: `create_mint_quote(amount)` (invoice stored in
`mint_quotes()`, UI polls `claim_mint_quote(id)`), `melt(invoice)`,
`send_token(amount)` (token delivered via `WalletEvent::TokenCreated`),
`receive_token(token)` (mint resolved from the token).
- Claim-on-load moved from a fire-and-forget task into `Reconcile`:
the fire-and-forget variant raced the import pass, which could wipe
freshly claimed proofs from the store before they were published.
- History amounts: mint/receive record what was received; melt records
invoice amount + fee (`FinalizedMelt::total_amount`); send records the
sent amount.
Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
sending tokens in-chat (v1 copies the token string).
### Phase 3 — NIP-61 redemption (done)
Spec note: NIP-61 changed since this plan was first written — nutzaps are
now public `kind:9321` events p-tagging the recipient's nostr identity
pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
tool; the dependency was dropped and reception rides the app's existing
nostr client.
Implemented:
- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
relays, wallet mints, wallet pubkey) so other clients can nutzap us.
- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
the same notification listener; the cache load scans stored `9321`s
too, so unredeemed nutzaps survive restarts.
- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
open wallets, verifies every proof is P2PK-locked to the wallet pubkey
(NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
the serialized job queue.
- The job redeems via `wallet.receive_proofs` with the wallet key in
`p2pk_signing_keys`, then syncs with a `7376` history event whose
unencrypted tags mark the nutzap `redeemed` and tag the sender.
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
in flight are skipped; failed redemptions are retried on relay echo.
### Audit (post phase 3) — done
Backend review before Phase 4: keep it simple, no premature edge-case
handling, struct/impl blocks over standalone functions. Applied:
- Code shape: `keys::WalletData` carries generate/derive_seed/decrypt/
to_event as an impl block; `ops::Job::run` and the `JobCtx` helpers
(`wallet`, `previous_ids`, `sync_token_events`, `reconcile`) are impl
methods; `LoadedState::load`, `WalletRegistry::open_store` and the
filters are associated functions; nutzap tag parsing moved into a
`Nutzap` struct (dropped the speculative `mint` tag fallback for the
current `u` tag). `History` moved to `keys.rs` so `build_history_event`
takes one struct instead of eight arguments.
- Spec gap: the sync tail never set `TokenEvent.del` — new `7375`s now
carry the previous event ids, so superseded tokens stay pruned even if
a NIP-09 delete is dropped by a relay.
- Bug: `start_job` errored "Wallet is not ready" for the auto `Reconcile`
scheduled before the redb store finished opening. Jobs scheduled while
the store is opening now queue and run once `ensure_wallets` completes;
`apply_loaded` no longer schedules a redundant `Reconcile`.
- Bug: a late-landing stale wallet decrypt could overwrite a newer
`17375`; `absorb_wallet` now re-checks `created_at` when applying.
- Consistency: an unreadable live token event logs a warning instead of
surfacing an error event (same as the load path); nutzaps arriving with
no wallet set up are dropped (a fresh setup would generate a different
key, so they were unredeemable anyway).
- Invariant: quote creation moved into the serialized job queue
(`Job::CreateQuote` / `Outcome::QuoteCreated`) — `mint_quote` writes to
the cdk store, so it must not interleave with running jobs. This also
collapsed the mint-resolution boilerplate shared by quote/melt/send
into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
it was missing), before `mint_unissued_quotes`. It is best-effort: the
cdk-redb fresh-store init does not create the `wallet_sagas` table
(upstream bug), so the first recovery attempts fail with "table does
not exist" until a saga is written — logged, never fatal.
Not changed, deliberately: per-outcome `Outcome` variants, the bounded
notification dedup set, the single queued Reconcile collapse, and the
remaining small conversion helpers in `ops.rs` (`cashu_proof`,
`proof_info`, `nostr_mint`) — they are simple, and further abstraction
would be over-engineering.
### Phase 4 — UI (done)
Implemented in a new `crates/nip60_ui` (crate-level wasm gate, so it
compiles to nothing on web; consumers list it under
`[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`):
- Backend additions: `WalletRegistry::history()` (`HistoryEntry` list,
newest first, from live + cached `7376`s), and `send_nutzap(recipient,
amount, target, comment)` — `Job::SendNutzap` fetches the recipient's
`10019` from the local database (falling back to `stream_events`),
picks the first shared mint, P2PK-locks a `prepare_send` to their
wallet pubkey, publishes a public `9321` (`p`/`u`/`unit`/`proof` tags,
optional `e`), then runs the usual sync tail with an out-history.
- `wallet.rs` — `WalletPanel` in the left dock: balance, setup/add-mint,
send-token (token shown for copy), receive (invoice per quote with
copy + claim, paste-token redeem), history list. Subscribes to
`WalletEvent`; errors surface as notifications here and, via a
workspace-level subscription, even when the panel is closed.
- `lib.rs` — `open_nutzap` dialog (amount prompt) reused by chat.
- Sidebar: "Wallet" row under Browse (gated off on wasm), dispatching
`Command::ShowWallet` -> dock the panel.
- `chat_ui`: a coins button in the composer opens the nutzap dialog for
the room's peer; group rooms are declined with a warning (v1 is
direct-chat only).
- `nip29_ui`: "Send Nutzap" context-menu item on messages, nutzapping
the author with an `e` tag on the message.
Not rendered yet: nutzaps/tokens do not appear in the chat timeline; the
wallet panel history is the only transaction view.
## Tests
- `keys.rs` round-trips: encrypt/decrypt `17375` payloads against the
rust-nostr reference types (mirrors `nostr::nip60` unit tests).
- Rollover logic: given prior token events + a new unspent set, assert the
produced `7375`/delete/`7376` trio (pure functions, no network).
- Integration (optional, behind `#[ignore]`): testnut mint mint -> melt
cycle against cdk's memory store.
## Open questions / tradeoffs
- Seed derivation is coop-specific; cross-app portability of proof
restores is unsolved by NIP-60 generally. Revisit if a derivation
convention lands in the NIP or cdk.
- v1 is single-unit (`sat`) and effectively single-mint in the UI, even
though the event format and registry support many mints.
- Concurrent spends from two devices double-spend; recovery is the spec's
Appendix-1 validation (check proofs at the mint, roll over survivors) —
implemented as reconciliation on sync, not real-time locking.
- Import replaces the unspent proof set wholesale; the serialized job
queue closes the in-flight-operation window, and mint-side double-spend
rejection remains the backstop.
- Tracking cdk via git main is a moving target; the lockfile pins a rev
and must be bumped deliberately (same policy as the rust-nostr git dep).
Watch for the next cdk release line built against nostr 0.45.x — moving
back to crates.io then removes the moving-target concern.
- Web target gets no wallet in v1: cdk store backends (redb, sqlite) need
a real filesystem and do not build on wasm32; the crate compiles to
nothing there (auto_update pattern). A wasm store would need a cdk
`WalletDatabase` over IndexedDB — explicitly out of scope.
- `7374` quote events are skipped (local cdk state per the spec); pending
quotes are not visible to other devices until claimed.
+1
View File
@@ -3,6 +3,7 @@ channel = "1.98"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
+1 -3
View File
@@ -14,7 +14,6 @@ ui = { path = "../crates/ui" }
state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" }
chat_ui = { path = "../crates/chat_ui" }
nip29 = { path = "../crates/nip29" }
settings = { path = "../crates/settings" }
person = { path = "../crates/person" }
@@ -22,8 +21,7 @@ person = { path = "../crates/person" }
gpui.workspace = true
gpui_platform.workspace = true
gpui_web.workspace = true
gpui-native-kit.workspace = true
speech = { path = "../crates/speech" }
gpui-kit.workspace = true
log.workspace = true
instant = { workspace = true, features = ["wasm-bindgen"] }
+5 -5
View File
@@ -21,12 +21,12 @@ if [[ "$1" == "--release" ]]; then
echo -e "${YELLOW}Building in release mode${NC}"
fi
NIGHTLY="nightly-2026-10-31"
CARGO="cargo +$NIGHTLY"
# 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"
rustup target add wasm32-unknown-unknown --toolchain nightly
if [[ "$(uname)" == "Darwin" ]]; then
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
@@ -35,7 +35,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
exit 1
fi
rustup component add llvm-tools-preview --toolchain "$NIGHTLY"
rustup component add llvm-tools-preview --toolchain nightly
RUSTUP_HOME=$(rustup show home)
if [[ "$(uname -m)" == "arm64" ]]; then
@@ -43,7 +43,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
else
HOST_ARCH="x86_64-apple-darwin"
fi
LLVM_BIN="$RUSTUP_HOME/toolchains/$NIGHTLY-$HOST_ARCH/lib/rustlib/$HOST_ARCH/bin"
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"
+34 -23
View File
@@ -1,10 +1,10 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use gpui::*;
use gpui_native_kit::theme::ActiveTheme;
use universal_time::{Instant, MonotonicClock, SystemTime, WallClock, define_time_provider};
use wasm_bindgen::prelude::*;
@@ -78,20 +78,16 @@ pub fn run() -> Result<(), JsValue> {
APPLICATION.with(|application| {
*application.borrow_mut() = Some(app.run_embedded(|cx| {
cx.bind_keys([
KeyBinding::new("cmd-d", chat_ui::ToggleDictation, None),
KeyBinding::new("ctrl-d", chat_ui::ToggleDictation, None),
]);
gpui_native_kit::init(cx);
speech::init_compat(cx);
gpui_kit::init(cx);
load_embedded_fonts(cx);
apply_web_fonts(cx);
// Initialize settings
settings::init(cx);
workspace::apply_appearance_mode(settings::persisted_appearance_mode(), cx);
apply_appearance(settings::persisted_appearance_mode(), cx);
settings::AppSettings::apply_theme_style(cx);
// Initialize the nostr client
state::init(cx, None);
@@ -110,20 +106,7 @@ pub fn run() -> Result<(), JsValue> {
// Initialize groups registry
nip29::init(cx);
let opts = WindowOptions {
window_background: cx.theme().window_background,
..Default::default()
};
cx.open_window(opts, |window, cx| {
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
gpui_native_kit::theme::Theme::sync_window_rem_size(window, cx);
window
.observe_window_appearance(|window, cx| {
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
cx.refresh_windows();
})
.detach();
gpui_kit::open_window(WindowOptions::default(), cx, |window, cx| {
workspace::init(window, cx)
})
.expect("Failed to open window. Please restart the application.");
@@ -151,3 +134,31 @@ fn load_embedded_fonts(cx: &mut App) {
.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),
}
}