From eef4acc91ff4e23eb9331a2a407c4ab15a84f005 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 13 Nov 2026 21:22:42 +0700 Subject: [PATCH] add trash panel and re-tag stale cached rooms --- crates/chat/src/lib.rs | 67 ++++++++-- crates/chat/src/message.rs | 11 ++ crates/chat_ui/src/lib.rs | 1 + crates/chat_ui/src/trash.rs | 179 ++++++++++++++++++++++++++ crates/workspace/src/sidebar/inbox.rs | 14 +- crates/workspace/src/sidebar/list.rs | 10 +- crates/workspace/src/sidebar/rows.rs | 32 ++++- docs/audit-stable-release.md | 7 +- 8 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 crates/chat_ui/src/trash.rs diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 18da14f8..1f2f64b7 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -397,6 +397,7 @@ impl ChatRegistry { if !errors.is_empty() { let mut newly_trashed = 0; + trash.update(cx, |trash, cx| { newly_trashed = errors .iter() @@ -405,16 +406,9 @@ impl ChatRegistry { cx.notify(); })?; - // Only newly trashed messages emit an error event so repeated - // reprocess attempts of the same message don't re-alert the user if newly_trashed > 0 { - let reason = if newly_trashed == 1 { - "A message could not be decrypted".to_string() - } else { - format!("{newly_trashed} messages could not be decrypted") - }; this.update(cx, |_, cx| { - cx.emit(ChatEvent::Error(reason)); + cx.emit(ChatEvent::Ping); })?; } } @@ -556,7 +550,7 @@ impl ChatRegistry { } /// Retry unwrapping trashed gift wraps with the newly available encryption key. - fn reprocess_trash(&mut self, cx: &mut Context) { + pub fn reprocess_trash(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); @@ -632,6 +626,32 @@ impl ChatRegistry { .count() } + /// How many messages are waiting in the trash. + pub fn trashed_count(&self, cx: &App) -> usize { + self.trash.read(cx).len() + } + + /// The messages that could not be decrypted, newest first. + pub fn trashed(&self, cx: &App) -> Vec { + let mut messages: Vec = self + .trash + .read(cx) + .iter() + .filter_map(|message| { + let event = Event::from_json(message.raw_event.as_str()).ok()?; + + Some(TrashedMessage { + id: event.id, + created_at: event.created_at, + reason: message.reason.clone(), + }) + }) + .collect(); + + messages.sort_by_key(|message| std::cmp::Reverse(message.created_at)); + messages + } + /// Get the relays that have seen a given rumor id. pub fn rumor_seen_on(&self, id: &EventId) -> Option> { self.event_map @@ -854,8 +874,9 @@ impl ChatRegistry { .custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]); let events = client.database().query(filter).await?; - let mut grouped: HashMap> = HashMap::new(); + let mut grouped: HashMap> = HashMap::new(); + let mut stale: Vec<(EventId, UnsignedEvent)> = Vec::new(); // Keep the single-threaded web event loop responsive during large rescans let mut last_yield = Instant::now(); @@ -869,15 +890,41 @@ impl ChatRegistry { && rumor.tags.public_keys().next().is_some() { search.write().unwrap().insert(&rumor); + if rumor.pubkey != public_key && !rumor.tags.public_keys().any(|k| k == public_key) { continue; } + + let room_id = rumor.uniq_id().to_string(); + let stored_room = raw + .tags + .iter() + .find(|tag| tag.kind() == "r") + .and_then(|tag| tag.content()); + + if stored_room.is_none_or(|stored| stored != room_id) + && let Some(gift_wrap) = raw + .tags + .iter() + .find(|tag| tag.kind() == "d") + .and_then(|tag| tag.content()) + .and_then(|value| EventId::from_hex(value).ok()) + { + stale.push((gift_wrap, rumor.clone())); + } + grouped.entry(rumor.uniq_id()).or_default().push(rumor); } } + for (gift_wrap, rumor) in stale { + if let Err(error) = set_rumor(&client, gift_wrap, &rumor).await { + log::warn!("Failed to re-tag a cached message: {error:#}"); + } + } + let mut rooms = HashSet::with_capacity(grouped.len()); for (_id, messages) in grouped.into_iter() { diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index ce1c1552..c00196d8 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -219,6 +219,17 @@ impl FailedMessage { } } +/// A message that could not be decrypted, as surfaced to the UI. +#[derive(Debug, Clone)] +pub struct TrashedMessage { + /// The undecryptable gift wrap's id, stable across renders + pub id: EventId, + /// When the gift wrap was received + pub created_at: Timestamp, + /// Why it could not be decrypted + pub reason: SharedString, +} + #[derive(Debug, Clone)] pub struct Mention { pub public_key: PublicKey, diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 76d3e40b..925e4f4b 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -57,6 +57,7 @@ static CUSTOM_EMOJI_RE: LazyLock = mod actions; mod file; +pub mod trash; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) diff --git a/crates/chat_ui/src/trash.rs b/crates/chat_ui/src/trash.rs new file mode 100644 index 00000000..d1c316a1 --- /dev/null +++ b/crates/chat_ui/src/trash.rs @@ -0,0 +1,179 @@ +use std::rc::Rc; + +use chat::{ChatRegistry, TrashedMessage}; +use common::TimestampExt; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext, Context, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, Styled, Window, div, uniform_list, +}; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::dock::{Panel, PanelEvent}; +use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, h_flex, v_flex}; +use ui::IconName; + +/// Initialize the trash panel. +pub fn init(window: &mut Window, cx: &mut App) -> gpui::Entity { + cx.new(|cx| TrashPanel::new(window, cx)) +} + +/// The messages that could not be decrypted, shown like a chat room. +pub struct TrashPanel { + focus_handle: FocusHandle, +} + +impl TrashPanel { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + + fn render_entry(&self, message: &TrashedMessage, cx: &Context) -> AnyElement { + h_flex() + .w_full() + .gap_2() + .items_center() + .px_2() + .py_1p5() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary.opacity(0.5)) + .child( + Icon::new(IconName::Warning) + .small() + .text_color(cx.theme().warning), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child(div().text_sm().child("Could not be decrypted")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(message.reason.clone()), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(message.created_at.to_ago()), + ) + .into_any_element() + } + + fn render_empty(&self, cx: &Context) -> AnyElement { + div() + .text_color(cx.theme().muted_foreground) + .child("Trash is empty") + .into_any_element() + } +} + +impl gpui_kit::component::dock::BasePanel for TrashPanel { + fn panel_name(&self) -> &'static str { + "TrashPanel" + } +} + +impl Panel for TrashPanel { + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Warning) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child("Trash") + .into_any_element() + } +} + +impl EventEmitter for TrashPanel {} + +impl Focusable for TrashPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TrashPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let messages = Rc::new(ChatRegistry::global(cx).read(cx).trashed(cx)); + let count = messages.len(); + + v_flex() + .size_full() + .p_3() + .gap_3() + .text_xs() + .child( + h_flex() + .w_full() + .gap_2() + .items_center() + .justify_between() + .child( + div() + .flex_1() + .text_color(cx.theme().muted_foreground) + .child("Messages that could not be decrypted. New encryption keys may make them readable."), + ) + .child( + Button::new("trash-retry") + .label("Try again") + .ghost() + .small() + .disabled(count == 0) + .on_click(|_, _window, cx| { + ChatRegistry::global(cx).update(cx, |registry, cx| { + registry.reprocess_trash(cx); + }); + }), + ), + ) + .when(count == 0, |this| this.child(self.render_empty(cx))) + .when(count > 0, |this| { + this.child( + uniform_list( + "trash-list", + count, + cx.processor(move |this, range, _window, cx| { + this.render_entries(range, messages.as_slice(), cx) + }), + ) + .min_h_0() + .flex_1(), + ) + }) + } +} + +impl TrashPanel { + fn render_entries( + &self, + range: std::ops::Range, + messages: &[TrashedMessage], + cx: &mut Context, + ) -> Vec { + messages + .get(range) + .map(|slice| { + slice + .iter() + .map(|message| { + // Keep a small gap between entries so they read as separate rows + div() + .pb_1p5() + .child(self.render_entry(message, cx)) + .into_any_element() + }) + .collect() + }) + .unwrap_or_default() + } +} diff --git a/crates/workspace/src/sidebar/inbox.rs b/crates/workspace/src/sidebar/inbox.rs index 1e878e51..56c66145 100644 --- a/crates/workspace/src/sidebar/inbox.rs +++ b/crates/workspace/src/sidebar/inbox.rs @@ -173,14 +173,20 @@ impl Sidebar { .collect() } - /// The chat requests awaiting a response. + /// The chat requests awaiting a response, followed by the trash entry when + /// it holds messages that could not be decrypted. pub(super) fn requests_rows(&self, cx: &App) -> Vec { - ChatRegistry::global(cx) - .read(cx) + let chat = ChatRegistry::global(cx).read(cx); + let mut rows: Vec = chat .rooms(&RoomKind::Request, cx) .into_iter() .map(|room| SidebarRow::Room { room }) - .collect() + .collect(); + + if chat.trashed_count(cx) > 0 { + rows.push(SidebarRow::Trash); + } + rows } /// The overlay with the create button and selection count. diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index 11760413..bb7f561f 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -50,6 +50,8 @@ pub(super) struct RowsSignature { /// 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)>, + /// Size of the undecryptable-messages trash, which no notify reaches + trashed: usize, } /// Read the id and kind of every room row held by the given cache. @@ -81,9 +83,10 @@ impl Sidebar { ("sidebar-list", None) }; - let request_count = ChatRegistry::global(cx) - .read(cx) - .count(&RoomKind::Request, cx); + let request_count = { + let chat = ChatRegistry::global(cx).read(cx); + chat.count(&RoomKind::Request, cx) + chat.trashed_count(cx) + }; v_flex() .size_full() @@ -218,6 +221,7 @@ impl Sidebar { pinned_rooms: AppSettings::get_pinned_rooms(cx), pinned_groups: AppSettings::get_pinned_groups(cx), room_kinds: room_signature(&self.rows, cx), + trashed: ChatRegistry::global(cx).read(cx).trashed_count(cx), }; if self.rows_dirty || self.rows_signature != signature { diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index 59d61cd6..efdd0cad 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use chat::{Room, RoomKind}; +use chat::{ChatRegistry, Room, RoomKind}; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ @@ -88,6 +88,8 @@ pub(super) enum SidebarRow { Message { hit: MessageHit, }, + /// Entry point to the trash panel, shown under requests + Trash, /// A non-interactive label, such as a search results section. Heading(&'static str), Empty(&'static str), @@ -411,6 +413,34 @@ impl Sidebar { }) .into_any_element() } + SidebarRow::Trash => { + let count = ChatRegistry::global(cx).read(cx).trashed_count(cx); + let dock = self.dock.clone(); + + Nav::new("sidebar-trash") + .label("Trash") + .text_sm() + .when(!hide_avatar, |this| { + this.prefix(Avatar::new(None).seed("trash").small()) + }) + .suffix( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(count.to_string())), + ) + .on_click(move |_event, window, cx| { + let panel = chat_ui::trash::init(window, cx); + crate::dock::add_panel_to( + &dock, + PanelHandle::new(panel), + DockPlacement::Center, + window, + cx, + ); + }) + .into_any_element() + } }; // Keep a small gap between rows so selected rows read as pills. div().pb_0p5().child(element).into_any_element() diff --git a/docs/audit-stable-release.md b/docs/audit-stable-release.md index 32a8c678..1a99085a 100644 --- a/docs/audit-stable-release.md +++ b/docs/audit-stable-release.md @@ -966,9 +966,10 @@ clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−2 (`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and `UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's `GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id - values change** (new hash domain) — existing cached groupings regroup per session as rumors - re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id - fields are u64-typed; migrating to `String` ids is mechanical. + values change** (new hash domain) — follow-up fix after first run: `Room::get_messages` + filters cached history by the persisted `r` tag, so pre-change rows were unreachable + (empty message lists); `query_chat_rooms` now compares each row's `r` tag against the + freshly computed id during its startup scan and re-tags stale rows, restoring history. 11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the device secret can no longer be double-sent), toast lifecycle is correct on both outcomes (success clears only the matching request; failure rebuilds the toast with a fresh enabled