add trash panel and re-tag stale cached rooms

This commit is contained in:
2026-11-13 21:22:42 +07:00
parent 3484c2c71b
commit eef4acc91f
8 changed files with 300 additions and 21 deletions
+57 -10
View File
@@ -397,6 +397,7 @@ impl ChatRegistry {
if !errors.is_empty() { if !errors.is_empty() {
let mut newly_trashed = 0; let mut newly_trashed = 0;
trash.update(cx, |trash, cx| { trash.update(cx, |trash, cx| {
newly_trashed = errors newly_trashed = errors
.iter() .iter()
@@ -405,16 +406,9 @@ impl ChatRegistry {
cx.notify(); 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 { 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| { 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. /// Retry unwrapping trashed gift wraps with the newly available encryption key.
fn reprocess_trash(&mut self, cx: &mut Context<Self>) { pub fn reprocess_trash(&mut self, 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();
@@ -632,6 +626,32 @@ impl ChatRegistry {
.count() .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<TrashedMessage> {
let mut messages: Vec<TrashedMessage> = 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. /// Get the relays that have seen a given rumor id.
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> { pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
self.event_map self.event_map
@@ -854,8 +874,9 @@ impl ChatRegistry {
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]); .custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
let events = client.database().query(filter).await?; let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
let mut stale: Vec<(EventId, UnsignedEvent)> = Vec::new();
// Keep the single-threaded web event loop responsive during large rescans // Keep the single-threaded web event loop responsive during large rescans
let mut last_yield = Instant::now(); let mut last_yield = Instant::now();
@@ -869,15 +890,41 @@ impl ChatRegistry {
&& rumor.tags.public_keys().next().is_some() && rumor.tags.public_keys().next().is_some()
{ {
search.write().unwrap().insert(&rumor); search.write().unwrap().insert(&rumor);
if rumor.pubkey != public_key if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key) && !rumor.tags.public_keys().any(|k| k == public_key)
{ {
continue; 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); 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()); let mut rooms = HashSet::with_capacity(grouped.len());
for (_id, messages) in grouped.into_iter() { for (_id, messages) in grouped.into_iter() {
+11
View File
@@ -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)] #[derive(Debug, Clone)]
pub struct Mention { pub struct Mention {
pub public_key: PublicKey, pub public_key: PublicKey,
+1
View File
@@ -57,6 +57,7 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
mod actions; mod actions;
mod file; mod file;
pub mod trash;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> { pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx)) cx.new(|cx| ChatPanel::new(room, window, cx))
+179
View File
@@ -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<TrashPanel> {
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 {
Self {
focus_handle: cx.focus_handle(),
}
}
fn render_entry(&self, message: &TrashedMessage, cx: &Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<PanelEvent> 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<Self>) -> 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<usize>,
messages: &[TrashedMessage],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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()
}
}
+10 -4
View File
@@ -173,14 +173,20 @@ impl Sidebar {
.collect() .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<SidebarRow> { pub(super) fn requests_rows(&self, cx: &App) -> Vec<SidebarRow> {
ChatRegistry::global(cx) let chat = ChatRegistry::global(cx).read(cx);
.read(cx) let mut rows: Vec<SidebarRow> = chat
.rooms(&RoomKind::Request, cx) .rooms(&RoomKind::Request, cx)
.into_iter() .into_iter()
.map(|room| SidebarRow::Room { room }) .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. /// The overlay with the create button and selection count.
+7 -3
View File
@@ -50,6 +50,8 @@ pub(super) struct RowsSignature {
/// Kinds of the rooms held by the cache; a request can turn ongoing with /// Kinds of the rooms held by the cache; a request can turn ongoing with
/// no event or registry notify the sidebar can hear /// no event or registry notify the sidebar can hear
room_kinds: Vec<(u64, RoomKind)>, 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. /// Read the id and kind of every room row held by the given cache.
@@ -81,9 +83,10 @@ impl Sidebar {
("sidebar-list", None) ("sidebar-list", None)
}; };
let request_count = ChatRegistry::global(cx) let request_count = {
.read(cx) let chat = ChatRegistry::global(cx).read(cx);
.count(&RoomKind::Request, cx); chat.count(&RoomKind::Request, cx) + chat.trashed_count(cx)
};
v_flex() v_flex()
.size_full() .size_full()
@@ -218,6 +221,7 @@ impl Sidebar {
pinned_rooms: AppSettings::get_pinned_rooms(cx), pinned_rooms: AppSettings::get_pinned_rooms(cx),
pinned_groups: AppSettings::get_pinned_groups(cx), pinned_groups: AppSettings::get_pinned_groups(cx),
room_kinds: room_signature(&self.rows, 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 { if self.rows_dirty || self.rows_signature != signature {
+31 -1
View File
@@ -1,6 +1,6 @@
use std::ops::Range; use std::ops::Range;
use chat::{Room, RoomKind}; use chat::{ChatRegistry, Room, RoomKind};
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -88,6 +88,8 @@ pub(super) enum SidebarRow {
Message { Message {
hit: MessageHit, hit: MessageHit,
}, },
/// Entry point to the trash panel, shown under requests
Trash,
/// A non-interactive label, such as a search results section. /// A non-interactive label, such as a search results section.
Heading(&'static str), Heading(&'static str),
Empty(&'static str), Empty(&'static str),
@@ -411,6 +413,34 @@ impl Sidebar {
}) })
.into_any_element() .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. // Keep a small gap between rows so selected rows read as pills.
div().pb_0p5().child(element).into_any_element() div().pb_0p5().child(element).into_any_element()
+4 -3
View File
@@ -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 (`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 `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 `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 values change** (new hash domain) — follow-up fix after first run: `Room::get_messages`
re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id filters cached history by the persisted `r` tag, so pre-change rows were unreachable
fields are u64-typed; migrating to `String` ids is mechanical. (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 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 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 (success clears only the matching request; failure rebuilds the toast with a fresh enabled