add inbox view

This commit is contained in:
2026-09-11 09:35:38 +07:00
parent b7221ef814
commit e63e58c125
7 changed files with 1119 additions and 346 deletions
+43 -87
View File
@@ -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<Self>) {
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<RelayMetadata>)> = [
@@ -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<Result<(), Error>> = 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<String>, cx: &mut Context<Self>) {
self.emit(BackendEvent::error(message), cx);
}
/// Update the inbox, then emit `event` to the other stores.
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
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<Self>) {
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 {
cx.emit(BackendEvent::SyncProgress {
total: progress.total,
current: progress.current,
},
cx,
);
});
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>) {
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.
+229 -124
View File
@@ -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<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
pub activity: Arc<Vec<Event>>,
/// 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<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
/// The current read/archive cutoffs.
pub fn state(&self) -> &InboxReadState {
&self.state
}
let Some(events) = self.group_events(root) else {
return;
};
/// Whether the stored state has been read for the current user.
pub fn is_loaded(&self) -> bool {
self.state_loaded
}
for event in &events {
/// Publish the unread count derived by [`InboxStore`].
pub fn set_unread_count(&mut self, count: usize, cx: &mut Context<Self>) {
if self.unread_count == count {
return;
}
self.unread_count = count;
cx.notify();
}
/// 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<Self>,
) {
for event in group {
self.state.mark_read(event);
}
let all = self.all_notification_events();
self.state.advance_read(&all, me, Timestamp::now());
self.after_state_change(cx);
self.state.advance_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Archive the group rooted at `root`. Archived events are always read too.
pub fn mark_archived(&mut self, root: EventId, cx: &mut Context<Self>) {
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 {
/// 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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
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<Result<(), Error>> = 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<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
pub activity: Arc<Vec<Event>>,
/// 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>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
debug_assert!(!self.refresh.debouncing());
@@ -198,12 +309,13 @@ impl Inbox {
fn run_refresh(&mut self, cx: &mut Context<Self>) {
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>) {
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<Self>) {
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<Result<(), Error>> = cx.spawn(async move |_this, _cx| {
if let Err(error) = save_state(&client, me, &state).await {
log::warn!("failed to save inbox state: {error}");
/// Publish the derived unread count for the sidebar badge.
fn publish_unread_count(&self, cx: &mut Context<Self>) {
let count = self.unread_count;
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.set_unread_count(count, cx));
}
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`.
+1 -1
View File
@@ -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;
+610
View File
@@ -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<DockArea>,
/// Derives the notification and activity lists while the panel is open.
store: Entity<InboxStore>,
/// Search box filtering the `My repositories` column.
search: Entity<InputState>,
/// Whether the inbox list is expanded past [`NOTIFICATION_PREVIEW`].
show_all: bool,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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>) {
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<Self>,
) {
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<Self>) {
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<SharedString> {
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<Self>,
) -> 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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Announcement> = 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<Self>,
) -> 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<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from("Inbox"))
}
}
impl EventEmitter<PanelEvent> 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<Self>) -> 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)),
),
),
)
}
}
+2
View File
@@ -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;
+36 -4
View File
@@ -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<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>,
/// 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<Self>) {
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<Self>) {
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(
+193 -125
View File
@@ -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 `<Dashboard />` 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<Inbox>` owned by `Backend`
(`inbox: Entity<Inbox>`), 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<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) 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<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
pub activity: Arc<Vec<Event>>,
/// 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<Self>);
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<Self>) {
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}");
pub struct InboxStore {
pub notifications: Arc<Vec<InboxItem>>,
pub activity: Arc<Vec<Event>>,
pub unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
_subscriptions: Vec<Subscription>,
}
});
cx.emit(event);
impl InboxStore {
pub fn new(cx: &mut Context<Self>) -> Self;
pub fn refresh(&mut self, cx: &mut Context<Self>);
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<Self>) {
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<Self>) {
}
}
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<Self>) {
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<Self>) {
// 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<Self>) {
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<InboxStore>`; 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<Inbox>`. 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<WeakEntity<InboxFilterView>>`; when it
already exists, update its mode and focus instead of adding a duplicate.
`Entity<InboxStore>`. 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<WeakEntity<InboxFilterView>>`; 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<WeakEntity<InboxView>>` (mirrors `explore`).
- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel.
- Add `inbox: Option<WeakEntity<InboxView>>` (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<Inbox>` 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<Inbox>` 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<InboxStore>` (`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<InboxStore>` and
drives it; the sidebar holds a `WeakEntity<InboxView>` 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)