From 8b9c8bd35eb09d30889d081407f65d40a9f7c32c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 13 Nov 2026 04:37:13 +0700 Subject: [PATCH] Prune dead code and key elements by stable ids --- Cargo.lock | 8 -- crates/assets/Cargo.toml | 1 - crates/chat/Cargo.toml | 3 - crates/chat_ui/src/lib.rs | 5 -- crates/nip29/Cargo.toml | 1 - crates/nip29_ui/src/lib.rs | 4 +- crates/nip29_ui/src/message.rs | 8 +- crates/nip60/Cargo.toml | 1 - crates/nip60/src/lib.rs | 41 ----------- crates/nip60/src/ops.rs | 81 +-------------------- crates/nip60_ui/src/wallet.rs | 1 - crates/person/src/person.rs | 29 -------- crates/state/src/file.rs | 6 -- crates/state/src/lib.rs | 11 +-- crates/ui/Cargo.toml | 3 - crates/ui/src/avatar.rs | 48 ++++++++++-- crates/ui/src/message.rs | 17 +++-- crates/ui/src/text.rs | 4 +- crates/workspace/src/lib.rs | 18 ++--- crates/workspace/src/panels/contact_list.rs | 7 +- crates/workspace/src/panels/greeter.rs | 16 ++-- crates/workspace/src/sidebar/list.rs | 58 ++++++++++++++- crates/workspace/src/sidebar/mod.rs | 30 +++++++- crates/workspace/src/sidebar/rows.rs | 26 ++++--- desktop/Cargo.toml | 1 - docs/audit-stable-release.md | 65 ++++++++++++++++- web/Cargo.toml | 1 - 27 files changed, 244 insertions(+), 250 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4bcfc046..8b435250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,7 +381,6 @@ dependencies = [ name = "assets" version = "2.0.0-alpha3" dependencies = [ - "anyhow", "gpui-kit", "gpui-pre", "rust-embed", @@ -1448,7 +1447,6 @@ dependencies = [ "rand 0.10.3", "settings", "smallvec", - "smol", "state", ] @@ -1821,7 +1819,6 @@ dependencies = [ "settings", "state", "tracing-subscriber", - "ui", "workspace", ] @@ -1849,7 +1846,6 @@ dependencies = [ "settings", "state", "tracing-wasm", - "ui", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "wasm-bindgen", "wasm-bindgen-futures", @@ -6052,7 +6048,6 @@ dependencies = [ "gpui-pre", "log", "nostr-sdk", - "person", "serde_json", "smallvec", "state", @@ -6119,7 +6114,6 @@ dependencies = [ "sha2 0.10.9", "smallvec", "state", - "tokio", ] [[package]] @@ -10348,8 +10342,6 @@ dependencies = [ "nostr-sdk", "person", "smallvec", - "uuid", - "web-time", ] [[package]] diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 54d49e18..cf8421e0 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -6,7 +6,6 @@ publish.workspace = true [dependencies] gpui.workspace = true -anyhow.workspace = true rust-embed.workspace = true gpui-kit.workspace = true diff --git a/crates/chat/Cargo.toml b/crates/chat/Cargo.toml index efc4f0e4..18ce895f 100644 --- a/crates/chat/Cargo.toml +++ b/crates/chat/Cargo.toml @@ -24,6 +24,3 @@ flume.workspace = true futures.workspace = true fuzzy-matcher = "0.3.7" - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -smol.workspace = true diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index abb8b740..76d3e40b 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -145,7 +145,6 @@ impl ChatPanel { }) .unwrap_or(("Unknown".into(), "Message...".into())); - // Define input state let input = cx.new(|cx| { TextareaState::new(window, cx) .placeholder(format!("Message {}", name)) @@ -153,7 +152,6 @@ impl ChatPanel { .clean_on_escape() }); - // Define dictation state let speech = cx.new(|cx| { let state = SpeechState::new(cx) .recognizer(speech::recognizer()) @@ -166,11 +164,9 @@ impl ChatPanel { state }); - // Define subject input state let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject...")); let subject_bar = cx.new(|_cx| false); - // Define subscriptions let mut subscriptions = smallvec![]; subscriptions.push( @@ -219,7 +215,6 @@ impl ChatPanel { }), ); - // Define all functions that will run after the current cycle cx.defer_in(window, |this, window, cx| { this.connect(window, cx); this.subscribe_room_events(window, cx); diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml index bbc329d3..b277dd32 100644 --- a/crates/nip29/Cargo.toml +++ b/crates/nip29/Cargo.toml @@ -11,7 +11,6 @@ futures.workspace = true serde_json.workspace = true state = { path = "../state" } common = { path = "../common" } -person = { path = "../person" } chat = { path = "../chat" } gpui.workspace = true diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 6c0a9d44..98b7cd06 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -1065,7 +1065,8 @@ impl GroupPanel { cx, ) }) - .element(ix.into(), window, cx), + // Key by event id: parse state is kept per element id and indices shift on prepend + .element(SharedString::from(message.id.to_hex()).into(), window, cx), }; let admin = nostr @@ -1077,7 +1078,6 @@ impl GroupPanel { let pinned = group.pins().pins().contains(&Pin::Event(message.id)); message::render( - ix, message, content, show_author, diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 4df8e660..c5d654f5 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -35,7 +35,6 @@ pub(crate) struct Actions { } pub(crate) fn render( - ix: usize, message: &Message, content: AnyElement, show_author: bool, @@ -60,7 +59,8 @@ pub(crate) fn render( nostr.read(cx).current_user(), ); - let row = MessageRow::new(ix) + // Key by event id: indices shift on prepend and would re-key descendants' state + let row = MessageRow::new(SharedString::from(message.id.to_hex())) .show_author(show_author) .hide_avatar(hide_avatar) .avatar( @@ -337,7 +337,7 @@ fn replies( let mut items = Vec::with_capacity(targets.len()); - for (ix, id) in targets.iter().enumerate() { + for id in targets.iter() { let context = entity.read_with(cx, |group, cx| { let message = group.message(*id)?; let author = persons.read(cx).person(&message.author, cx); @@ -356,7 +356,7 @@ fn replies( let panel = panel.clone(); items.push( - ReplyPreview::new(ix, author, preview) + ReplyPreview::new(SharedString::from(format!("reply-{id}")), author, preview) .on_click(move |_event, _window, cx| { if let Err(error) = panel.update(cx, |panel, cx| { panel.scroll_to(&id, cx); diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml index f67f1109..77e8eb3d 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -20,7 +20,6 @@ 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 diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index 085a64d3..93778e50 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -31,7 +31,6 @@ impl Global for GlobalWalletRegistry {} #[derive(Debug, Clone)] pub enum WalletEvent { Updated, - TokenCreated(String), Error(String), } @@ -203,36 +202,6 @@ impl WalletRegistry { self.emit_updated(cx); } - /// Pay a lightning invoice from the wallet. - pub fn melt(&mut self, invoice: String, cx: &mut Context) { - let Some(mint_url) = self.primary_mint(cx) else { - return; - }; - - self.schedule_job( - ops::Job::Melt { - mint: mint_url, - invoice, - }, - cx, - ); - } - - /// Send ecash as a token string the recipient can paste anywhere. - pub fn send_token(&mut self, amount: u64, cx: &mut Context) { - let Some(mint_url) = self.primary_mint(cx) else { - return; - }; - - self.schedule_job( - ops::Job::SendToken { - mint: mint_url, - amount, - }, - cx, - ); - } - /// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events. pub fn send_nutzap( &mut self, @@ -947,16 +916,6 @@ impl WalletRegistry { self.emit_updated(cx); log::info!("minted {amount} sats from quote {quote_id}"); } - ops::Outcome::Melted { update, amount } => { - self.apply_token_update(update); - self.emit_updated(cx); - log::info!("melted {amount} sats"); - } - ops::Outcome::Sent { update, token } => { - self.apply_token_update(update); - cx.emit(WalletEvent::TokenCreated(token)); - self.emit_updated(cx); - } ops::Outcome::NutzapSent { update, amount } => { self.apply_token_update(update); self.emit_updated(cx); diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index e347c636..347f9e22 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -8,8 +8,8 @@ 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, + CurrencyUnit, Id, KeySetInfo, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey, + SpendingConditions, State, }; use cdk::secret::Secret; use cdk::wallet::types::ProofInfo; @@ -35,14 +35,6 @@ pub(crate) enum Job { mint: MintUrl, quote_id: String, }, - Melt { - mint: MintUrl, - invoice: String, - }, - SendToken { - mint: MintUrl, - amount: u64, - }, SendNutzap { recipient: PublicKey, amount: u64, @@ -117,67 +109,6 @@ impl Job { 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, @@ -330,14 +261,6 @@ pub(crate) enum Outcome { update: TokenUpdate, amount: u64, }, - Melted { - update: TokenUpdate, - amount: u64, - }, - Sent { - update: TokenUpdate, - token: String, - }, NutzapSent { update: TokenUpdate, amount: u64, diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs index e3ed3c6b..67efb3e7 100644 --- a/crates/nip60_ui/src/wallet.rs +++ b/crates/nip60_ui/src/wallet.rs @@ -52,7 +52,6 @@ impl WalletPanel { WalletEvent::Error(error) => { window.push_notification(Notification::error(error.clone()), cx); } - WalletEvent::TokenCreated(_) => {} }, )); diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f5e9de77..bfa3e20f 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -1,6 +1,3 @@ -use std::cmp::Ordering; -use std::hash::{Hash, Hasher}; - use gpui::SharedString; use nostr_sdk::prelude::*; use state::Announcement; @@ -20,32 +17,6 @@ pub struct Person { messaging_relays: Vec, } -impl PartialEq for Person { - fn eq(&self, other: &Self) -> bool { - self.public_key == other.public_key - } -} - -impl Eq for Person {} - -impl PartialOrd for Person { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for Person { - fn cmp(&self, other: &Self) -> Ordering { - self.name().cmp(&other.name()) - } -} - -impl Hash for Person { - fn hash(&self, state: &mut H) { - self.public_key.hash(state) - } -} - impl Person { pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { Self { diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 2aba636e..88d09749 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -376,12 +376,6 @@ impl DownloadedFile { LocalFile::Url(_) => Ok(()), } } - - /// Save the file to the user's downloads (web) - #[cfg(target_arch = "wasm32")] - pub fn save(&self) -> Result<(), Error> { - crate::web_file::download(&self.name, &self.mime, &self.data) - } } /// Download and decrypt a file attachment to the user's device diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index f5892c18..6b166365 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -91,8 +91,6 @@ pub struct NostrRegistry { signer: UniversalSigner, /// Current user's public key current_user: Option, - /// Whether the stored credential needs a passphrase to unlock - passphrase_required: bool, /// Tasks for asynchronous operations tasks: Vec>>, } @@ -175,7 +173,6 @@ impl NostrRegistry { client, signer, current_user: None, - passphrase_required: false, tasks: vec![], } } @@ -209,7 +206,6 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); - this.passphrase_required = false; cx.emit(StateEvent::SignerChanged); cx.notify(); @@ -291,8 +287,7 @@ impl NostrRegistry { this.set_signer(signer, cx); })?; } else if content.starts_with("ncryptsec1") { - this.update(cx, |this, cx| { - this.passphrase_required = true; + this.update(cx, |_this, cx| { cx.emit(StateEvent::PassphraseRequired); })?; } else if content == "proxy" { @@ -492,9 +487,8 @@ impl NostrRegistry { /// Sign out and remove the stored credential pub fn sign_out(&mut self, cx: &mut Context) { self.current_user = None; - self.passphrase_required = false; - credentials::delete(cx).detach_and_log_err(cx); + cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::NoSigner); cx.notify(); @@ -519,7 +513,6 @@ impl NostrRegistry { credentials::write(cx, "extension", b"extension").detach_and_log_err(cx); this.signer.swap_inner(signer); this.current_user = Some(public_key); - this.passphrase_required = false; cx.emit(StateEvent::SignerChanged); cx.notify(); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 45e2aeea..064d433c 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -12,8 +12,5 @@ gpui.workspace = true gpui-base.workspace = true gpui-kit.workspace = true nostr-sdk.workspace = true -web-time = "1.1" smallvec.workspace = true log.workspace = true - -uuid = "1.10" diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 33d9b4fd..63443a7c 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + use gpui::{ App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, px, @@ -20,6 +23,10 @@ const MIN_FILLED: usize = 5; /// Seed for an avatar that has neither a picture nor a seed of its own. const FALLBACK_SEED: &str = "coop"; +/// Memoized seed-derived patterns; PixelAvatar is recreated on every frame. +static PATTERN_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + /// An avatar for a user or other entity. #[derive(IntoElement)] pub struct Avatar { @@ -93,7 +100,10 @@ impl RenderOnce for Avatar { .child(AvatarImage::new(url).size_full().rounded_full()) .into_any_element(), None => PixelAvatar { - seed: fnv1a(self.seed.unwrap_or_else(|| FALLBACK_SEED.into()).as_bytes()), + seed: self + .seed + .map(SharedString::from) + .unwrap_or_else(|| SharedString::new_static(FALLBACK_SEED)), side, style: StyleRefinement::default(), } @@ -114,7 +124,7 @@ impl RenderOnce for Avatar { /// A deterministic, offline pixel-art avatar. #[derive(IntoElement)] struct PixelAvatar { - seed: u64, + seed: SharedString, side: Pixels, style: StyleRefinement, } @@ -128,11 +138,11 @@ impl Styled for PixelAvatar { impl RenderOnce for PixelAvatar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let pattern = pattern(self.seed); + let AvatarPattern { hash, grid } = cached_pattern(&self.seed); let mut cells = Vec::new(); let main = Hsla { - h: self.seed as f32 / u64::MAX as f32, + h: hash as f32 / u64::MAX as f32, s: 0.6, l: if theme.is_dark() { 0.6 } else { 0.45 }, a: 1., @@ -152,7 +162,7 @@ impl RenderOnce for PixelAvatar { for row in 0..GRID_SIZE { for col in 0..GRID_SIZE { - let value = pattern[row * GRID_SIZE + col]; + let value = grid[row * GRID_SIZE + col]; if value != 0 { let color = if value == 2 { shade } else { main }; cells.push( @@ -186,6 +196,32 @@ impl RenderOnce for PixelAvatar { } } +/// The seed-derived data a PixelAvatar renders, theme-dependent styling is applied per render. +#[derive(Clone, Copy)] +struct AvatarPattern { + hash: u64, + grid: [u8; GRID_SIZE * GRID_SIZE], +} + +/// `seed`'s hash and pixel grid, computing and caching them on first use. +fn cached_pattern(seed: &str) -> AvatarPattern { + let mut cache = PATTERN_CACHE + .lock() + .unwrap_or_else(|error| error.into_inner()); + + if let Some(cached) = cache.get(seed) { + return *cached; + } + + let hash = fnv1a(seed.as_bytes()); + let value = AvatarPattern { + hash, + grid: pattern(hash), + }; + cache.insert(seed.to_string(), value); + value +} + /// The rendered side length of an avatar at `size`. fn side_length(size: Size) -> Pixels { match size { @@ -202,8 +238,6 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] { let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; let mut filled = 0usize; - // Only the inner rows and the inner left half are candidates; mirroring - // then keeps the art within the same inset, leaving the outer ring empty. let art_rows = GRID_SIZE - 2 * MARGIN; let art_columns = GRID_SIZE / 2 - MARGIN; diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index 89b124ce..c5f76177 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -25,7 +25,7 @@ type SharedClickListener = Rc; type DropdownBuilder = Rc) -> PopupMenu>; /// Messages from the same author within this window are grouped into one run. -pub const RUN_WINDOW_SECS: u64 = 300; +pub(crate) const RUN_WINDOW_SECS: u64 = 300; /// Whether a message starts a new run, given the previous message's author and unix time. pub fn opens_run(previous: Option<(T, u64)>, current: (T, u64)) -> bool { @@ -758,14 +758,19 @@ impl RenderOnce for MessageActions { /// A message's text revealed word by word, paced by the estimated speech duration. #[derive(IntoElement)] pub struct RevealText { - text: SharedString, + words: Vec, duration: Duration, } impl RevealText { + /// Create text that reveals its words over `duration`. pub fn new(text: impl Into, duration: Duration) -> Self { Self { - text: text.into(), + words: text + .into() + .split_whitespace() + .map(SharedString::from) + .collect(), duration, } } @@ -773,11 +778,7 @@ impl RevealText { impl RenderOnce for RevealText { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let words: Vec = self - .text - .split_whitespace() - .map(SharedString::from) - .collect(); + let words = self.words; let total = words.len().max(1) as f32; div().flex().flex_wrap().gap_x_1().with_animation( diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 8669b448..fcd07637 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -8,7 +8,7 @@ use person::Directory; /// A span of the source content, by byte range, that renders as `text` instead. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct InlineReplacement { +pub(crate) struct InlineReplacement { pub range: Range, pub text: SharedString, /// When set, the span renders as this image (NIP-30 custom emoji) @@ -44,7 +44,7 @@ pub struct RenderedText { } impl RenderedText { - pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self { + pub(crate) fn new(content: &str, replacements: &[InlineReplacement]) -> Self { let mut text = String::with_capacity(content.len()); let mut last = 0; diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index c33ef756..955556c9 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -52,7 +52,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } -struct DeviceNotifcation; +struct DeviceNotification; struct MsgRelayNotification; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -141,7 +141,7 @@ impl Workspace { one shown there before approving."; let note = Notification::new() - .id::() + .id::() .autohide(false) .title(format!("Wait for approval · {code}")) .message(MSG) @@ -155,7 +155,7 @@ impl Workspace { from the settings."; let note = Notification::new() - .id::() + .id::() .message(MSG) .with_type(NotificationType::Info) .action(|_this, _window, _cx| { @@ -165,7 +165,7 @@ impl Workspace { device.update(cx, |this, cx| { this.get_announcement(cx); }); - window.remove_notification::(cx); + window.remove_notification::(cx); }, ) }); @@ -174,7 +174,7 @@ impl Workspace { } DeviceEvent::Set => { let note = Notification::new() - .id::() + .id::() .message("Encryption Key has been set") .with_type(NotificationType::Success); @@ -231,14 +231,6 @@ impl Workspace { ::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 => {} }, )); diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index 1e6a3c57..e4979116 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -235,13 +235,14 @@ impl ContactListPanel { fn render_list_items(&mut self, cx: &mut Context) -> Vec { let mut items = Vec::new(); - for (ix, contact) in self.contacts.iter().enumerate() { + for contact in &self.contacts { let public_key = contact.public_key; + let hex = public_key.to_hex(); let profile = self.directory.read(cx).person(&public_key, cx); items.push( h_flex() - .id(ix) + .id(hex.clone()) .group("") .flex_1() .w_full() @@ -262,7 +263,7 @@ impl ContactListPanel { .child(profile.name()), ) .child( - Button::new("remove_{ix}") + Button::new(format!("remove-{hex}")) .icon(IconName::X) .xsmall() .ghost() diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 5807094a..c5d93d3e 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -213,17 +213,17 @@ impl GreeterPanel { } else { requests .iter() - .enumerate() - .map(|(index, (room, preview))| { + .map(|(room, preview)| { let room = room.clone(); let dock = dock.clone(); + let room_id = room.read(cx).id; let name = room.read(cx).display_name(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); let preview = preview.clone(); h_flex() - .id(format!("request-{index}")) + .id(format!("request-{room_id}")) .group("") .w_full() .h_9() @@ -280,7 +280,7 @@ impl GreeterPanel { ), ) .child( - Button::new(format!("response-{index}")) + Button::new(format!("response-{room_id}")) .label("Response") .xsmall() .ghost() @@ -332,17 +332,17 @@ impl GreeterPanel { let rows: Vec = self .follow_ups .iter() - .enumerate() - .map(|(index, (room, preview))| { + .map(|(room, preview)| { let room = room.clone(); let dock = dock.clone(); + let room_id = room.read(cx).id; let name = room.read(cx).display_name(cx); let seed = room.read(cx).display_image_seed(cx); let created_at = room.read(cx).created_at.to_ago(); let preview = preview.clone(); h_flex() - .id(format!("follow-up-{index}")) + .id(format!("follow-up-{room_id}")) .group("") .w_full() .h_9() @@ -399,7 +399,7 @@ impl GreeterPanel { ), ) .child( - Button::new(format!("reply-{index}")) + Button::new(format!("reply-{room_id}")) .label("Reply") .xsmall() .ghost() diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index 51d59380..11760413 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -34,11 +34,42 @@ impl ListSection { } } +/// Cheap snapshot of the cached rows' inputs, compared on every render; covers +/// inputs that never notify the sidebar (pin edits, room kind promotions, and +/// the visibility flags mutated across the sidebar's modules). +#[derive(Default, PartialEq)] +pub(super) struct RowsSignature { + contacts_open: bool, + requests_open: bool, + contacts_loaded: bool, + pins_open: bool, + groups_open: bool, + chats_open: bool, + pinned_rooms: Vec, + pinned_groups: Vec, + /// Kinds of the rooms held by the cache; a request can turn ongoing with + /// no event or registry notify the sidebar can hear + room_kinds: Vec<(u64, RoomKind)>, +} + +/// Read the id and kind of every room row held by the given cache. +fn room_signature(rows: &[SidebarRow], cx: &App) -> Vec<(u64, RoomKind)> { + rows.iter() + .filter_map(|row| match row { + SidebarRow::Room { room } => { + let room = room.read(cx); + Some((room.id, room.kind)) + } + _ => None, + }) + .collect() +} + impl Sidebar { pub(super) fn render_list(&mut self, cx: &mut Context) -> AnyElement { let requests_open = self.requests_open; let contacts_open = self.contacts_open && !requests_open; - let rows = Rc::new(self.rows_for(cx)); + let rows = self.cached_rows(cx); // Whether to show the create button for the contact picker let create_button = contacts_open && !self.selected_contacts.is_empty(); @@ -175,6 +206,31 @@ impl Sidebar { .into_any_element() } + /// Rebuild the rows only when a dirty source fired or the snapshot changed. + fn cached_rows(&mut self, cx: &mut Context) -> Rc> { + let mut signature = RowsSignature { + contacts_open: self.contacts_open, + requests_open: self.requests_open, + contacts_loaded: self.contacts.is_some(), + pins_open: self.pins_open, + groups_open: self.is_groups_open(cx), + chats_open: self.is_chats_open(cx), + pinned_rooms: AppSettings::get_pinned_rooms(cx), + pinned_groups: AppSettings::get_pinned_groups(cx), + room_kinds: room_signature(&self.rows, cx), + }; + + if self.rows_dirty || self.rows_signature != signature { + self.rows = Rc::new(self.rows_for(cx)); + self.rows_dirty = false; + // Describe the rebuilt cache, so the next render compares apples to apples + signature.room_kinds = room_signature(&self.rows, cx); + } + + self.rows_signature = signature; + Rc::clone(&self.rows) + } + fn rows_for(&self, cx: &App) -> Vec { if self.contacts_open { return self.contacts_rows(); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index e5018fb8..8c490c3f 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::rc::Rc; use anyhow::Error; #[cfg(not(target_arch = "wasm32"))] @@ -18,9 +19,11 @@ use gpui_kit::component::notification::Notification; use gpui_kit::component::progress::ProgressCircle; use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex}; use instant::Duration; +use list::RowsSignature; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use person::{Directory, PersonRegistry}; +use rows::SidebarRow; pub(crate) use rows::open_room; use search::SearchResult; use smallvec::{SmallVec, smallvec}; @@ -90,6 +93,12 @@ pub struct Sidebar { chat_progress: f32, /// Simulated web of trust sync progress sync_progress: f32, + /// Cached main list rows; rebuilt only when invalidation is signaled + rows: Rc>, + /// Snapshot of the cached rows' inputs, compared on every render + rows_signature: RowsSignature, + /// Whether the rows changed through a source the snapshot cannot see + rows_dirty: bool, /// The updater facts the header renders, mirrored so updater churn cannot invalidate the cached sidebar #[cfg(not(target_arch = "wasm32"))] updater_display: Entity>, @@ -118,10 +127,12 @@ impl Sidebar { match event { ChatEvent::Ping => { this.new_requests = true; + this.rows_dirty = true; cx.notify(); } ChatEvent::Loaded => { this.chat_progress = 0.0; + this.rows_dirty = true; cx.notify(); } _ => {} @@ -130,9 +141,10 @@ impl Sidebar { ); subscriptions.push( - cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { + cx.subscribe_in(&groups, window, |this, _, event, window, cx| { match event { GroupsEvent::Updated => { + this.rows_dirty = true; // TODO: we must not trigger re-render directly like this cx.notify(); } @@ -145,11 +157,24 @@ impl Sidebar { } => { window.push_notification(Notification::warning(warning.clone()), cx); } + // New groups can arrive without `Updated` and without a + // registry notify, so only the event itself can dirty the cache + GroupsEvent::Synced | GroupsEvent::Created { .. } => { + this.rows_dirty = true; + } _ => {} }; }), ); + subscriptions.push(cx.observe(&chat, |this, _, _| { + this.rows_dirty = true; + })); + + subscriptions.push(cx.observe(&groups, |this, _, _| { + this.rows_dirty = true; + })); + subscriptions.push( cx.subscribe_in( &nostr, @@ -260,6 +285,9 @@ impl Sidebar { global_task: None, chat_progress: 0.0, sync_progress: 0.0, + rows: Rc::default(), + rows_signature: RowsSignature::default(), + rows_dirty: true, #[cfg(not(target_arch = "wasm32"))] updater_display, tasks, diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index e5886064..59d61cd6 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -105,10 +105,7 @@ impl Sidebar { rows.get(range.clone()) .into_iter() .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - + .map(|row| { let element = match row { SidebarRow::Section(section) => { let section = *section; @@ -181,7 +178,7 @@ impl Sidebar { let dock = self.dock.clone(); let room = room.clone(); - let nav = Nav::new(index) + let nav = Nav::new(id) .label(name) .text_sm() .when(!hide_avatar, |this| { @@ -220,7 +217,7 @@ impl Sidebar { }); div() - .id(index) + .id(id) .w_full() .child(nav) .context_menu(move |menu, _window, _cx| { @@ -251,7 +248,7 @@ impl Sidebar { let sidebar = cx.entity().downgrade(); let public_key = public_key.to_owned(); - Nav::new(index) + Nav::new(public_key.to_hex()) .label(person.name()) .text_sm() .when(!hide_avatar, |this| { @@ -275,7 +272,7 @@ impl Sidebar { let person = self.directory.read(cx).person(public_key, cx); let public_key = public_key.to_owned(); - Nav::new(index) + Nav::new(public_key.to_hex()) .label(person.name()) .text_sm() .when(!hide_avatar, |this| { @@ -308,7 +305,7 @@ impl Sidebar { .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) .map(SharedString::from); - let nav = Nav::new(index) + let nav = Nav::new(tag.clone()) .label(name) .text_sm() .when(!hide_avatar, |this| { @@ -347,7 +344,7 @@ impl Sidebar { }); div() - .id(index) + .id(tag.clone()) .w_full() .child(nav) .context_menu(move |menu, _window, _cx| { @@ -383,8 +380,15 @@ impl Sidebar { let dock = self.dock.clone(); let room = hit.room.clone(); let content = hit.content.clone(); + // Several hits can share a room, so the id digests the whole hit + let hit_id = common::stable_id_u64(&[ + hit.room.read(cx).id.to_string().as_str(), + hit.author.to_string().as_str(), + hit.created_at.as_secs().to_string().as_str(), + hit.content.as_ref(), + ]); - Nav::new(index) + Nav::new(hit_id) .label(content) .text_sm() .font_medium() diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index f146fe56..6a00ea28 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -34,7 +34,6 @@ info_plist_path = "resources/Info.plist" [dependencies] assets = { path = "../crates/assets" } workspace = { path = "../crates/workspace" } -ui = { path = "../crates/ui" } state = { path = "../crates/state" } nip4e = { path = "../crates/nip4e" } nip60 = { path = "../crates/nip60" } diff --git a/docs/audit-stable-release.md b/docs/audit-stable-release.md index 82cdcb60..61a91108 100644 --- a/docs/audit-stable-release.md +++ b/docs/audit-stable-release.md @@ -5,7 +5,7 @@ Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire works > **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been > implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing). > See §9 for the per-fix log. The P1 list (committed as `fd802a88` base) has also been processed — -> see §10. +> see §10. P2 hygiene is done too (S1/S2 excluded by request) — see §11. ## 1. Scope and Method @@ -994,3 +994,66 @@ clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−2 the field; NIP-05 shows "could not be verified" on network failure instead of "does not match"; undecryptable messages and room load/connect failures produce visible notifications. - First message-send/receive after the room-id change republishes `r` tags under the new ids. + +## 11. P2 Remediation Log (2026-11-12) + +P2 hygiene processed with S1 (task-handle containers) and S2 (silent error discards) excluded by +request. Validation after the full batch: `cargo clippy --workspace --all-targets` zero warnings, +`cargo fmt --all --check` clean, `cargo test --workspace` 38 passed / 0 failed. + +1. **Domain-keyed element ids (S6)** — sidebar rows use `Room.id` / group `cache_tag` / pubkey + hex / compound message-hit digests instead of list indexes (`rows.rs`, `nav.rs` untouched); + contact-list rows and remove buttons key on pubkey hex; greeter sections key on + `request-/response-/follow-up-/reply-{room_id}`; nip29_ui content `TextView`, `MessageRow`, + and `ReplyPreview` key on event-id hex with a `reply-` namespace (traced into gpui-base: + `TextViewState`'s key is the whole element-id stack, so the row container id had to change + too — this also kills the prepend-triggered markdown re-parse jank). Uniqueness arguments per + list are in the work log; `chat_ui`'s identical index-keyed row pattern remains as a noted + follow-up (audit S3). +2. **Unused dependencies removed** (each grep-verified before removal): `smol` (chat), `person` + (nip29), `tokio` (nip60 — `gpui_tokio` keeps a superset), `ui` (desktop + web — plain path + deps, no feature unification needed; ui stays in the graph via chat_ui/nip29_ui/nip4e/ + nip60_ui/workspace), `anyhow` (assets), `web-time` + `uuid` (ui). Cargo.lock rewritten by + cargo only. +3. **Dead code removed** (each grep-verified): `state::passphrase_required` field + writes (UI + uses `StateEvent::PassphraseRequired`); wasm-only `DownloadedFile::save`; `Person`'s unused + `PartialEq/Eq/PartialOrd/Ord/Hash` impls (+ their imports); nip60's unreachable + `melt()`/`send_token()` API with `Job::Melt`/`Job::SendToken`, `Outcome::Melted`/`Sent`, + `WalletEvent::TokenCreated`, its emission, and workspace's misleading "copy it from the wallet + panel" notification arm (plus the matching no-op arm in nip60_ui's exhaustive match). + `dock::find_panel` dead machinery and `skin.rs`'s write-once flag were already removed in P1. +4. **Avatar memoization** (`ui/src/avatar.rs`): `AvatarPattern { hash, grid: [u8; 64] }` cached + per seed in a `LazyLock>>`; repeat renders skip FNV-1a + + xorshift and only rebuild the divs; theme colors stay computed at paint time so output is + bit-identical. The per-frame fallback-seed `String` is gone (`SharedString::new_static`). +5. **Sidebar row-model caching** (`sidebar/mod.rs`, `list.rs`): rows cached as + `Rc>` behind two invalidation inputs — a `rows_dirty` flag set by chat/groups + events and two new entity observes (catching `track_room`/`sort`/`reset`, which notify the + registries without emitting events), and a cheap `RowsSignature` snapshot (catching room-kind + promotion Request→Ongoing, which notifies only the Room entity, and pin/unpin, which mutates + `AppSettings`' inner entity and notifies nothing the sidebar observes — both verified real + hazards a flag-only design would have missed). Progress ticks, typing, and hover are verified + cache-neutral; `find_input` verified as not a `rows_for` input. `group_view` member rendering + left uncached deliberately: `GroupEvent::Updated` is over-emitted and not provably complete, + so no safe cache exists without a membership-version counter. +6. **Rename** `DeviceNotifcation` → `DeviceNotification` (workspace/src/lib.rs, 5 sites). Note: + nip4e defines its own correctly-spelled marker type, so the two crates' notification ids were + already distinct; unifying them (so removals cross-dismiss) is a deliberate behavior change + left for later. +7. **ui API tightening + RevealText**: `InlineReplacement`, `RenderedText::new`, and + `RUN_WINDOW_SECS` → `pub(crate)` (verified unused externally); `RevealText::new` doc added; + word-split moved from every animation frame into `new`. +8. **Summary-comment cleanup**: the five cited chat_ui "Define …" comments removed; all + why-comments (including the O(1)-lookup one) kept. Remaining candidates flagged + (`// Get room id and name`, `// Define attachments and replies_to entities`) left untouched + per scope. + +### P2 notes + +- nip60's send/melt removal means there is currently no UI to melt to a lightning invoice or + export a token string; both were unreachable before. Re-add together with their UI in a future + feature change. +- Element ids: two known residuals documented (inbox.rs contact p-tag dedupe; + `HashSet` equality includes relay/alias, so the same pubkey with a different relay + could render twice in the contact picker). +- S1/S2 (task-handle containers, `.ok()`/`let _ =` sweeps) remain open by request. diff --git a/web/Cargo.toml b/web/Cargo.toml index 6d64dd38..8d778c44 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] assets = { path = "../crates/assets" } workspace = { path = "../crates/workspace" } -ui = { path = "../crates/ui" } state = { path = "../crates/state" } nip4e = { path = "../crates/nip4e" } chat = { path = "../crates/chat" }