Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905a339e3e | ||
|
|
6ce20927ee |
@@ -39,9 +39,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust nightly
|
- name: Install Rust nightly
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
with:
|
with:
|
||||||
toolchain: nightly-2026-10-31
|
|
||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
components: llvm-tools-preview
|
components: llvm-tools-preview
|
||||||
|
|
||||||
@@ -52,4 +51,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CC_wasm32_unknown_unknown: clang
|
CC_wasm32_unknown_unknown: clang
|
||||||
AR_wasm32_unknown_unknown: llvm-ar
|
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
+8
-1002
File diff suppressed because it is too large
Load Diff
+1
-14
@@ -34,7 +34,7 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
|||||||
nostr-browser-signer = { 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-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sqlite = { 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)
|
# Crypto (NIP-17 encrypted file messages)
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
@@ -48,13 +48,6 @@ hmac = "0.12"
|
|||||||
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
|
# 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" ] }
|
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
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
tts = { git = "https://github.com/ndarilek/tts-rs" }
|
tts = { git = "https://github.com/ndarilek/tts-rs" }
|
||||||
@@ -73,12 +66,6 @@ webbrowser = "1.0.4"
|
|||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
instant = "0.1"
|
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]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -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 |
+1
-24
@@ -947,11 +947,7 @@ async fn extract_rumor(
|
|||||||
let Some(device_signer) = device_signer else {
|
let Some(device_signer) = device_signer else {
|
||||||
return Err(error);
|
return Err(error);
|
||||||
};
|
};
|
||||||
match try_unwrap_with(device_signer, gift_wrap).await {
|
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?,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut rumor = unwrapped.rumor;
|
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
|
/// Stores an unwrapped event in local database with reference to original
|
||||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
let room_id = rumor.uniq_id().to_string();
|
let room_id = rumor.uniq_id().to_string();
|
||||||
|
|||||||
@@ -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 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 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.
|
/// The outcome of delivering an encrypted rumor to a single receiver.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -554,7 +553,6 @@ impl Room {
|
|||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
|
|
||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let resyncing = device.read(cx).is_resyncing();
|
|
||||||
let encryption_signer = device.read(cx).signer(cx);
|
let encryption_signer = device.read(cx).signer(cx);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -576,28 +574,6 @@ impl Room {
|
|||||||
Some(cx.background_spawn(async move {
|
Some(cx.background_spawn(async move {
|
||||||
let signer_kind = config.signer_kind();
|
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
|
// Deliver to every receiver concurrently
|
||||||
let sends = receivers.into_iter().map(|receiver| {
|
let sends = receivers.into_iter().map(|receiver| {
|
||||||
deliver(
|
deliver(
|
||||||
|
|||||||
@@ -23,6 +23,3 @@ smallvec.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
nip60_ui = { path = "../nip60_ui" }
|
|
||||||
|
|||||||
+17
-118
@@ -30,14 +30,14 @@ use person::{Directory, Person};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use speech::speaker::{END_GRACE, Speaker};
|
use speech::speaker::Speaker;
|
||||||
use state::{
|
use state::{
|
||||||
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
|
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
|
||||||
};
|
};
|
||||||
use ui::{
|
use ui::{
|
||||||
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
|
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
|
||||||
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage,
|
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
|
||||||
group_reactions, opens_run, rendered_text,
|
opens_run, rendered_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::file::*;
|
use crate::file::*;
|
||||||
@@ -46,7 +46,8 @@ use crate::file::*;
|
|||||||
const PRIVATE_NOTICE: &str =
|
const PRIVATE_NOTICE: &str =
|
||||||
"This conversation is private. Only members can see each other's messages.";
|
"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> =
|
static EMOJI_RE: LazyLock<Regex> =
|
||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||||
|
|
||||||
@@ -89,10 +90,6 @@ pub struct ChatPanel {
|
|||||||
speech_started: Option<instant::Instant>,
|
speech_started: Option<instant::Instant>,
|
||||||
/// Reads messages aloud
|
/// Reads messages aloud
|
||||||
speaker: Speaker,
|
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 state
|
||||||
subject_input: Entity<InputState>,
|
subject_input: Entity<InputState>,
|
||||||
/// Subject bar visibility
|
/// Subject bar visibility
|
||||||
@@ -225,20 +222,6 @@ impl ChatPanel {
|
|||||||
this.get_messages(window, cx);
|
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 {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
@@ -251,9 +234,7 @@ impl ChatPanel {
|
|||||||
input,
|
input,
|
||||||
speech,
|
speech,
|
||||||
speech_started: None,
|
speech_started: None,
|
||||||
speaker,
|
speaker: Speaker::new(),
|
||||||
speech_reveal: None,
|
|
||||||
reveal_timeout: None,
|
|
||||||
subject_input,
|
subject_input,
|
||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
@@ -264,7 +245,7 @@ impl ChatPanel {
|
|||||||
reports_by_id,
|
reports_by_id,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tasks: vec![reveal_end_task],
|
tasks: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,41 +662,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>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
@@ -1228,15 +1174,6 @@ impl ChatPanel {
|
|||||||
nostr.read(cx).current_user(),
|
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)
|
MessageRow::new(ix)
|
||||||
.show_author(show_author)
|
.show_author(show_author)
|
||||||
.hide_avatar(hide_avatar)
|
.hide_avatar(hide_avatar)
|
||||||
@@ -1261,7 +1198,7 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
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))
|
.child(MessageMedia::new(&message.media))
|
||||||
.when_some(message.file.as_ref(), |this, file| {
|
.when_some(message.file.as_ref(), |this, file| {
|
||||||
this.child(self.render_message_file(&id, file, cx))
|
this.child(self.render_message_file(&id, file, cx))
|
||||||
@@ -1494,6 +1431,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
let reply_id = *id;
|
let reply_id = *id;
|
||||||
let dropdown_id = *id;
|
let dropdown_id = *id;
|
||||||
|
let speech_id = *id;
|
||||||
let public_key = *public_key;
|
let public_key = *public_key;
|
||||||
|
|
||||||
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
|
let actions = actions.reply(cx.listener(move |this, _event, _window, cx| {
|
||||||
@@ -1509,42 +1447,15 @@ impl ChatPanel {
|
|||||||
actions
|
actions
|
||||||
};
|
};
|
||||||
|
|
||||||
let speaking = self.speech_reveal.is_some();
|
let actions = actions.speech(cx.listener(move |this, _event, window, cx| {
|
||||||
let speech_id = *id;
|
let text = this.message(&speech_id).map(|message| message.preview());
|
||||||
|
|
||||||
let actions = actions.speech(
|
if let Some(text) = text
|
||||||
speaking,
|
&& let Err(error) = this.speaker.speak(&text)
|
||||||
cx.listener(move |this, _event, window, cx| {
|
{
|
||||||
let text = this.message(&speech_id).map(|message| message.preview());
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
}
|
||||||
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| {
|
actions.dropdown(move |menu, _window, _cx| {
|
||||||
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
|
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
|
||||||
@@ -2047,18 +1958,6 @@ impl Render for ChatPanel {
|
|||||||
this.upload(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| {
|
.when(dictating, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
+19
-47
@@ -28,7 +28,7 @@ use settings::AppSettings;
|
|||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use speech::speaker::Speaker;
|
use speech::speaker::Speaker;
|
||||||
use state::{NostrRegistry, pick_file, upload_file};
|
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};
|
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
@@ -323,10 +323,6 @@ pub struct GroupPanel {
|
|||||||
speech_started: Option<instant::Instant>,
|
speech_started: Option<instant::Instant>,
|
||||||
/// Reads messages aloud
|
/// Reads messages aloud
|
||||||
speaker: Speaker,
|
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.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
attachments: Vec<Url>,
|
attachments: Vec<Url>,
|
||||||
/// Whether an upload is in flight.
|
/// Whether an upload is in flight.
|
||||||
@@ -429,20 +425,6 @@ impl GroupPanel {
|
|||||||
this.track_participants(cx);
|
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 {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
group: group.downgrade(),
|
group: group.downgrade(),
|
||||||
@@ -455,12 +437,10 @@ impl GroupPanel {
|
|||||||
input,
|
input,
|
||||||
speech,
|
speech,
|
||||||
speech_started: None,
|
speech_started: None,
|
||||||
speaker,
|
speaker: Speaker::new(),
|
||||||
speech_reveal: None,
|
|
||||||
reveal_timeout: None,
|
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
tasks: smallvec![reveal_end_task],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1043,29 +1023,22 @@ impl GroupPanel {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let directory = self.directory.clone();
|
let directory = self.directory.clone();
|
||||||
|
|
||||||
let content = match self
|
let content = self
|
||||||
.speech_reveal
|
.rendered_texts_by_id
|
||||||
.as_ref()
|
.entry(message.id)
|
||||||
.filter(|(reveal_id, _)| *reveal_id == message.id)
|
.or_insert_with(|| {
|
||||||
{
|
rendered_text(
|
||||||
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
|
&message.content,
|
||||||
None => self
|
message
|
||||||
.rendered_texts_by_id
|
.mentions
|
||||||
.entry(message.id)
|
.iter()
|
||||||
.or_insert_with(|| {
|
.map(|mention| (mention.range.clone(), mention.public_key)),
|
||||||
rendered_text(
|
&message.emojis,
|
||||||
&message.content,
|
&directory,
|
||||||
message
|
cx,
|
||||||
.mentions
|
)
|
||||||
.iter()
|
})
|
||||||
.map(|mention| (mention.range.clone(), mention.public_key)),
|
.element(ix.into(), window, cx);
|
||||||
&message.emojis,
|
|
||||||
&directory,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.element(ix.into(), window, cx),
|
|
||||||
};
|
|
||||||
|
|
||||||
let admin = nostr
|
let admin = nostr
|
||||||
.read(cx)
|
.read(cx)
|
||||||
@@ -1086,7 +1059,6 @@ impl GroupPanel {
|
|||||||
panel: cx.weak_entity(),
|
panel: cx.weak_entity(),
|
||||||
admin,
|
admin,
|
||||||
pinned,
|
pinned,
|
||||||
speaking: self.speech_reveal.is_some(),
|
|
||||||
persons: self.directory.clone(),
|
persons: self.directory.clone(),
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use nip29::{Activity, Group, Reaction};
|
|||||||
use nostr_sdk::prelude::{EventId, Kind};
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use speech::speaker::END_GRACE;
|
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use ui::{
|
use ui::{
|
||||||
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
|
Avatar, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview,
|
||||||
@@ -28,8 +27,6 @@ pub(crate) struct Actions {
|
|||||||
pub panel: WeakEntity<GroupPanel>,
|
pub panel: WeakEntity<GroupPanel>,
|
||||||
pub admin: bool,
|
pub admin: bool,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
/// Whether a message is currently being spoken, disabling the speech buttons
|
|
||||||
pub speaking: bool,
|
|
||||||
pub persons: Entity<Directory>,
|
pub persons: Entity<Directory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,40 +91,14 @@ pub(crate) fn render(
|
|||||||
let speech_panel = actions.panel;
|
let speech_panel = actions.panel;
|
||||||
let panel = speech_panel.clone();
|
let panel = speech_panel.clone();
|
||||||
let speech_text = message.preview();
|
let speech_text = message.preview();
|
||||||
let speech_id = message.id;
|
|
||||||
let speaking = actions.speaking;
|
|
||||||
|
|
||||||
let row = row.overlay(
|
let row = row.overlay(MessageActions::new().speech(move |_event, _window, cx| {
|
||||||
MessageActions::new().speech(speaking, move |_event, _window, cx| {
|
_ = speech_panel.update_in(cx, |panel, window, cx| {
|
||||||
_ = speech_panel.update_in(cx, |panel, window, cx| {
|
if let Err(error) = panel.speaker.speak(&speech_text) {
|
||||||
match panel.speaker.speak(&speech_text) {
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
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 custom = nostr
|
let custom = nostr
|
||||||
.read(cx)
|
.read(cx)
|
||||||
|
|||||||
+165
-488
@@ -1,10 +1,9 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::collections::{HashSet, VecDeque};
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::{
|
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 gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::Directory;
|
use person::Directory;
|
||||||
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
use state::{Announcement, NostrRegistry, UniversalSigner, credentials};
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
mod storage;
|
const IDENTIFIER: &str = "coop:device";
|
||||||
|
|
||||||
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||||
@@ -62,18 +59,10 @@ impl DeviceEvent {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
signer: Entity<Option<UniversalSigner>>,
|
signer: Entity<Option<UniversalSigner>>,
|
||||||
/// Ephemeral client key for the in-flight key request; never persisted
|
/// Whether there is a pending request for encryption key approval
|
||||||
app_keys: Option<Keys>,
|
pub(crate) pending_request: bool,
|
||||||
/// 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 an announcement has been made for this device
|
/// Whether an announcement has been made for this device
|
||||||
pub(crate) announcement_existed: Arc<AtomicBool>,
|
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
|
/// Person directory for requesters shown in notifications
|
||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Relay notification listener
|
/// Relay notification listener
|
||||||
@@ -119,21 +108,6 @@ impl DeviceRegistry {
|
|||||||
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
|
||||||
// The old account's state must not leak into the new one
|
// The old account's state must not leak into the new one
|
||||||
this.announcement_existed.store(false, Ordering::Relaxed);
|
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.handle_notifications(cx);
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
}
|
}
|
||||||
@@ -141,19 +115,15 @@ impl DeviceRegistry {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
let _ = entity.update(cx, |this, cx| {
|
entity
|
||||||
this.handle_notifications(cx);
|
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||||
});
|
.ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
signer,
|
signer,
|
||||||
app_keys: None,
|
pending_request: false,
|
||||||
pending_request: None,
|
|
||||||
processed_requests: VecDeque::new(),
|
|
||||||
announcement_existed: Arc::new(AtomicBool::new(false)),
|
announcement_existed: Arc::new(AtomicBool::new(false)),
|
||||||
resync: Arc::new(AtomicBool::new(false)),
|
|
||||||
rotating: false,
|
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
@@ -171,8 +141,6 @@ impl DeviceRegistry {
|
|||||||
let announcement_existed = self.announcement_existed.clone();
|
let announcement_existed = self.announcement_existed.clone();
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
let (tx, rx) = flume::bounded::<Event>(100);
|
||||||
|
|
||||||
let read = credentials::read_processed_requests(cx);
|
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
@@ -207,21 +175,6 @@ impl DeviceRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
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 {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Custom(10044) => {
|
Kind::Custom(10044) => {
|
||||||
@@ -238,7 +191,7 @@ impl DeviceRegistry {
|
|||||||
// New response event from the master device
|
// New response event from the master device
|
||||||
Kind::Custom(4455) => {
|
Kind::Custom(4455) => {
|
||||||
this.update(cx, |this, cx| {
|
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
|
/// Set whether there is a pending request for encryption key approval
|
||||||
fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context<Self>) {
|
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
|
||||||
if self.processed_requests.contains(&event_id) {
|
self.pending_request = pending;
|
||||||
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();
|
|
||||||
}
|
|
||||||
cx.notify();
|
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
|
/// Get the signer
|
||||||
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
|
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
|
||||||
self.signer.read(cx).clone()
|
self.signer.read(cx).clone()
|
||||||
@@ -315,16 +218,9 @@ impl DeviceRegistry {
|
|||||||
*this = Some(UniversalSigner::new(new_signer));
|
*this = Some(UniversalSigner::new(new_signer));
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
// The local key is now consistent with the announcement
|
|
||||||
self.resync.store(false, Ordering::Release);
|
|
||||||
cx.emit(DeviceEvent::Set);
|
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
|
/// Backup the encryption's secret key, returning the `nsec1...` content
|
||||||
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
|
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
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
|
/// Create new encryption key and announce it to user's relay list
|
||||||
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -517,7 +331,6 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
let n = keys.public_key();
|
let n = keys.public_key();
|
||||||
let created_at = Timestamp::now();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
// Construct an announcement event
|
// Construct an announcement event
|
||||||
@@ -527,7 +340,7 @@ impl DeviceRegistry {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Persist the key locally before announcing it
|
// Persist the key locally before announcing it
|
||||||
set_keys(&client, &signer, &secret, created_at).await?;
|
set_keys(&client, &signer, &secret).await?;
|
||||||
|
|
||||||
// Publish announcement
|
// Publish announcement
|
||||||
client
|
client
|
||||||
@@ -570,12 +383,10 @@ impl DeviceRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.wait_for_request(cx);
|
this.wait_for_request(cx);
|
||||||
this.rotate_if_due(cx);
|
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.resync.store(true, Ordering::Release);
|
this.request(cx);
|
||||||
this.request(Some(device_pubkey), cx);
|
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -603,17 +414,15 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request encryption keys from other device
|
/// Request encryption keys from other device
|
||||||
pub(crate) fn request(
|
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
expected_encryption: Option<PublicKey>,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
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 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 app_pubkey = app_keys.public_key();
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let public_key = signer.get_public_key_async().await?;
|
||||||
|
|
||||||
@@ -625,7 +434,8 @@ impl DeviceRegistry {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
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)
|
if decrypt_approval(&client, &app_keys, public_key, &event)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -634,26 +444,9 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable approval event found, construct a request event.
|
// 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]));
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::Custom(4454), "")
|
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||||
.tags(tags)
|
.tags(vec![Tag::custom("P", vec![app_pubkey])])
|
||||||
.finalize_async(&signer)
|
.finalize_async(&signer)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -690,13 +483,19 @@ impl DeviceRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
let app_keys_task = get_or_init_app_keys(cx);
|
||||||
let Some(app_keys) = self.app_keys.clone() else {
|
|
||||||
log::warn!("Cannot wait for approval without a client key");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, 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| {
|
this.update(cx, |_this, cx| {
|
||||||
cx.emit(DeviceEvent::Requesting(authorization_code(
|
cx.emit(DeviceEvent::Requesting(authorization_code(
|
||||||
&app_keys.public_key(),
|
&app_keys.public_key(),
|
||||||
@@ -706,7 +505,7 @@ impl DeviceRegistry {
|
|||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
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()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(4455))
|
.kind(Kind::Custom(4455))
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
@@ -730,18 +529,15 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Parse the approval event to get encryption key then set it
|
/// Parse the approval event to get encryption key then set it
|
||||||
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
|
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 nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
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 approval_id = event.id;
|
||||||
|
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
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 identity = signer.get_public_key_async().await?;
|
||||||
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
|
||||||
|
|
||||||
@@ -753,14 +549,14 @@ impl DeviceRegistry {
|
|||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, 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
|
// Clean up our request and the approval event from relays
|
||||||
this.cleanup_sync_events(approval_id, cx);
|
this.cleanup_sync_events(approval_id, cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Ignoring key transfer: {e}");
|
this.update(cx, |_this, cx| {
|
||||||
|
cx.emit(DeviceEvent::error(e.to_string()));
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -814,58 +610,40 @@ impl DeviceRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
let request_id = event.id;
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = get_keys(&client, &signer).await?;
|
let keys = get_keys(&client, &signer).await?;
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
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
|
// Extract the target public key from the event tags
|
||||||
let target =
|
let target = event
|
||||||
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
|
.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
|
// Encrypt the device keys with the user's signer
|
||||||
if let Some(requested) = requested_encryption_key(&event)
|
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
|
||||||
&& 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?;
|
|
||||||
|
|
||||||
// Construct the response event
|
// Construct the response event
|
||||||
//
|
//
|
||||||
// P tag: the one-time sender key used for the encryption
|
// P tag: the current device's public key
|
||||||
// p tag: the account identity, for inbox routing
|
// p tag: the requester's public key
|
||||||
// p tag: the requester's client key
|
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
||||||
let response = EventBuilder::new(Kind::Custom(4455), payload)
|
|
||||||
.tags(vec![
|
.tags(vec![
|
||||||
Tag::custom("P", vec![sender.public_key().to_hex()]),
|
Tag::custom("P", vec![keys.public_key().to_hex()]),
|
||||||
Tag::public_key(identity),
|
|
||||||
Tag::public_key(target),
|
Tag::public_key(target),
|
||||||
])
|
])
|
||||||
.finalize_async(&signer)
|
.finalize_async(&signer)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let relays = merge_relays(
|
// Send the response event to the user's relay list
|
||||||
write_relay_hints(&client, identity).await?,
|
client.send_event(&event).to_nip65().await?;
|
||||||
request_relay_hints(&event),
|
|
||||||
);
|
|
||||||
|
|
||||||
let send = client.send_event(&response);
|
|
||||||
if relays.is_empty() {
|
|
||||||
send.to_nip65().await?;
|
|
||||||
} else {
|
|
||||||
send.to(relays).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -874,11 +652,8 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
this.update(cx, |this, cx| {
|
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
|
// Allow future requests to surface again
|
||||||
this.pending_request = None;
|
this.set_pending_request(false, cx);
|
||||||
cx.notify();
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
@@ -903,70 +678,21 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
/// Handle encryption request
|
/// Handle encryption request
|
||||||
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
let Some(client_key) = client_pubkey_from_event(&event) else {
|
// Ignore if there is already a pending request
|
||||||
log::warn!("Ignoring invalid encryption key request: no valid requester key");
|
if self.pending_request {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
self.set_pending_request(true, cx);
|
||||||
|
|
||||||
let read = credentials::read_processed_requests(cx);
|
let notification = match self.notification(event, 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) {
|
|
||||||
Ok(notification) => notification,
|
Ok(notification) => notification,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Ignoring invalid encryption key request: {e}");
|
log::warn!("Ignoring invalid encryption key request: {e}");
|
||||||
|
self.set_pending_request(false, cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.pending_request = Some(PendingRequest { event, client_key });
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
if let Some(window) = cx.windows().first().copied() {
|
if let Some(window) = cx.windows().first().copied() {
|
||||||
if let Err(error) = window.update(cx, |_view, window, cx| {
|
if let Err(error) = window.update(cx, |_view, window, cx| {
|
||||||
window.push_notification(notification, cx);
|
window.push_notification(notification, cx);
|
||||||
@@ -989,16 +715,18 @@ impl DeviceRegistry {
|
|||||||
compare the code with the one shown on the requesting \
|
compare the code with the one shown on the requesting \
|
||||||
device.";
|
device.";
|
||||||
|
|
||||||
let requester =
|
let requester = event
|
||||||
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
|
.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 = authorization_code(&requester);
|
||||||
let code_label = code.clone();
|
|
||||||
|
|
||||||
let directory = self.directory.clone();
|
let directory = self.directory.clone();
|
||||||
directory.update(cx, |directory, cx| {
|
directory.update(cx, |directory, cx| directory.track([requester], cx));
|
||||||
directory.track([requester], cx);
|
let code_label = code.clone();
|
||||||
});
|
|
||||||
|
|
||||||
let entity = cx.entity().downgrade();
|
let entity = cx.entity().downgrade();
|
||||||
let loading = Rc::new(Cell::new(false));
|
let loading = Rc::new(Cell::new(false));
|
||||||
@@ -1103,111 +831,6 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
struct DeviceNotification;
|
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.
|
/// The first eight hexadecimal characters of the client public key.
|
||||||
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
fn authorization_code(public_key: &PublicKey) -> SharedString {
|
||||||
let hex = public_key.to_hex();
|
let hex = public_key.to_hex();
|
||||||
@@ -1215,6 +838,35 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
|
|||||||
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
|
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.
|
/// Decrypt and validate an approval event against the current announcement.
|
||||||
async fn decrypt_approval(
|
async fn decrypt_approval(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -1222,7 +874,13 @@ async fn decrypt_approval(
|
|||||||
identity: PublicKey,
|
identity: PublicKey,
|
||||||
event: &Event,
|
event: &Event,
|
||||||
) -> Result<Keys, Error> {
|
) -> 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
|
let decrypted = app_keys
|
||||||
.nip44_decrypt_async(&master, event.content.as_str())
|
.nip44_decrypt_async(&master, event.content.as_str())
|
||||||
@@ -1231,52 +889,71 @@ async fn decrypt_approval(
|
|||||||
let secret = SecretKey::from_hex(&decrypted)?;
|
let secret = SecretKey::from_hex(&decrypted)?;
|
||||||
let keys = Keys::new(secret);
|
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()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(10044))
|
.kind(Kind::Custom(10044))
|
||||||
.author(identity)
|
.author(identity)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let announcement = client
|
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
|
||||||
.database()
|
let announced = announcement
|
||||||
.query(filter.clone())
|
.tags
|
||||||
.await?
|
.iter()
|
||||||
.into_iter()
|
.find(|tag| tag.kind() == "n")
|
||||||
.next();
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
|
.context("Announcement has no valid encryption key")?;
|
||||||
|
|
||||||
let announcement = match announcement {
|
if keys.public_key() != announced {
|
||||||
Some(announcement) => Some(announcement),
|
return Err(anyhow!("Approval key doesn't match the announcement"));
|
||||||
None => {
|
|
||||||
let mut stream = client
|
|
||||||
.stream_events(filter)
|
|
||||||
.timeout(Duration::from_secs(3))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
stream
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map(|(_url, result)| result)
|
|
||||||
.transpose()?
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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)
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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("e_id).await?;
|
|
||||||
|
|
||||||
if quote.state != MintQuoteState::Paid {
|
|
||||||
return Ok(Outcome::Idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
let proofs = wallet.mint("e_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("e.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)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "nip60_ui"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
common = { path = "../common" }
|
|
||||||
nip60 = { path = "../nip60" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
|
||||||
gpui-kit.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
smallvec.workspace = true
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
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,
|
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, 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::clipboard::Clipboard;
|
|
||||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
|
||||||
use gpui_kit::component::notification::Notification;
|
|
||||||
use gpui_kit::component::separator::Separator;
|
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use smallvec::SmallVec;
|
|
||||||
|
|
||||||
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,
|
|
||||||
/// The token produced by the last send, shown for copying
|
|
||||||
sent_token: Option<SharedString>,
|
|
||||||
_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(token) => {
|
|
||||||
this.sent_token = Some(token.clone().into());
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name: "Wallet".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
sent_token: None,
|
|
||||||
_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_sent_token(&self, cx: &Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child("Sent token:"),
|
|
||||||
)
|
|
||||||
.when_some(self.sent_token.as_ref(), |this, token| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.truncate()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child(token.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Clipboard::new("copy-token")
|
|
||||||
.accessibility_label("Copy cashu token")
|
|
||||||
.value_fn({
|
|
||||||
let token = token.clone();
|
|
||||||
move |_, _| SharedString::from(token.to_string())
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask for an amount and send ecash.
|
|
||||||
fn open_send_dialog(window: &mut Window, cx: &mut App) {
|
|
||||||
let registry = WalletRegistry::global(cx);
|
|
||||||
let receiver_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub… (optional)"));
|
|
||||||
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
|
|
||||||
|
|
||||||
let send: SubmitFn = {
|
|
||||||
let receiver_input = receiver_input.clone();
|
|
||||||
let amount_input = amount_input.clone();
|
|
||||||
|
|
||||||
Rc::new(move |_, window, cx| {
|
|
||||||
let Some(amount) = parse_amount(&amount_input, cx) else {
|
|
||||||
window.push_notification(Notification::error("Enter an amount in sats"), cx);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let receiver = receiver_input.read(cx).value().trim().to_string();
|
|
||||||
|
|
||||||
let recipient = match receiver.as_str() {
|
|
||||||
"" => None,
|
|
||||||
value => match PublicKey::parse(value) {
|
|
||||||
Ok(recipient) => Some(recipient),
|
|
||||||
Err(_) => {
|
|
||||||
window.push_notification(
|
|
||||||
Notification::error("Receiver must be an npub or a public key"),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
match recipient {
|
|
||||||
Some(recipient) => registry.update(cx, |registry, cx| {
|
|
||||||
registry.send_nutzap(recipient, amount, None, None, cx);
|
|
||||||
}),
|
|
||||||
None => registry.update(cx, |registry, cx| {
|
|
||||||
registry.send_token(amount, cx);
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, _cx| {
|
|
||||||
this.width(px(380.))
|
|
||||||
.title("Send eCash")
|
|
||||||
.on_ok({
|
|
||||||
let send = send.clone();
|
|
||||||
move |event, window, cx| send(event, window, cx)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.child("To (npub, optional):"),
|
|
||||||
)
|
|
||||||
.child(Input::new(&receiver_input).cleanable(true)),
|
|
||||||
)
|
|
||||||
.child(Input::new(&amount_input).cleanable(true))
|
|
||||||
.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({
|
|
||||||
let send = send.clone();
|
|
||||||
move |event, window, cx| {
|
|
||||||
if send(event, 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())
|
|
||||||
.when_some(self.sent_token.as_ref(), |this, _token| {
|
|
||||||
this.child(self.render_sent_token(cx))
|
|
||||||
})
|
|
||||||
.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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Box<Person>),
|
Person(Person),
|
||||||
Announcement(Event),
|
Announcement(Event),
|
||||||
Relays(Event),
|
Relays(Event),
|
||||||
Emojis(Event),
|
Emojis(Event),
|
||||||
@@ -111,7 +111,7 @@ impl PersonRegistry {
|
|||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
match event {
|
match event {
|
||||||
Dispatch::Person(person) => {
|
Dispatch::Person(person) => {
|
||||||
this.insert(*person, cx);
|
this.insert(person, cx);
|
||||||
}
|
}
|
||||||
Dispatch::Announcement(event) => {
|
Dispatch::Announcement(event) => {
|
||||||
this.set_announcement(&event, cx);
|
this.set_announcement(&event, cx);
|
||||||
@@ -175,11 +175,7 @@ impl PersonRegistry {
|
|||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
let person = Person::new(event.pubkey, metadata);
|
let person = Person::new(event.pubkey, metadata);
|
||||||
|
|
||||||
if tx
|
if tx.send_async(Dispatch::Person(person)).await.is_err() {
|
||||||
.send_async(Dispatch::Person(Box::new(person)))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
log::warn!("Channel closed, dropping metadata event");
|
log::warn!("Channel closed, dropping metadata event");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,13 +144,6 @@ impl Person {
|
|||||||
|
|
||||||
/// Set profile encryption keys announcement
|
/// Set profile encryption keys announcement
|
||||||
pub(crate) fn set_announcement(&mut self, announcement: 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);
|
self.announcement = Some(announcement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-43
@@ -100,12 +100,7 @@ pub fn persisted_theme() -> ThemeSettings {
|
|||||||
|
|
||||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
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
|
/// Built-in image shown behind chat and group panels
|
||||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||||
|
|
||||||
@@ -152,7 +147,6 @@ setting_accessors! {
|
|||||||
pub screening: bool,
|
pub screening: bool,
|
||||||
pub wot_auto_trust: bool,
|
pub wot_auto_trust: bool,
|
||||||
pub nip4e: bool,
|
pub nip4e: bool,
|
||||||
pub nip4e_rotation_days: Option<u32>,
|
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
pub file_server: Url,
|
pub file_server: Url,
|
||||||
pub panel_background: bool,
|
pub panel_background: bool,
|
||||||
@@ -228,9 +222,6 @@ pub struct Settings {
|
|||||||
pub wot_auto_trust: bool,
|
pub wot_auto_trust: bool,
|
||||||
/// Enable decoupling encryption key
|
/// Enable decoupling encryption key
|
||||||
pub nip4e: bool,
|
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
|
/// Hide the notice that messages in public groups are not encrypted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_group_notice: bool,
|
pub hide_group_notice: bool,
|
||||||
@@ -263,7 +254,6 @@ impl Default for Settings {
|
|||||||
screening: true,
|
screening: true,
|
||||||
wot_auto_trust: true,
|
wot_auto_trust: true,
|
||||||
nip4e: false,
|
nip4e: false,
|
||||||
nip4e_rotation_days: None,
|
|
||||||
hide_group_notice: false,
|
hide_group_notice: false,
|
||||||
theme: ThemeSettings::default(),
|
theme: ThemeSettings::default(),
|
||||||
panel_background: true,
|
panel_background: true,
|
||||||
@@ -340,40 +330,22 @@ impl AppSettings {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the persisted theme tuning on top of the active theme
|
||||||
pub fn apply_theme_style(cx: &mut App) {
|
pub fn apply_theme_style(cx: &mut App) {
|
||||||
Self::apply_theme_tuning(Self::get_theme(cx), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
|
|
||||||
use gpui_kit::component::Theme;
|
use gpui_kit::component::Theme;
|
||||||
|
|
||||||
{
|
let theme = Self::get_theme(cx);
|
||||||
let styled = Theme::global_mut(cx);
|
let styled = Theme::global_mut(cx);
|
||||||
styled.font_size = px(tuning.font_size);
|
styled.font_size = px(theme.font_size);
|
||||||
styled.focus_ring = tuning.focus_ring;
|
styled.focus_ring = theme.focus_ring;
|
||||||
if let Some(radius) = tuning.radius {
|
if let Some(radius) = theme.radius {
|
||||||
styled.radius = px(radius);
|
styled.radius = px(radius);
|
||||||
}
|
|
||||||
if let Some(radius_lg) = tuning.radius_lg {
|
|
||||||
styled.radius_lg = px(radius_lg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if let Some(radius_lg) = theme.radius_lg {
|
||||||
Theme::update(cx, |styled| {
|
styled.radius_lg = px(radius_lg);
|
||||||
let background = styled.background;
|
}
|
||||||
let foreground = styled.foreground;
|
Theme::sync_base(cx);
|
||||||
let (hover_alpha, active_alpha) = if styled.is_dark() {
|
cx.refresh_windows();
|
||||||
(0.08, 0.12)
|
|
||||||
} else {
|
|
||||||
(0.04, 0.06)
|
|
||||||
};
|
|
||||||
if styled.list_hover == background {
|
|
||||||
styled.list_hover = foreground.alpha(hover_alpha);
|
|
||||||
}
|
|
||||||
if styled.tab_active == background {
|
|
||||||
styled.tab_active = foreground.alpha(active_alpha);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings
|
/// Load settings
|
||||||
@@ -444,8 +416,7 @@ impl AppSettings {
|
|||||||
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
||||||
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
||||||
}
|
}
|
||||||
let tuning = self.inner.read(cx).theme;
|
cx.refresh_windows();
|
||||||
Self::apply_theme_tuning(tuning, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if decoupling encryption key is enabled
|
/// Check if decoupling encryption key is enabled
|
||||||
|
|||||||
@@ -1,58 +1,33 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tts::Tts;
|
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.
|
/// Reads text aloud through the platform's speech backend, created on first use.
|
||||||
pub struct Speaker {
|
pub struct Speaker {
|
||||||
tts: Option<Tts>,
|
tts: Option<Tts>,
|
||||||
end_tx: flume::Sender<()>,
|
}
|
||||||
end_rx: flume::Receiver<()>,
|
|
||||||
|
impl Default for Speaker {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Speaker {
|
impl Speaker {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (end_tx, end_rx) = flume::unbounded();
|
Self { tts: None }
|
||||||
|
|
||||||
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.
|
/// Speak the text, interrupting what this speaker is already saying.
|
||||||
pub fn speak(&mut self, text: &str) -> Result<Duration, tts::Error> {
|
pub fn speak(&mut self, text: &str) -> Result<(), tts::Error> {
|
||||||
let end_tx = self.end_tx.clone();
|
|
||||||
let tts = match self.tts.as_ref() {
|
let tts = match self.tts.as_ref() {
|
||||||
Some(tts) => tts,
|
Some(tts) => tts,
|
||||||
None => self.tts.insert({
|
None => self.tts.insert(Tts::default()?),
|
||||||
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)?;
|
tts.speak(text, true)?;
|
||||||
Ok(Self::estimate(tts, text))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop speaking, a speaker that never spoke is already silent.
|
/// Stop speaking; a speaker that never spoke is already silent.
|
||||||
pub fn stop(&mut self) -> Result<(), tts::Error> {
|
pub fn stop(&mut self) -> Result<(), tts::Error> {
|
||||||
match self.tts.as_ref() {
|
match self.tts.as_ref() {
|
||||||
Some(tts) => tts.stop()?,
|
Some(tts) => tts.stop()?,
|
||||||
@@ -62,27 +37,11 @@ impl Speaker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort speaking state, a speaker that never spoke is silent.
|
/// Best-effort speaking state; a speaker that never spoke is silent.
|
||||||
pub fn speaking(&self) -> bool {
|
pub fn speaking(&self) -> bool {
|
||||||
self.tts
|
self.tts
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|tts| tts.is_speaking().ok())
|
.and_then(|tts| tts.is_speaking().ok())
|
||||||
.unwrap_or(false)
|
.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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task};
|
|||||||
|
|
||||||
use crate::USER_KEYRING;
|
use crate::USER_KEYRING;
|
||||||
|
|
||||||
/// The storage key for the log of handled NIP-4e key-sync request ids
|
/// The storage key for the NIP-4e app keys
|
||||||
const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests";
|
const APP_KEYS_KEY: &str = "coop/nip4e/app-keys";
|
||||||
|
|
||||||
/// Read the stored credential as `(username, secret)`
|
/// Read the stored credential as `(username, secret)`
|
||||||
#[allow(clippy::type_complexity)]
|
#[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
|
/// Load the NIP-4e app keys from storage, as raw secret bytes
|
||||||
pub fn read_processed_requests(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
pub fn read_app_keys(cx: &App) -> Task<Result<Option<Vec<u8>>>> {
|
||||||
read_named(cx, PROCESSED_REQUESTS_KEY)
|
read_named(cx, APP_KEYS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the log of handled key-sync request ids, as concatenated hex ids
|
/// Persist the NIP-4e app keys, as raw secret bytes
|
||||||
pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task<Result<()>> {
|
pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task<Result<()>> {
|
||||||
write_named(cx, PROCESSED_REQUESTS_KEY, ids)
|
write_named(cx, APP_KEYS_KEY, secret)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ use nostr_sdk::prelude::*;
|
|||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
/// The public key of the device that created this announcement.
|
/// The public key of the device that created this announcement.
|
||||||
public_key: PublicKey,
|
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 {
|
impl Announcement {
|
||||||
@@ -20,20 +16,11 @@ impl Announcement {
|
|||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
.and_then(|c| PublicKey::parse(c).ok())?;
|
.and_then(|c| PublicKey::parse(c).ok())?;
|
||||||
|
|
||||||
Some(Self {
|
Some(Self { public_key })
|
||||||
public_key,
|
|
||||||
event_id: event.id,
|
|
||||||
created_at: event.created_at,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the public key of the device that created this announcement.
|
/// Returns the public key of the device that created this announcement.
|
||||||
pub fn public_key(&self) -> PublicKey {
|
pub fn public_key(&self) -> PublicKey {
|
||||||
self.public_key
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ pub enum IconName {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Settings2,
|
Settings2,
|
||||||
Unlock,
|
|
||||||
Upload,
|
Upload,
|
||||||
Warning,
|
Warning,
|
||||||
}
|
}
|
||||||
@@ -64,7 +63,6 @@ impl IconNamed for IconName {
|
|||||||
Self::Search => "icons/search.svg",
|
Self::Search => "icons/search.svg",
|
||||||
Self::Settings => "icons/settings.svg",
|
Self::Settings => "icons/settings.svg",
|
||||||
Self::Settings2 => "icons/settings2.svg",
|
Self::Settings2 => "icons/settings2.svg",
|
||||||
Self::Unlock => "icons/unlock.svg",
|
|
||||||
Self::Upload => "icons/upload.svg",
|
Self::Upload => "icons/upload.svg",
|
||||||
Self::Warning => "icons/warning.svg",
|
Self::Warning => "icons/warning.svg",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
|
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
|
||||||
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
|
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
|
||||||
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
|
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
|
||||||
StyleRefinement, Styled, StyledImage, Window, div, img, px,
|
img, px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||||
use gpui_kit::component::clipboard::Clipboard;
|
use gpui_kit::component::clipboard::Clipboard;
|
||||||
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
|
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, Disableable, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex,
|
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
|
||||||
v_flex,
|
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
@@ -606,7 +604,7 @@ pub struct MessageActions {
|
|||||||
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
|
||||||
reply: Option<SharedClickListener>,
|
reply: Option<SharedClickListener>,
|
||||||
copy: Option<(ElementId, SharedString)>,
|
copy: Option<(ElementId, SharedString)>,
|
||||||
speech: Option<(bool, SharedClickListener)>,
|
speech: Option<SharedClickListener>,
|
||||||
dropdown: Option<DropdownBuilder>,
|
dropdown: Option<DropdownBuilder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,13 +644,12 @@ impl MessageActions {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add the read-aloud button, disabled while a message is already being spoken.
|
/// Add the read-aloud button.
|
||||||
pub fn speech(
|
pub fn speech(
|
||||||
mut self,
|
mut self,
|
||||||
disabled: bool,
|
|
||||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.speech = Some((disabled, Rc::new(handler)));
|
self.speech = Some(Rc::new(handler));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,14 +726,13 @@ impl RenderOnce for MessageActions {
|
|||||||
.when_some(copy, |this, (id, value)| {
|
.when_some(copy, |this, (id, value)| {
|
||||||
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
|
this.child(Clipboard::new(id).value(value).tooltip("Copy").small())
|
||||||
})
|
})
|
||||||
.when_some(speech, |this, (disabled, speech)| {
|
.when_some(speech, |this, speech| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("speech")
|
Button::new("speech")
|
||||||
.icon(crate::IconName::Speech)
|
.icon(crate::IconName::Speech)
|
||||||
.tooltip("Read aloud")
|
.tooltip("Read aloud")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.disabled(disabled)
|
|
||||||
.on_click(move |event, window, cx| speech(event, window, cx)),
|
.on_click(move |event, window, cx| speech(event, window, cx)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -754,44 +750,3 @@ impl RenderOnce for MessageActions {
|
|||||||
.refine_style(&style)
|
.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())
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ ui = { path = "../ui" }
|
|||||||
gpui-kit.workspace = true
|
gpui-kit.workspace = true
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
nip29 = { path = "../nip29" }
|
|
||||||
nip29_ui = { path = "../nip29_ui" }
|
|
||||||
nip4e = { path = "../nip4e" }
|
nip4e = { path = "../nip4e" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
chat_ui = { path = "../chat_ui" }
|
chat_ui = { path = "../chat_ui" }
|
||||||
|
nip29 = { path = "../nip29" }
|
||||||
|
nip29_ui = { path = "../nip29_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
speech = { path = "../speech" }
|
speech = { path = "../speech" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
@@ -30,7 +30,3 @@ serde.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
nip60 = { path = "../nip60" }
|
|
||||||
nip60_ui = { path = "../nip60_ui" }
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
Styled, Subscription, Task, Window, div, px, relative,
|
Subscription, Task, Window, div, px, relative,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
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::input::{Input, InputEvent, InputState};
|
||||||
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex};
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use state::{NostrRegistry, StateEvent};
|
use state::{NostrRegistry, StateEvent};
|
||||||
use ui::IconName;
|
|
||||||
|
|
||||||
pub fn open(window: &mut Window, cx: &mut App) {
|
pub fn open(window: &mut Window, cx: &mut App) {
|
||||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
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)
|
this.close_button(false)
|
||||||
.overlay_closable(false)
|
.overlay_closable(false)
|
||||||
.keyboard(false)
|
.keyboard(false)
|
||||||
|
.title("Unlock your identity")
|
||||||
.child(import.clone())
|
.child(import.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,15 +74,7 @@ impl ImportIdentity {
|
|||||||
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
|
||||||
let pass_input = cx.new(|cx| {
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let input = InputState::new(window, cx).masked(true);
|
|
||||||
|
|
||||||
if mode == Mode::Unlock {
|
|
||||||
input.placeholder("Passphrase to unlock your identity")
|
|
||||||
} else {
|
|
||||||
input
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
@@ -245,59 +235,15 @@ impl ImportIdentity {
|
|||||||
Ok(())
|
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 {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
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 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.";
|
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 value = self.key_input.read(cx).value().trim().to_string();
|
||||||
let require_password = value.starts_with("ncryptsec1");
|
let require_password = value.starts_with("ncryptsec1");
|
||||||
let require_encrypt = value.starts_with("nsec1");
|
let require_encrypt = value.starts_with("nsec1");
|
||||||
@@ -311,19 +257,25 @@ impl Render for ImportIdentity {
|
|||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(
|
.when(!is_unlock, |this| {
|
||||||
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| {
|
|
||||||
this.child(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.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)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -371,7 +323,7 @@ impl Render for ImportIdentity {
|
|||||||
.child({
|
.child({
|
||||||
let content = v_flex().gap_1().child(
|
let content = v_flex().gap_1().child(
|
||||||
Button::new("login")
|
Button::new("login")
|
||||||
.label("Continue")
|
.label(if is_unlock { "Unlock" } else { "Continue" })
|
||||||
.primary()
|
.primary()
|
||||||
.loading(self.loading)
|
.loading(self.loading)
|
||||||
.disabled(self.loading)
|
.disabled(self.loading)
|
||||||
@@ -381,26 +333,30 @@ impl Render for ImportIdentity {
|
|||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let content = content.child(
|
let content = content.when(!is_unlock, |this| {
|
||||||
Button::new("proxy")
|
this.child(
|
||||||
.label("Connect via Web Extension")
|
Button::new("proxy")
|
||||||
.ghost()
|
.label("Connect via Web Extension")
|
||||||
.disabled(self.loading)
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.disabled(self.loading)
|
||||||
this.proxy(cx);
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
})),
|
this.proxy(cx);
|
||||||
);
|
})),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let content = content.child(
|
let content = content.when(!is_unlock, |this| {
|
||||||
Button::new("extension")
|
this.child(
|
||||||
.label("Continue with Browser Extension")
|
Button::new("extension")
|
||||||
.ghost()
|
.label("Continue with Browser Extension")
|
||||||
.disabled(self.loading)
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
.disabled(self.loading)
|
||||||
this.extension(cx);
|
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||||
})),
|
this.extension(cx);
|
||||||
);
|
})),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
content
|
content
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
use gpui_kit::component::input::{Input, InputState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::{WindowExt, h_flex, v_flex};
|
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};
|
use nip29::{GroupReference, GroupsRegistry};
|
||||||
|
|
||||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use gpui_kit::component::progress::Progress;
|
|||||||
use gpui_kit::component::separator::Separator;
|
use gpui_kit::component::separator::Separator;
|
||||||
use gpui_kit::component::switch::Switch;
|
use gpui_kit::component::switch::Switch;
|
||||||
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
|
use settings::{AppSettings, AppearanceMode};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use speech::model::{self as speech_model};
|
use speech::model::{self as speech_model};
|
||||||
use ui::{settings_block, settings_row};
|
use ui::{settings_block, settings_row};
|
||||||
@@ -41,8 +41,6 @@ pub struct Preferences {
|
|||||||
radius_input: Entity<InputState>,
|
radius_input: Entity<InputState>,
|
||||||
radius_lg_input: Entity<InputState>,
|
radius_lg_input: Entity<InputState>,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mint_input: Entity<InputState>,
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
_speech_poll: Task<()>,
|
_speech_poll: Task<()>,
|
||||||
/// Keeps the control subscriptions alive while the dialog is open
|
/// Keeps the control subscriptions alive while the dialog is open
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
@@ -92,9 +90,6 @@ impl Preferences {
|
|||||||
.max(RADIUS_RANGE.1 as f64)
|
.max(RADIUS_RANGE.1 as f64)
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
|
|
||||||
|
|
||||||
let subscriptions = vec![
|
let subscriptions = vec![
|
||||||
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
|
||||||
if let Some(font_size) = value {
|
if let Some(font_size) = value {
|
||||||
@@ -145,8 +140,6 @@ impl Preferences {
|
|||||||
radius_input,
|
radius_input,
|
||||||
radius_lg_input,
|
radius_lg_input,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mint_input,
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
_speech_poll,
|
_speech_poll,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -192,56 +185,6 @@ 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)
|
/// Set the appearance mode (system, light or dark)
|
||||||
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
|
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
|
||||||
AppSettings::update_appearance_mode(mode, cx);
|
AppSettings::update_appearance_mode(mode, cx);
|
||||||
@@ -394,7 +337,6 @@ impl Render for Preferences {
|
|||||||
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
"Corner radius for large elements like dialogs. Empty follows the OS theme.";
|
||||||
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
const FOCUS_RING: &str = "Draw a ring around focused controls.";
|
||||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
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 BACKGROUND: &str = "Show an image behind chat and group panels.";
|
||||||
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
const MEDIA: &str = "Server for media attachments. Only blossom services are supported.";
|
||||||
|
|
||||||
@@ -402,7 +344,6 @@ impl Render for Preferences {
|
|||||||
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let nip4e = AppSettings::get_nip4e(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 appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||||
let theme = AppSettings::get_theme(cx);
|
let theme = AppSettings::get_theme(cx);
|
||||||
let panel_background = AppSettings::get_panel_background(cx);
|
let panel_background = AppSettings::get_panel_background(cx);
|
||||||
@@ -414,7 +355,7 @@ impl Render for Preferences {
|
|||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.my_2()
|
.mt_2()
|
||||||
.gap_4()
|
.gap_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.child(
|
||||||
@@ -566,22 +507,6 @@ impl Render for Preferences {
|
|||||||
}),
|
}),
|
||||||
cx,
|
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(Separator::horizontal())
|
||||||
.child(self.render_speech(cx))
|
.child(self.render_speech(cx))
|
||||||
.child(self.render_dictation_shortcut(window, cx))
|
.child(self.render_dictation_shortcut(window, cx))
|
||||||
@@ -603,7 +528,5 @@ impl Render for Preferences {
|
|||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
.child(Separator::horizontal())
|
|
||||||
.child(self.render_wallet(cx))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ enum Command {
|
|||||||
ShowContactList,
|
ShowContactList,
|
||||||
ShowInbox,
|
ShowInbox,
|
||||||
ShowBrowse,
|
ShowBrowse,
|
||||||
ShowWallet,
|
|
||||||
NewChat,
|
NewChat,
|
||||||
NewGroup,
|
NewGroup,
|
||||||
JoinGroup,
|
JoinGroup,
|
||||||
@@ -92,12 +91,11 @@ impl Workspace {
|
|||||||
let workspace = cx.entity().downgrade();
|
let workspace = cx.entity().downgrade();
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
|
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
|
||||||
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);
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
AppSettings::apply_theme_style(cx);
|
}));
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to the nostr events
|
// Subscribe to the nostr events
|
||||||
@@ -197,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, {
|
App::on_action(cx, {
|
||||||
let workspace = workspace.clone();
|
let workspace = workspace.clone();
|
||||||
move |action, cx| {
|
move |action, cx| {
|
||||||
@@ -332,13 +306,6 @@ impl Workspace {
|
|||||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||||
self.add_panel_to_dock(panel, DockPlacement::Center, 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 => {
|
Command::NewChat => {
|
||||||
new_chat::open(self.dock.downgrade(), window, cx);
|
new_chat::open(self.dock.downgrade(), window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
|
||||||
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
|
||||||
Stateful, Styled, Window, div, px,
|
|
||||||
};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui::{AnyElement, Entity};
|
use gpui::{AnyElement, Entity, IntoElement};
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui::{
|
||||||
|
App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px,
|
||||||
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_kit::component::Disableable;
|
use gpui_kit::component::Disableable;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
use gpui_kit::component::clipboard::Clipboard;
|
|
||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
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 gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::shorten_pubkey;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use state::{CLIENT_NAME, NostrRegistry};
|
use state::{CLIENT_NAME, NostrRegistry};
|
||||||
use ui::{Avatar, IconName};
|
use ui::{Avatar, IconName};
|
||||||
|
|
||||||
@@ -38,10 +31,6 @@ impl Sidebar {
|
|||||||
let updater = AutoUpdater::try_global(cx);
|
let updater = AutoUpdater::try_global(cx);
|
||||||
let profile = self.directory.read(cx).person(current_user, cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
let encryption_key = profile
|
|
||||||
.announcement()
|
|
||||||
.map(|announcement| announcement.public_key());
|
|
||||||
let encryption_enabled = AppSettings::get_nip4e(cx);
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("sidebar-user")
|
.id("sidebar-user")
|
||||||
@@ -82,11 +71,6 @@ impl Sidebar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
actions
|
actions
|
||||||
.when(encryption_enabled, |actions| {
|
|
||||||
actions.when_some(encryption_key, |actions, encryption_key| {
|
|
||||||
actions.child(self.render_encryption(encryption_key))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("sidebar-search")
|
Button::new("sidebar-search")
|
||||||
.icon(IconName::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 {
|
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div {
|
||||||
v_flex()
|
v_flex()
|
||||||
.p_2()
|
.p_2()
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ impl Sidebar {
|
|||||||
let requests_open = self.requests_open;
|
let requests_open = self.requests_open;
|
||||||
let contacts_open = self.contacts_open && !requests_open;
|
let contacts_open = self.contacts_open && !requests_open;
|
||||||
let rows = Rc::new(self.rows_for(cx));
|
let rows = Rc::new(self.rows_for(cx));
|
||||||
|
|
||||||
// Whether to show the create button for the contact picker
|
// Whether to show the create button for the contact picker
|
||||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
||||||
|
|
||||||
@@ -59,25 +60,12 @@ impl Sidebar {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.border_l_1()
|
|
||||||
.border_color(cx.theme().sidebar_border)
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_start()
|
.items_start()
|
||||||
.justify_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(
|
.child(
|
||||||
Nav::new("browse")
|
Nav::new("browse")
|
||||||
.prefix(Icon::new(IconName::Compass).small())
|
.prefix(Icon::new(IconName::Compass).small())
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use nostr_sdk::prelude::*;
|
|||||||
use person::{Directory, PersonRegistry};
|
use person::{Directory, PersonRegistry};
|
||||||
pub(crate) use rows::open_room;
|
pub(crate) use rows::open_room;
|
||||||
use search::SearchResult;
|
use search::SearchResult;
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||||
use ui::title_bar_drag_handlers;
|
use ui::title_bar_drag_handlers;
|
||||||
@@ -105,8 +104,6 @@ impl Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let groups = GroupsRegistry::global(cx);
|
let groups = GroupsRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let directory = Directory::new(cx);
|
|
||||||
|
|
||||||
let find_input = cx.new(|cx| {
|
let find_input = cx.new(|cx| {
|
||||||
InputState::new(window, 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| {
|
tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||||
loop {
|
loop {
|
||||||
@@ -237,7 +229,7 @@ impl Sidebar {
|
|||||||
chats_open: None,
|
chats_open: None,
|
||||||
contacts_open: false,
|
contacts_open: false,
|
||||||
contacts: None,
|
contacts: None,
|
||||||
directory,
|
directory: Directory::new(cx),
|
||||||
selected_contacts: HashSet::new(),
|
selected_contacts: HashSet::new(),
|
||||||
requests_open: false,
|
requests_open: false,
|
||||||
new_requests: false,
|
new_requests: false,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ workspace = { path = "../crates/workspace" }
|
|||||||
ui = { path = "../crates/ui" }
|
ui = { path = "../crates/ui" }
|
||||||
state = { path = "../crates/state" }
|
state = { path = "../crates/state" }
|
||||||
nip4e = { path = "../crates/nip4e" }
|
nip4e = { path = "../crates/nip4e" }
|
||||||
nip60 = { path = "../crates/nip60" }
|
|
||||||
chat = { path = "../crates/chat" }
|
chat = { path = "../crates/chat" }
|
||||||
chat_ui = { path = "../crates/chat_ui" }
|
chat_ui = { path = "../crates/chat_ui" }
|
||||||
nip29 = { path = "../crates/nip29" }
|
nip29 = { path = "../crates/nip29" }
|
||||||
|
|||||||
+14
-12
@@ -72,13 +72,14 @@ fn main() {
|
|||||||
|
|
||||||
let appearance = settings::persisted_appearance_mode();
|
let appearance = settings::persisted_appearance_mode();
|
||||||
let font_size = settings::persisted_theme().font_size;
|
let font_size = settings::persisted_theme().font_size;
|
||||||
|
match apply_native_theme(appearance, font_size, cx) {
|
||||||
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
|
Ok(()) => start_theme_watcher(appearance, cx),
|
||||||
log::warn!("native theme unavailable ({error})");
|
Err(error) => {
|
||||||
apply_appearance(appearance, cx);
|
log::warn!("native theme unavailable ({error})");
|
||||||
AppSettings::apply_theme_style(cx);
|
apply_appearance(appearance, cx);
|
||||||
|
AppSettings::apply_theme_style(cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
start_theme_watcher(cx);
|
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(cx, cli_key);
|
state::init(cx, cli_key);
|
||||||
@@ -91,11 +92,6 @@ fn main() {
|
|||||||
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
nip4e::init(cx);
|
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
|
// Initialize app registry
|
||||||
chat::init(cx);
|
chat::init(cx);
|
||||||
|
|
||||||
@@ -217,12 +213,14 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
|
|||||||
/// Holds the watcher subscription so it lives as long as the app.
|
/// Holds the watcher subscription so it lives as long as the app.
|
||||||
struct ThemeWatcher {
|
struct ThemeWatcher {
|
||||||
_subscription: ThemeSubscription,
|
_subscription: ThemeSubscription,
|
||||||
|
/// The appearance mode the observer in `workspace` was registered with.
|
||||||
|
appearance: std::cell::Cell<AppearanceMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Global for ThemeWatcher {}
|
impl Global for ThemeWatcher {}
|
||||||
|
|
||||||
/// Start a background task to watch for OS theme changes and re-apply the native theme.
|
/// Start a background task to watch for OS theme changes and re-apply the native theme.
|
||||||
fn start_theme_watcher(cx: &mut App) {
|
fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) {
|
||||||
let (sender, receiver) = flume::unbounded();
|
let (sender, receiver) = flume::unbounded();
|
||||||
match native_theme::watch::on_theme_change(move |event| {
|
match native_theme::watch::on_theme_change(move |event| {
|
||||||
if let Err(error) = sender.send(event) {
|
if let Err(error) = sender.send(event) {
|
||||||
@@ -232,6 +230,7 @@ fn start_theme_watcher(cx: &mut App) {
|
|||||||
Ok(subscription) => {
|
Ok(subscription) => {
|
||||||
cx.set_global(ThemeWatcher {
|
cx.set_global(ThemeWatcher {
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
|
appearance: std::cell::Cell::new(initial_appearance),
|
||||||
});
|
});
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
while receiver.recv_async().await.is_ok() {
|
while receiver.recv_async().await.is_ok() {
|
||||||
@@ -239,6 +238,9 @@ fn start_theme_watcher(cx: &mut App) {
|
|||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
let appearance = AppSettings::get_appearance_mode(cx);
|
let appearance = AppSettings::get_appearance_mode(cx);
|
||||||
let font_size = AppSettings::get_theme(cx).font_size;
|
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) {
|
if let Err(error) = apply_native_theme(appearance, font_size, cx) {
|
||||||
log::warn!("failed to apply updated OS theme ({error})");
|
log::warn!("failed to apply updated OS theme ({error})");
|
||||||
}
|
}
|
||||||
|
|||||||
-387
@@ -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.
|
|
||||||
@@ -21,12 +21,12 @@ if [[ "$1" == "--release" ]]; then
|
|||||||
echo -e "${YELLOW}Building in release mode${NC}"
|
echo -e "${YELLOW}Building in release mode${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NIGHTLY="nightly-2026-10-31"
|
# wasm_thread requires a nightly compiler for its atomics features
|
||||||
CARGO="cargo +$NIGHTLY"
|
CARGO="cargo +nightly"
|
||||||
|
|
||||||
# Step 0: Toolchain prerequisites
|
# Step 0: Toolchain prerequisites
|
||||||
echo -e "${GREEN}Step 0: Checking toolchain...${NC}"
|
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
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
# secp256k1-sys compiles C for wasm; Apple clang has the wasm32 target and
|
# 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}"
|
echo -e "${RED}Apple clang not found. Install the Xcode command line tools: xcode-select --install${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rustup component add llvm-tools-preview --toolchain "$NIGHTLY"
|
rustup component add llvm-tools-preview --toolchain nightly
|
||||||
|
|
||||||
RUSTUP_HOME=$(rustup show home)
|
RUSTUP_HOME=$(rustup show home)
|
||||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
@@ -43,7 +43,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
|
|||||||
else
|
else
|
||||||
HOST_ARCH="x86_64-apple-darwin"
|
HOST_ARCH="x86_64-apple-darwin"
|
||||||
fi
|
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 CC_wasm32_unknown_unknown=clang
|
||||||
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
|
export AR_wasm32_unknown_unknown="$LLVM_BIN/llvm-ar"
|
||||||
|
|||||||
Reference in New Issue
Block a user