From e63e58c1252a9de8974ec52c7c2bd398cbc1f451 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 11 Sep 2026 09:35:38 +0700 Subject: [PATCH] add inbox view --- crates/signed_state/src/backend.rs | 134 ++--- crates/signed_state/src/inbox.rs | 357 ++++++++----- crates/signed_state/src/lib.rs | 2 +- crates/workspace/src/views/inbox.rs | 610 ++++++++++++++++++++++ crates/workspace/src/views/mod.rs | 2 + crates/workspace/src/views/sidebar/mod.rs | 40 +- docs/inbox-plan.md | 320 +++++++----- 7 files changed, 1119 insertions(+), 346 deletions(-) create mode 100644 crates/workspace/src/views/inbox.rs diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 1d8cbc8..f1722d8 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -34,10 +34,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; /// Delay the notification pump waits for more events before emitting a batch. -/// -/// A negentropy sync can deliver hundreds of events in a burst; batching -/// them here means every subscriber debounces the burst once, not once per -/// subscriber. const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] @@ -79,9 +75,6 @@ impl BackendEvent { } } -/// The global backend entity. -/// -/// Owns the nostr client, the signer, the notification pump and the inbox. pub struct Backend { client: Client, signer: UniversalSigner, @@ -162,9 +155,9 @@ impl Backend { // Collect and emit the collected events. let batch = std::mem::take(&mut pending); - if let Err(e) = this.update(cx, |this, cx| { - this.emit(BackendEvent::NostrUpdate(batch), cx) - }) { + if let Err(e) = + this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch))) + { log::warn!("failed to emit nostr update: {e}"); } } @@ -221,9 +214,7 @@ impl Backend { })?; } Err(e) => { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } Ok::<(), Error>(()) @@ -231,14 +222,13 @@ impl Backend { notify_task.detach(); } - /// Restore the saved session from the keyring. + /// Restore the saved session from the Keyring. /// - /// Emits [`BackendEvent::SignerRequired`] when no credential is stored. - /// - /// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. + /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored. + /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { - self.emit(BackendEvent::SignerRequired, cx); + cx.emit(BackendEvent::SignerRequired); return; } @@ -248,7 +238,7 @@ impl Backend { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { - this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?; return Ok(()); } }; @@ -272,10 +262,10 @@ impl Backend { // A passphrase is required to decrypt it before the session can resume. this.update(cx, |this, cx| { this.passphrase_required = true; - this.emit(BackendEvent::PassphraseRequired, cx); + cx.emit(BackendEvent::PassphraseRequired); })?; } else { - this.update(cx, |this, cx| this.emit(BackendEvent::SignerRequired, cx))?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?; } Ok::<_, Error>(()) @@ -283,9 +273,9 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); - this.emit(BackendEvent::SignerRequired, cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); + cx.emit(BackendEvent::SignerRequired); })?; } @@ -369,8 +359,10 @@ impl Backend { this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); - this.emit(BackendEvent::SignerChanged, cx); + + cx.emit(BackendEvent::SignerChanged); this.sync_inbox(cx); + cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ @@ -978,7 +970,7 @@ impl Backend { } else if credential.starts_with("bunker://") { self.login_with_bunker(credential, cx); } else { - self.emit(BackendEvent::error("Unsupported credential."), cx); + cx.emit(BackendEvent::error("Unsupported credential.")); } } @@ -996,7 +988,7 @@ impl Backend { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), Err(e) => { - self.emit(BackendEvent::error(e.to_string()), cx); + cx.emit(BackendEvent::error(e.to_string())); return; } }; @@ -1007,9 +999,7 @@ impl Backend { let task: Task> = cx.spawn(async move |this, cx| { if let Err(e) = write.await { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; @@ -1025,7 +1015,7 @@ impl Backend { let connect_uri = match NostrConnectUri::parse(&uri_string) { Ok(uri) => uri, Err(e) => { - self.emit(BackendEvent::error(e.to_string()), cx); + cx.emit(BackendEvent::error(e.to_string())); return; } }; @@ -1055,9 +1045,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1076,8 +1064,8 @@ impl Backend { this.signer.swap_inner(Keys::generate()); this.current_user = None; this.passphrase_required = false; - this.emit(BackendEvent::SignerChanged, cx); - this.emit(BackendEvent::SignerRequired, cx); + cx.emit(BackendEvent::SignerChanged); + cx.emit(BackendEvent::SignerRequired); this.sync_inbox(cx); cx.notify(); })?; @@ -1109,9 +1097,7 @@ impl Backend { .await; if let Err(e) = result { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx) - })?; + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) @@ -1155,31 +1141,15 @@ impl Backend { /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { - self.emit(BackendEvent::error(message), cx); - } - - /// Update the inbox, then emit `event` to the other stores. - fn emit(&self, event: BackendEvent, cx: &mut Context) { - let inbox = self.inbox.downgrade(); - let inbox_event = event.clone(); - - cx.defer(move |cx| { - if let Err(error) = inbox.update(cx, |inbox, cx| { - inbox.handle_backend_event(&inbox_event, cx); - }) { - log::warn!("inbox dropped before handling backend event: {error}"); - } - }); - - cx.emit(event); + cx.emit(BackendEvent::error(message)); } /// Attach the inbox to the current signer and activate or clear it. - /// - /// The inbox's own update is deferred because activating reads `Backend`, - /// which every call site is in the middle of updating. fn sync_inbox(&mut self, cx: &mut Context) { - if let Some(me) = self.current_user { + let client = self.client.clone(); + let me = self.current_user; + + if let Some(me) = me { self.subscribe_bootstrap(filters::notifications(me), cx); self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); @@ -1197,20 +1167,9 @@ impl Backend { } } - let inbox = self.inbox.downgrade(); - - cx.defer(move |cx| { - let updated = inbox.update(cx, |inbox, cx| { - if Backend::global(cx).read(cx).current_user().is_some() { - inbox.activate(cx); - } else { - inbox.reset(cx); - } - }); - - if let Err(error) = updated { - log::warn!("inbox dropped before syncing with the signer: {error}"); - } + self.inbox.update(cx, |inbox, cx| match me { + Some(me) => inbox.activate(me, client, cx), + None => inbox.reset(cx), }); } @@ -1235,14 +1194,14 @@ impl Backend { this.current_user = Some(public_key); this.passphrase_required = false; this.bootstrap_user(public_key, cx); - this.emit(BackendEvent::SignerChanged, cx); + cx.emit(BackendEvent::SignerChanged); this.sync_inbox(cx); cx.notify(); })?; } Err(e) => { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); })?; } } @@ -1279,8 +1238,8 @@ impl Backend { cx.spawn(async move |this, cx| { if let Err(e) = fetch.await { - this.update(cx, |this, cx| { - this.emit(BackendEvent::error(e.to_string()), cx); + this.update(cx, |_this, cx| { + cx.emit(BackendEvent::error(e.to_string())); })?; } Ok::<(), Error>(()) @@ -1308,13 +1267,10 @@ impl Backend { let alive = this.update(cx, |this, cx| { this.sync_progress = Some((progress.total, progress.current)); - this.emit( - BackendEvent::SyncProgress { - total: progress.total, - current: progress.current, - }, - cx, - ); + cx.emit(BackendEvent::SyncProgress { + total: progress.total, + current: progress.current, + }); cx.notify(); }); @@ -1343,14 +1299,14 @@ impl Backend { ); this.update(cx, |this, cx| { this.sync_progress = None; - this.emit(BackendEvent::Synced, cx); + cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { this.update(cx, |this, cx| { this.sync_progress = None; - this.emit(BackendEvent::error(e.to_string()), cx) + cx.emit(BackendEvent::error(e.to_string())) })?; } } @@ -1364,7 +1320,7 @@ impl Backend { /// Callers publish with `client.send_event(...)` directly, then call this /// so stores like `RepoListStore` refresh without re-querying the relays. pub fn announce_published(&self, event: Event, cx: &mut Context) { - self.emit(BackendEvent::Published(Box::new(event)), cx); + cx.emit(BackendEvent::Published(Box::new(event))); } /// Publish a NIP-09 deletion for each of `events`, best-effort. diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 67539de..9b58dda 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -16,131 +16,87 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Maximum number of "continue where you left off" activity events kept. const ACTIVITY_LIMIT: usize = 50; -/// State backing the inbox home screen. +/// The user's persisted inbox read state. #[derive(Default)] pub struct Inbox { - /// Notifications grouped by thread root, newest activity first. - pub notifications: Arc>, - /// The user's own recent git activity, newest first. - pub activity: Arc>, - /// Number of non-archived groups with an unread event. - pub unread_count: usize, state: InboxReadState, /// Set once the stored state has been read for the current user. state_loaded: bool, - refresh: RefreshGate, + /// Unread notification groups, published by [`InboxStore`] for the sidebar badge. + pub unread_count: usize, } impl Inbox { - /// Mark every event in the group rooted at `root` as read. - pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let Some(events) = self.group_events(root) else { - return; - }; - - for event in &events { - self.state.mark_read(event); - } - - let all = self.all_notification_events(); - self.state.advance_read(&all, me, Timestamp::now()); - self.after_state_change(cx); + /// The current read/archive cutoffs. + pub fn state(&self) -> &InboxReadState { + &self.state } - /// Archive the group rooted at `root`. Archived events are always read too. - pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; + /// Whether the stored state has been read for the current user. + pub fn is_loaded(&self) -> bool { + self.state_loaded + } - let Some(events) = self.group_events(root) else { + /// Publish the unread count derived by [`InboxStore`]. + pub fn set_unread_count(&mut self, count: usize, cx: &mut Context) { + if self.unread_count == count { return; - }; + } + self.unread_count = count; + cx.notify(); + } - for event in &events { + /// Mark the events of one notification group read, then bound the id sets. + pub fn mark_read( + &mut self, + group: &[Event], + all: &[Event], + me: PublicKey, + cx: &mut Context, + ) { + for event in group { + self.state.mark_read(event); + } + self.state.advance_read(all, me, Timestamp::now()); + self.persist(cx); + cx.notify(); + } + + /// Archive one notification group. Archived events are always read too. + pub fn mark_archived( + &mut self, + group: &[Event], + all: &[Event], + me: PublicKey, + cx: &mut Context, + ) { + for event in group { self.state.mark_archived(event); self.state.mark_read(event); } - let all = self.all_notification_events(); let now = Timestamp::now(); - - self.state.advance_archived(&all, me, now); - self.state.advance_read(&all, me, now); - self.after_state_change(cx); + self.state.advance_archived(all, me, now); + self.state.advance_read(all, me, now); + self.persist(cx); + cx.notify(); } /// Mark every known notification read. - pub fn mark_all_read(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; - - let all = self.all_notification_events(); - self.state.mark_all_read(&all, me, Timestamp::now()); - self.after_state_change(cx); - } - - /// Handle a backend event that can change the inbox contents. - pub(crate) fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { - match event { - BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), - BackendEvent::NostrUpdate(updates) => { - let relevant = updates.iter().any(|update| { - let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); - let is_comment = update.kind == Kind::Comment; - let is_event_deletion = update.kind == Kind::EventDeletion; - let is_request_to_vanish = update.kind == Kind::RequestToVanish; - - is_notification || is_comment || is_event_deletion || is_request_to_vanish - }); - if relevant { - self.refresh(cx); - } - } - _ => {} - } - } - - /// Activate the inbox for the backend's current user. - pub(crate) fn activate(&mut self, cx: &mut Context) { - let backend = Backend::global(cx); - let Some(me) = backend.read(cx).current_user() else { - return; - }; - - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); - self.unread_count = 0; - self.state = InboxReadState::default(); - self.state_loaded = false; - // Drop any in-flight or pending run belonging to the previous user. - self.refresh = RefreshGate::default(); - cx.notify(); - - self.load_state(me, cx); - } - - /// Forget everything for the current user. - pub(crate) fn reset(&mut self, cx: &mut Context) { - self.notifications = Arc::new(Vec::new()); - self.activity = Arc::new(Vec::new()); - self.unread_count = 0; - self.state = InboxReadState::default(); - self.state_loaded = false; - self.refresh = RefreshGate::default(); + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { + self.state.mark_all_read(all, me, Timestamp::now()); + self.persist(cx); cx.notify(); } - /// Read the stored state, then run the first refresh. - fn load_state(&mut self, me: PublicKey, cx: &mut Context) { - let backend = Backend::global(cx); - let client = backend.read(cx).client(); + /// Load the stored state for current user. + pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { + self.state = InboxReadState::default(); + self.state_loaded = false; + self.unread_count = 0; + cx.notify(); + let backend = Backend::global(cx); let work = cx.background_spawn(async move { load_state(&client, me).await }); cx.spawn(async move |this, cx| { @@ -158,7 +114,7 @@ impl Inbox { } this.state_loaded = true; - this.refresh_initial(cx); + cx.notify(); })?; Ok::<(), Error>(()) @@ -166,6 +122,161 @@ impl Inbox { .detach(); } + /// Clear the state of the signed-out user. + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.state = InboxReadState::default(); + self.state_loaded = false; + self.unread_count = 0; + cx.notify(); + } + + /// Sign the state with a random key and store it locally. + fn persist(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let client = Backend::global(cx).read(cx).client(); + let state = self.state.clone(); + + let task: Task> = cx.background_spawn(async move { + if let Err(error) = save_state(&client, me, &state).await { + log::warn!("failed to save inbox state: {error}"); + } + Ok(()) + }); + + task.detach(); + } +} + +/// Derives the inbox home screen's notification and activity lists. +/// +/// Created by the inbox panel, so the database work only happens while the +/// panel is open. The persisted read state stays in the global [`Inbox`]. +#[derive(Default)] +pub struct InboxStore { + /// Notifications grouped by thread root, newest activity first. + pub notifications: Arc>, + /// The user's own recent git activity, newest first. + pub activity: Arc>, + /// Number of non-archived groups with an unread event. + pub unread_count: usize, + /// Copy of the global read state the current lists were derived with. + state: InboxReadState, + /// Set once the global state has been read for the current user. + state_loaded: bool, + refresh: RefreshGate, +} + +impl InboxStore { + /// Create the store and derive the lists from the current global state. + pub fn new(cx: &mut Context) -> Self { + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) { + log::warn!("inbox store dropped before bootstrap could run: {error}"); + } + }); + + Self::default() + } + + /// Mark every event in the group rooted at `root` read. + pub fn mark_read(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_read(&group, &all, me, cx)); + } + + /// Archive the group rooted at `root`. + pub fn mark_archived(&mut self, root: EventId, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let Some(group) = self.group_events(root) else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_archived(&group, &all, me, cx)); + } + + /// Mark every known notification read. + pub fn mark_all_read(&mut self, cx: &mut Context) { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + return; + }; + + let all = self.all_notification_events(); + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); + } + + /// Re-derive from the global state when it is loaded or changes. + pub fn sync_state(&mut self, cx: &mut Context) { + let inbox = Backend::global(cx).read(cx).inbox(); + let (loaded, state) = { + let inbox = inbox.read(cx); + (inbox.is_loaded(), inbox.state().clone()) + }; + + if !loaded { + let was_present = + self.state_loaded || !self.notifications.is_empty() || !self.activity.is_empty(); + self.clear(); + if was_present { + cx.notify(); + } + return; + } + + if !self.state_loaded { + self.state_loaded = true; + self.state = state; + self.refresh_initial(cx); + return; + } + + if self.state != state { + self.state = state; + self.regroup(); + self.publish_unread_count(cx); + cx.notify(); + } + } + + /// Handle a backend event that can change the derived lists. + pub fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { + match event { + BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx), + BackendEvent::NostrUpdate(updates) => { + let relevant = updates.iter().any(|update| { + let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind); + let is_comment = update.kind == Kind::Comment; + let is_event_deletion = update.kind == Kind::EventDeletion; + let is_request_to_vanish = update.kind == Kind::RequestToVanish; + + is_notification || is_comment || is_event_deletion || is_request_to_vanish + }); + if relevant { + self.refresh(cx); + } + } + _ => {} + } + } + /// One-shot initial load, no debounce. fn refresh_initial(&mut self, cx: &mut Context) { debug_assert!(!self.refresh.debouncing()); @@ -198,12 +309,13 @@ impl Inbox { fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); - let Some(me) = Backend::global(cx).read(cx).current_user() else { + let backend = Backend::global(cx); + let Some(me) = backend.read(cx).current_user() else { self.refresh.abort(); return; }; - let client = Backend::global(cx).read(cx).client(); + let client = backend.read(cx).client(); let state = self.state.clone(); let work = cx.background_spawn(async move { @@ -259,6 +371,7 @@ impl Inbox { this.notifications = Arc::new(notifications); this.activity = Arc::new(activity); this.unread_count = unread_count; + this.publish_unread_count(cx); cx.notify(); this.refresh.finish() @@ -274,13 +387,6 @@ impl Inbox { task.detach(); } - /// Advance the cutoffs, re-derive the groups and persist the state. - fn after_state_change(&mut self, cx: &mut Context) { - self.regroup(); - self.persist(cx); - cx.notify(); - } - /// Recompute the unread and archived flags from the current state. fn regroup(&mut self) { let mut items = (*self.notifications).clone(); @@ -293,23 +399,22 @@ impl Inbox { self.notifications = Arc::new(items); } - /// Sign the state with a random key and store it locally. - fn persist(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { - return; - }; + /// Publish the derived unread count for the sidebar badge. + fn publish_unread_count(&self, cx: &mut Context) { + let count = self.unread_count; + let inbox = Backend::global(cx).read(cx).inbox(); + inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx)); + } - let client = Backend::global(cx).read(cx).client(); - let state = self.state.clone(); - - let task: Task> = cx.spawn(async move |_this, _cx| { - if let Err(error) = save_state(&client, me, &state).await { - log::warn!("failed to save inbox state: {error}"); - } - Ok(()) - }); - - task.detach(); + /// Forget everything derived for the current user. + fn clear(&mut self) { + self.notifications = Arc::new(Vec::new()); + self.activity = Arc::new(Vec::new()); + self.unread_count = 0; + self.state = InboxReadState::default(); + self.state_loaded = false; + // Drop any in-flight or pending run belonging to the previous user. + self.refresh = RefreshGate::default(); } /// Events of the group rooted at `root`. diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 061c70a..f325ad5 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -13,7 +13,7 @@ pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use git_store::GitStore; use gpui::{App, AppContext}; -pub use inbox::Inbox; +pub use inbox::{Inbox, InboxStore}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs new file mode 100644 index 0000000..d90b4eb --- /dev/null +++ b/crates/workspace/src/views/inbox.rs @@ -0,0 +1,610 @@ +use assets::CustomIconName; +use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, + Subscription, WeakEntity, Window, div, px, +}; +use gpui_component::input::{Input, InputState}; +use gpui_component::scroll::ScrollableElement; +use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; +use nostr::prelude::{Event, Kind}; +use signed_core::{Announcement, COVER_NOTE_KIND, InboxItem, RepoAddr, activity_subject}; +use signed_state::{Backend, BackendEvent, InboxStore, ProfileStore, RepoListStore}; +use signed_ui::{CountBadge, SegmentButton, UserAvatar}; +use utils::relative_time; + +use super::open_repo_panel; +use super::sidebar::create_repo_dialog; + +/// Notification groups shown before the `Show all` toggle is used. +const NOTIFICATION_PREVIEW: usize = 5; + +/// Activity rows shown in `Continue where you left off`. +const ACTIVITY_SHOWN: usize = 15; + +pub struct InboxView { + focus_handle: FocusHandle, + dock_area: WeakEntity, + /// Derives the notification and activity lists while the panel is open. + store: Entity, + /// Search box filtering the `My repositories` column. + search: Entity, + /// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`]. + show_all: bool, + _subscriptions: Vec, +} + +impl InboxView { + pub fn new( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let backend = Backend::global(cx); + let inbox = backend.read(cx).inbox(); + + let store = cx.new(InboxStore::new); + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); + + // Drive the store from the global inbox state and from backend events. + let _subscriptions = vec![ + cx.observe(&inbox, |this, _inbox, cx| { + this.store.update(cx, |store, cx| store.sync_state(cx)); + }), + cx.subscribe(&backend, |this, _backend, event, cx| { + if matches!( + event, + BackendEvent::SignerChanged | BackendEvent::SignerRequired + ) { + this.show_all = false; + cx.notify(); + } + + this.store + .update(cx, |store, cx| store.handle_backend_event(event, cx)); + }), + ]; + + Self { + focus_handle: cx.focus_handle(), + dock_area, + store, + search, + show_all: false, + _subscriptions, + } + } + + /// Mark every known notification read. + fn mark_all_read(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| store.mark_all_read(cx)); + } + + /// Open a repository's detail panel. + fn open_repo( + &mut self, + announcement: &Announcement, + window: &mut Window, + cx: &mut Context, + ) { + open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + } + + /// Show the existing Create Repository dialog. + fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { + create_repo_dialog::open(self.dock_area.clone(), window, cx); + } + + /// Display name of the repository at `addr`, from the announcement store. + fn repo_name(&self, addr: Option<&RepoAddr>, cx: &App) -> Option { + let addr = addr?; + RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|announcement| announcement.addr() == *addr) + .map(display_name) + } + + /// Bordered card with a header bar and a body. + fn section(&self, header: impl IntoElement, body: impl IntoElement, cx: &App) -> AnyElement { + v_flex() + .w_full() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .overflow_hidden() + .child( + div() + .px_3() + .py_2() + .bg(cx.theme().muted.opacity(0.5)) + .border_b_1() + .border_color(cx.theme().border) + .child(header), + ) + .child(body) + .into_any_element() + } + + fn render_inbox_panel( + &self, + unread: usize, + visible: &[&InboxItem], + cx: &mut Context, + ) -> AnyElement { + let shown = if self.show_all { + visible.len() + } else { + visible.len().min(NOTIFICATION_PREVIEW) + }; + + let mut body = v_flex().w_full(); + + if visible.is_empty() { + body = body.child(empty_state(IconName::Inbox, "You're all caught up.", cx)); + } else { + for (ix, item) in visible.iter().take(shown).enumerate() { + body = body.child(self.render_notification_row(ix, item, cx)); + } + + if visible.len() > NOTIFICATION_PREVIEW { + let label = if self.show_all { + "Show less" + } else { + "Show all" + }; + body = body.child(div().px_3().py_2().child( + SegmentButton::new("show-all", label).on_click(cx.listener( + |this, _event, _window, cx| { + this.show_all = !this.show_all; + cx.notify(); + }, + )), + )); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(IconName::Inbox).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Inbox")), + ) + .when(unread > 0, |this| this.child(CountBadge::new(unread))) + .child(div().flex_1()) + .child( + SegmentButton::new("mark-all-read", "Mark all read") + .on_click(cx.listener(|this, _event, _window, cx| this.mark_all_read(cx))), + ); + + self.section(header, body, cx) + } + + fn render_notification_row( + &self, + ix: usize, + item: &InboxItem, + cx: &mut Context, + ) -> AnyElement { + let Some(newest) = item.events.first() else { + return div().into_any_element(); + }; + + let profiles = ProfileStore::global(cx); + let profile = profiles.read(cx).get(&newest.pubkey); + let kind = item.root_kind.unwrap_or(newest.kind); + let subject = SharedString::from(activity_subject(newest)); + let repo = self.repo_name(item.address.as_ref(), cx); + let age = relative_time(item.latest_activity()); + let unread = item.is_unread(); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(UserAvatar::new(profile.name()).picture(profile.picture())) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_sm() + .when(unread, |this| this.font_semibold()) + .whitespace_nowrap() + .text_ellipsis() + .child(subject), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .when(unread, |this| { + this.child( + div() + .flex_shrink_0() + .size(px(8.)) + .rounded(px(4.)) + .bg(cx.theme().primary), + ) + }) + .into_any_element() + } + + fn render_activity_panel(&self, activity: &[Event], cx: &mut Context) -> AnyElement { + let mut body = v_flex().w_full(); + + if activity.is_empty() { + body = body.child(empty_state( + CustomIconName::Recent, + "No recent activity.", + cx, + )); + } else { + for (ix, event) in activity.iter().take(ACTIVITY_SHOWN).enumerate() { + body = body.child(self.render_activity_row(ix, event, cx)); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::Recent).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("Continue where you left off")), + ); + + self.section(header, body, cx) + } + + fn render_activity_row(&self, ix: usize, event: &Event, cx: &mut Context) -> AnyElement { + let kind = event.kind; + let subject = SharedString::from(activity_subject(event)); + let repo = self.repo_name(event.tags.coordinates().next().as_ref(), cx); + let age = relative_time(event.created_at); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child(div().flex_shrink_0().child(kind_icon(kind))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(subject), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(kind_label(kind))) + .when_some(repo, |this, repo| { + this.child(SharedString::from("on")).child(repo) + }) + .child(SharedString::from("·")) + .child(SharedString::from(age)), + ), + ) + .into_any_element() + } + + fn render_repos_panel(&self, cx: &mut Context) -> AnyElement { + let Some(me) = Backend::global(cx).read(cx).current_user() else { + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::GitBranch).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("My repositories")), + ); + let body = v_flex().w_full().child(empty_state( + CustomIconName::GitBranch, + "Sign in to see your repositories.", + cx, + )); + return self.section(header, body, cx); + }; + + let query = self.search.read(cx).value().trim().to_lowercase(); + let repos: Vec = RepoListStore::global(cx) + .read(cx) + .announcements_of(&me) + .into_iter() + .filter(|announcement| { + query.is_empty() + || display_name(announcement).to_lowercase().contains(&query) + || announcement.id.to_lowercase().contains(&query) + }) + .collect(); + + let mut body = v_flex().w_full().child( + div().px_3().py_2().child( + Input::new(&self.search) + .cleanable(true) + .w_full() + .text_sm() + .border_color(cx.theme().muted) + .bg(cx.theme().muted) + .prefix(Icon::new(IconName::Search).small()), + ), + ); + + if repos.is_empty() { + body = body.child(empty_state( + CustomIconName::GitBranch, + "No repositories yet.", + cx, + )); + } else { + for (ix, announcement) in repos.iter().enumerate() { + body = body.child(self.render_repo_row(ix, announcement, cx)); + } + } + + let header = h_flex() + .w_full() + .gap_2() + .child(Icon::new(CustomIconName::GitBranch).small()) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::from("My repositories")), + ) + .when(!repos.is_empty(), |this| { + this.child(CountBadge::new(repos.len())) + }) + .child(div().flex_1()) + .child( + SegmentButton::new("new-repo", "New") + .icon(Icon::new(CustomIconName::CirclePlus)) + .primary() + .on_click( + cx.listener(|this, _event, window, cx| this.open_create_repo(window, cx)), + ), + ); + + self.section(header, body, cx) + } + + fn render_repo_row( + &self, + ix: usize, + announcement: &Announcement, + cx: &mut Context, + ) -> AnyElement { + let name = display_name(announcement); + let description = announcement.description.clone().unwrap_or_default(); + let activity = RepoListStore::global(cx) + .read(cx) + .last_activity + .get(&announcement.addr()) + .copied(); + + h_flex() + .id(ix) + .w_full() + .gap_3() + .px_3() + .py_2() + .items_center() + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + Icon::new(CustomIconName::GitBranch) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when(!description.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .text_ellipsis() + .child(SharedString::from(description)), + ) + }), + ) + .when_some(activity, |this, activity| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time(activity))), + ) + }) + .on_click(cx.listener({ + let announcement = announcement.clone(); + move |this, _event, window, cx| this.open_repo(&announcement, window, cx) + })) + .into_any_element() + } +} + +/// Name to show for a repository, its `name` tag or its id. +fn display_name(announcement: &Announcement) -> SharedString { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) +} + +/// Leading icon for a notification or activity kind. +fn kind_icon(kind: Kind) -> Icon { + if kind == COVER_NOTE_KIND { + return Icon::new(IconName::FileText).small(); + } + + match kind { + Kind::GitIssue => Icon::new(CustomIconName::GitIssueOpen), + Kind::GitPullRequest | Kind::GitPullRequestUpdate => { + Icon::new(CustomIconName::GitPullRequest) + } + Kind::GitPatch => Icon::new(CustomIconName::GitCommit), + Kind::Comment => Icon::new(IconName::FileText), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => Icon::new(IconName::CircleCheck), + _ => Icon::new(IconName::Bell), + } + .small() +} + +/// Short noun for a notification or activity kind. +fn kind_label(kind: Kind) -> &'static str { + if kind == COVER_NOTE_KIND { + return "note"; + } + + match kind { + Kind::GitIssue => "issue", + Kind::GitPullRequest => "PR", + Kind::GitPullRequestUpdate => "PR update", + Kind::GitPatch => "patch", + Kind::Comment => "comment", + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => "status", + _ => "activity", + } +} + +/// Centered muted icon and message filling its container. +fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { + v_flex() + .w_full() + .items_center() + .justify_center() + .gap_2() + .py_8() + .child( + Icon::new(icon) + .large() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(message)), + ) + .into_any_element() +} + +impl BasePanel for InboxView { + fn panel_name(&self) -> &'static str { + "inbox" + } +} + +impl Panel for InboxView { + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().text_sm().child(SharedString::from("Inbox")) + } +} + +impl EventEmitter for InboxView {} + +impl Focusable for InboxView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (unread, notifications, activity) = { + let store = self.store.read(cx); + ( + store.unread_count, + store.notifications.clone(), + store.activity.clone(), + ) + }; + + let visible: Vec<&InboxItem> = notifications.iter().filter(|item| !item.archived).collect(); + + v_flex() + .size_full() + .image_cache(gpui::retain_all("inbox")) + .child( + v_flex().size_full().overflow_y_scrollbar().child( + h_flex() + .items_start() + .gap_4() + .p_4() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_4() + .child(self.render_inbox_panel(unread, &visible, cx)) + .child(self.render_activity_panel(&activity, cx)), + ) + .child( + v_flex() + .w(px(300.)) + .flex_shrink_0() + .child(self.render_repos_panel(cx)), + ), + ), + ) + } +} diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 830a84a..6659761 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,8 +1,10 @@ mod dialog_state; +mod inbox; mod repo_detail; mod repo_list; pub(crate) mod sidebar; +pub use inbox::InboxView; pub use repo_detail::RepoDetailView; pub(crate) use repo_detail::open_repo_panel; pub use repo_list::RepoListView; diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 523bc10..3af536d 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -21,11 +21,11 @@ use signed_core::{Announcement, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; -use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; +use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; -use super::{RepoDetailView, RepoListView, open_repo_panel}; +use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; -mod create_repo_dialog; +pub(crate) mod create_repo_dialog; pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; @@ -37,7 +37,10 @@ use self::onboarding_dialog::OnboardingState; pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, + inbox: Option>, explore: Option>, + /// Unread notification groups, shown as the inbox nav item's badge. + unread: usize, /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. @@ -99,10 +102,22 @@ impl SidebarPanel { } })); + // The inbox nav item shows the unread notification count as a badge. + let inbox = backend.read(cx).inbox(); + subscriptions.push(cx.observe(&inbox, |this, inbox, cx| { + let unread = inbox.read(cx).unread_count; + if this.unread != unread { + this.unread = unread; + cx.notify(); + } + })); + let mut this = Self { focus_handle: cx.focus_handle(), dock_area, + inbox: None, explore: None, + unread: inbox.read(cx).unread_count, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), @@ -203,6 +218,20 @@ impl SidebarPanel { }); } + /// Open the inbox home panel in the dock area's center. + pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { + if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() { + return; + } + + let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), window, cx)); + self.inbox = Some(panel.downgrade()); + + let _ = self.dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + /// Open the Explore repository list panel in the dock area's center. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self @@ -615,8 +644,11 @@ impl Render for SidebarPanel { .justify_start() .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .when(self.unread > 0, |this| { + this.suffix(CountBadge::new(self.unread)) + }) .on_click(cx.listener(|this, _ev, window, cx| { - this.open_explore(window, cx) + this.open_inbox(window, cx) })), ) .child( diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md index 044981f..13a22ad 100644 --- a/docs/inbox-plan.md +++ b/docs/inbox-plan.md @@ -6,11 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for > page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when > an account is active, and that home screen is the inbox. -> **Status.** Phases 0 and 1 are implemented and green on `feat/inbox`: +> **Status.** Phases 0, 1 and 2 are implemented and green on `feat/inbox`: > `cargo test -p signed_core` (68), `cargo test -p signed_state` (24), -> `cargo clippy -p signed_state --all-targets` clean, `cargo check --workspace` succeeds. -> Phases 2-5 are not started. This document reflects the implementation as it stands, including -> the Phase 1 refactors (§4.3). +> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean, +> `cargo check --workspace --all-targets` succeeds. +> Phases 3-5 are not started. This document reflects the implementation as it stands, including the +> Phase 1 refactors and the §4.3 split of the inbox into a thin global `Inbox` and a panel-scoped +> `InboxStore`. ## 1. What the GitWorkshop home screen is @@ -267,12 +269,14 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve id across saves; that was removed as more derived state than it was worth. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. -### 4.3 `signed_state`: `Inbox`, a child entity of `Backend` +### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore` -The inbox is not an app-wide global. It is a child `Entity` owned by `Backend` -(`inbox: Entity`), following the project's child-entity pattern -(`docs/backend-rearchitecture.md` §11): its observer set (the inbox screen, the sidebar badge) is a -strict subset of the backend's, so it is observed independently. +The inbox is split in two, because the expensive derivation is only needed while the home screen is +open. + +**`Inbox`** is a child `Entity` owned by `Backend` (`inbox: Entity`) and is +deliberately thin: it owns only the read/archive state that must outlive the panel, the NIP-78 +load/save, and the unread count the sidebar badge reads. ```rust // backend.rs @@ -282,80 +286,90 @@ pub struct Backend { } // inbox.rs +#[derive(Default)] pub struct Inbox { - /// Activity directed at the user, grouped by thread root, newest first. - pub notifications: Arc>, - /// The user's own recent git activity, newest first. - pub activity: Arc>, - /// Unread notification count (non-archived). - pub unread_count: usize, state: InboxReadState, - /// Set once the stored state has been read for the current user. state_loaded: bool, - refresh: RefreshGate, + /// Published by `InboxStore` for the sidebar badge. + pub unread_count: usize, +} + +impl Inbox { + pub fn state(&self) -> &InboxReadState; + pub fn is_loaded(&self) -> bool; + pub fn set_unread_count(&mut self, count: usize, cx: &mut Context); + pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); + pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx); + pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx); + pub(crate) fn reset(&mut self, cx); } ``` -`Backend::new` builds it with `cx.new(|_| Inbox::default())`, and callers reach it through -`Backend::global(cx).read(cx).inbox()` or `Backend::inbox()`. All inbox operations (`mark_read`, -`mark_archived`, `mark_all_read`, `refresh`) live on `Inbox`. - -**The store holds no derived state.** The current user is read from `Backend::current_user()` at -each use site, repo relays are queried from `RepoListStore` in `Backend::sync_inbox`, and the -signing key is random per save rather than cached. This follows the project rule against caching -derived state. - -**Lifespan: idle until a signer exists.** `Inbox` is created with the backend but does nothing -until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init` -gains no parameters (the `InboxStore::set_global` idea was dropped). - -The dependency is strictly one-way: **`Backend` → `Inbox`**. `Inbox` holds no `Backend` handle, so -there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them. - -**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a -deferred effect and then emits to the other subscribers: +**`InboxStore`** is created by `InboxView` and therefore only exists while the panel is open. It +derives the notification groups and the activity list, coalesces refreshes and applies the read +state for rendering. ```rust -/// Update the inbox, then emit `event` to the other stores. -fn emit(&self, event: BackendEvent, cx: &mut Context) { - let inbox = self.inbox.downgrade(); - let inbox_event = event.clone(); +pub struct InboxStore { + pub notifications: Arc>, + pub activity: Arc>, + pub unread_count: usize, + state: InboxReadState, + state_loaded: bool, + refresh: RefreshGate, + _subscriptions: Vec, +} - cx.defer(move |cx| { - if let Err(error) = inbox.update(cx, |inbox, cx| { - inbox.handle_backend_event(&inbox_event, cx); - }) { - log::warn!("inbox dropped before handling backend event: {error}"); - } - }); - - cx.emit(event); +impl InboxStore { + pub fn new(cx: &mut Context) -> Self; + pub fn refresh(&mut self, cx: &mut Context); + pub fn mark_read(&mut self, root: EventId, cx); + pub fn mark_archived(&mut self, root: EventId, cx); + pub fn mark_all_read(&mut self, cx); } ``` -The `cx.defer` is load-bearing: every emit site runs inside `Backend::update`, and the inbox -handlers read `Backend`, so a synchronous call would re-enter the borrowed entity and panic. All -`cx.emit(...)` sites route through `self.emit(...)`. +`InboxStore` has no subscriptions of its own. The panel owns the two subscriptions that carry +logic: it observes the global `Inbox` (`InboxStore::sync_state`) and subscribes to `Backend` +(`InboxStore::handle_backend_event`, plus resetting `show_all` on a signer change). Re-rendering +needs no subscription: GPUI invalidates a window for every entity it read during render, so the +panel tracks the store, the search `InputState` and `RepoListStore` just by reading them in +`render`. The store never writes derived data back to `Backend` except the unread count. -`Inbox::handle_backend_event` reacts to only three shapes: +**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. `InboxStore` +is created and dropped with the panel. Neither is wired from the `desktop` crate and +`signed_state::init` gains no parameters. -- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is - `Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`). -- `Synced` / `Published`: refresh. +**Badge trade-off.** The unread count is derived by the store, so the sidebar badge is only current +after the inbox has been opened once in the session. Keeping it always live would require the +expensive derivation to run globally, which is exactly what this split avoids. + +The dependency chain is `Backend` → `Inbox` and `InboxView` → `InboxStore`; the store reaches back +only to publish the unread count. + +`Backend` no longer funnels its events through the inbox: `InboxStore` subscribes to `Backend` +directly. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: +`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox. + +`InboxStore::handle_backend_event` refreshes on: + +- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is + a deletion (`EventDeletion` / `RequestToVanish`). +- `Synced` / `Published`. - everything else: ignored. -`BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: `CheckoutsStore` -and `SidebarPanel` consume them. They no longer drive the inbox. - -**Signer lifecycle: `Backend::sync_inbox`.** The inbox does not match `SignerChanged` / -`SignerRequired`. `Backend` owns the wiring and calls `sync_inbox` from the three real signer -transitions: `create_identity`, `set_signer` (covers nsec, bunker and passphrase restore) and -`logout`. The fetch work that used to live in `Inbox::activate` moved here, because the filters and -repo relays need `Backend`'s state: +**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the +three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore) +and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or +`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend` +while `sync_inbox` is mid-update: ```rust fn sync_inbox(&mut self, cx: &mut Context) { - if let Some(me) = self.current_user { + let me = self.current_user; + + if let Some(me) = me { self.subscribe_bootstrap(filters::notifications(me), cx); self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); @@ -373,38 +387,26 @@ fn sync_inbox(&mut self, cx: &mut Context) { } } - let inbox = self.inbox.downgrade(); - cx.defer(move |cx| { - let updated = inbox.update(cx, |inbox, cx| { - if Backend::global(cx).read(cx).current_user().is_some() { - inbox.activate(cx); - } else { - inbox.reset(cx); - } - }); - if let Err(error) = updated { - log::warn!("inbox dropped before syncing with the signer: {error}"); - } + let client = self.client.clone(); + self.inbox.update(cx, |inbox, cx| match me { + Some(me) => inbox.activate(me, client, cx), + None => inbox.reset(cx), }); } ``` The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind -10002 yet.) The deferred `update` is required because `activate` reads `Backend`, which every -caller is mid-update on. `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` no longer -has `subscribe_remote` / `connect_own_repo_relays`. +10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote` +/ `connect_own_repo_relays`. -**Activation** (`activate`) clears the user's data, drops any in-flight or pending run belonging to -the previous user (`self.refresh = RefreshGate::default()`), then loads the NIP-78 state from LMDB -and chains the first refresh once it is loaded: +**Activation** clears the state and loads the NIP-78 state from LMDB. `InboxStore` clears its own +lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded: ```rust -pub(crate) fn activate(&mut self, cx: &mut Context) { - let Some(me) = Backend::global(cx).read(cx).current_user() else { return }; - // clear notifications, activity, unread_count; state = default; state_loaded = false; - // self.refresh = RefreshGate::default(); - self.load_state(me, cx); +pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { + // state = default; state_loaded = false; unread_count = 0; cx.notify(); + // spawn load_state(client, me), then set state and state_loaded = true } ``` @@ -413,11 +415,11 @@ pub(crate) fn activate(&mut self, cx: &mut Context) { Reading the state event needs no signer at all (the `d` tag carries the identity); activation is still gated on the signer because the fetch filters need the user's pubkey. -**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays`, as shown in -`sync_inbox` above. The query that follows is intentionally the offline-first cache read, not a -wait on the network; see the note below. +**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through +`Backend::sync_inbox` (see above). The query the store runs is intentionally the offline-first cache +read, not a wait on the network; see the note below. -**Refresh** (mirrors `RepoListStore::run_refresh`): +**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`): - `cx.background_spawn`: query the notification filters and the activity filter from `client.database()`. @@ -428,21 +430,26 @@ wait on the network; see the note below. their `K` tag is a git kind; sort newest first; take the top N. - Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a - previous user's results never land. Then set `notifications`, `activity`, `unread_count`, - `cx.notify()`, `refresh.finish()`. + previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish + the unread count to the global `Inbox`, `cx.notify()`, `refresh.finish()`. + +The store's `sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the +lists, on the first load it runs the initial refresh, and on a state change (a mark action) it +re-derives the flags (`InboxItem::apply_state`) and publishes the new unread count. **Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, so the query that follows them reads the local cache rather than waiting for the relays. That is deliberate offline-first behavior: cached content appears at once on a warm start and with no network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so -`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the inbox refreshes. This was +`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was reviewed and left as-is. -**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each group's events are -marked, then the cutoffs are advanced against *all* notification events to bound the id sets. After -each change the groups are re-derived (`InboxItem::apply_state`) and the state is saved to LMDB, -signed with a fresh random key (see 4.2). +**Actions**: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()` live on `InboxStore`, which +passes the group and every known notification event to the global `Inbox`. The global marks the +group, advances the cutoffs against *all* notification events to bound the id sets, saves the state +to LMDB (signed with a fresh random key, see 4.2), and notifies. The store then re-derives and +publishes the unread count. **My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes `announcements_of(user)`. @@ -457,19 +464,24 @@ signed with a fresh random key (see 4.2). ### 5.1 `InboxView` center panel New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -One scrollable two-column flex row. +It owns an `Entity`; GPUI re-renders the panel when the store changes because the panel +reads it during render. One `overflow_y_scrollbar` container holding a two-column flex row, left +column flexible and right column fixed at 300px. Both columns are bordered cards with a header bar. -- **Inbox column**: header with the unread count badge and actions **Unread**, **Archived**, - **Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle - expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a - relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`. -- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, - repo name, and relative time. -- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same +- **Inbox column**: header with the unread count badge and **Mark all read**; then the non-archived + notification items (top 5, with a **Show all** toggle expanding inline). Rows show the actor + avatar, a kind icon, the subject, the kind label, the repo name, a relative time, and an unread + dot (the subject is semibold while unread). Empty state: "You're all caught up." with + `IconName::Inbox`. +- **Continue where you left off**: `Inbox::activity`, top 15, each row a kind icon, subject, kind + label, repo name, and relative time. +- **My repositories**: `RepoListStore::announcements_of(me)` with a search `InputState` (same pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows - open `open_repo_panel`. + open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says + "Sign in to see your repositories.". -No greeting header. +No greeting header. The **Unread** and **Archived** header buttons belong to Phase 3 and are not +rendered until `add_bottom_panel` / `InboxFilterView` exist (see 5.2). ### 5.2 Unread / Archived as bottom-dock panels @@ -490,17 +502,19 @@ pub fn add_bottom_panel( The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then: - New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the - `Entity`. It renders the matching subset of `Inbox::notifications` as a list. -- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the - requested mode. `InboxView` keeps `filter_view: Option>`; when it - already exists, update its mode and focus instead of adding a duplicate. + `Entity`. It renders the matching subset of `InboxStore::notifications` as a list. +- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) call + `add_bottom_panel` with the requested mode. `InboxView` keeps + `filter_view: Option>`; when it already exists, update its mode and + focus instead of adding a duplicate. Until then the inbox header has only **Mark all read**. ### 5.3 Sidebar In `views/sidebar/mod.rs`: -- Add `inbox: Option>` (mirrors `explore`). -- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel. +- Add `inbox: Option>` (mirrors `explore`) and `unread: usize`. +- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds + a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). - Point the existing nav item at it and add an unread suffix: ```rust @@ -509,7 +523,8 @@ In `views/sidebar/mod.rs`: .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), ``` -- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge updates. +- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store + publishes. ### 5.4 Click-through (P1) @@ -551,16 +566,19 @@ patch-root click opens the repo panel. Note as a known limitation. | `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests | | `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | | `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | **new**: `Inbox` child entity, NIP-78 load/save, refresh, actions; no `Backend` handle, no `cx.subscribe` | -| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, private `emit` funnel, `sync_inbox`, `RepoListStore` import | +| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state) and panel-scoped `InboxStore` (query, grouping, activity, actions) | +| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | | `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | | `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` (no global install) | | `crates/dock/src/lib.rs` | `add_bottom_panel` helper | -| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` | +| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel owning `Entity` (`InboxFilterView` is Phase 3) | | `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge | +| `crates/workspace/src/views/sidebar/mod.rs` | `inbox`/`unread` fields, `open_inbox`, nav wiring and badge, `create_repo_dialog` visibility | | `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) | +`create_repo_dialog` changes from private (`mod`) to `pub(crate) mod` inside `sidebar`, so the inbox's +New button can open it. + No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` activates the `Inbox` child entity at each signer transition. @@ -577,6 +595,7 @@ activates the `Inbox` child entity at each signer transition. exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the implementation notes below. 3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge. + **DONE.** See the implementation notes below. 4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. 5. **Phase 4 - click-through**: `open_item` and announcement lookup. 6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail @@ -631,16 +650,65 @@ and two additions to `crates/signed_core/src/inbox.rs`. - `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests); `cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds. +### Phase 2 implementation notes + +Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes. + +- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity` and + drives it; the sidebar holds a `WeakEntity` so there is no cycle. The panel's `Backend` + subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time + entity tracking rather than explicit observations. +- The layout is one `overflow_y_scrollbar` container with a two-column `h_flex`. The left column + (`flex_1`, `min_w_0`) stacks the inbox card over the activity card; the right column is fixed at + 300px. Each card is a bordered rounded `v_flex` with a header bar (`section`). +- Notification rows read the newest event of each group for the actor, subject and time, and the + root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of + `RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged. +- The **Unread** / **Archived** header buttons are intentionally absent: they need + `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is **Mark all read** plus the + inline **Show all** toggle, so the panel is fully usable on its own. +- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The + cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. +- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox + nav item is repointed and carries a `CountBadge` suffix driven by the observed unread count. The + screen is still opened by the nav item, not on app startup, matching the "idle until signer" rule; + auto-opening it as the post-login home is a possible follow-up. +- `create_repo_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it. +- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets` + succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass. + +### Architecture refactor (after Phase 2) + +Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran +whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to +feed it. + +- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the + sidebar badge reads, plus the NIP-78 load/save and the mark actions. +- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate + and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to + `Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking. +- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` + updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. +- `InboxView` observes only its store; the two observations it used to hold moved into the store. +- `signed_core` is unchanged. `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and + `cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are clean. + +Trade-off: the sidebar badge is only current after the inbox is opened once, because the unread +count is derived by the panel-scoped store. + ## 8. Validation - `cargo test -p signed_core` (68 tests): root resolution, grouping, read-state cutoff, serde round-trip. -- `cargo test -p signed_state` (24 tests): the `Inbox` store paths that do not need GPUI (state - round-trip, grouping helpers). -- `cargo clippy -p signed_state --all-targets` and `cargo check --workspace` after each phase. -- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another - identity's issue/comment, the activity list shows your own items, the repositories panel matches - the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events). - Restart to confirm the read state is read back from LMDB. +- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths that do not need GPUI + (state round-trip, grouping helpers). +- `cargo test -p workspace` (7 tests): repository-detail helpers. +- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and + `cargo check --workspace --all-targets` after each phase. +- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel + populates from another identity's issue/comment, the activity list shows your own items, the + repositories panel matches the sidebar, and that no kind-30078 event is broadcast (watch the + relays / `Published` events). Restart to confirm the read state is read back from LMDB. ## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)