add trash panel and re-tag stale cached rooms
This commit is contained in:
+57
-10
@@ -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<Self>) {
|
||||
pub fn reprocess_trash(&mut self, cx: &mut Context<Self>) {
|
||||
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<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.
|
||||
pub fn rumor_seen_on(&self, id: &EventId) -> Option<HashSet<RelayUrl>> {
|
||||
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<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
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,6 +57,7 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
pub mod trash;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<SidebarRow> {
|
||||
ChatRegistry::global(cx)
|
||||
.read(cx)
|
||||
let chat = ChatRegistry::global(cx).read(cx);
|
||||
let mut rows: Vec<SidebarRow> = 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user