36 KiB
Inbox (home screen) implementation plan
Ported from GitWorkshop's home screen, the Dashboard rendered at route / for a logged-in user.
Correction to the first draft. The first draft assumed the inbox was the
/notificationspage. It is not. GitWorkshop'sIndexroute (src/pages/Index.tsx) renders<Dashboard />when an account is active, and that home screen is the 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 test -p workspace(7),cargo clippy -p workspace --all-targetsclean,cargo check --workspace --all-targetssucceeds. 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 globalInboxand a panel-scopedInboxStore.
1. What the GitWorkshop home screen is
Index.tsx:
if (account) return <Dashboard />;
return <LandingPage />;
Dashboard.tsx layout:
- Desktop: two columns.
- Left column:
GreetingHeader,NotificationsPanel,RecentActivitySection. - Right column:
MyRepositoriesPanel,AccessiblePrivateRepositoriesPanel,FollowedReposPanel.
- Left column:
- Mobile: a single column in a different order.
The panel that gives the screen its inbox identity is NotificationsPanel:
- heading Notifications with a bell icon and an unread count badge,
- a Mark all read action and a View all link to
/notifications, - a compact list of the first 5 non-archived notification items,
- the empty state reads "Your inbox is empty" (with an
Inboxicon).
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced inline on the home screen. The home screen also shows your own recent activity and your repositories.
Data hooks:
| Section | Hook | What it loads |
|---|---|---|
| Notifications (inbox) | useNotifications() |
Notification model: grouped thread activity directed at you, read/archived state |
| Continue where you left off | useUserActivity(pubkey) |
Git activity authored by you: kinds 1621/1617/1618/1111 (git K)/1624/1630-1633, newest first, limit 50 |
| My repositories | useUserRepositories(pubkey) |
Kind 30617 announcements authored by you |
| Followed repositories | useUserFollowedRepos(pubkey) |
Repos you follow |
| Accessible private repositories | useAccessiblePrivateRepositories() |
Private repos from CI/services |
2. Scope for Signed
| Priority | Section | Notes |
|---|---|---|
| P0 | Inbox panel | Activity directed at you, grouped by thread root; unread badge; mark all read; top N + show all |
| P0 | My repositories | Reuse RepoListStore::announcements_of(me); search filter; existing New-repo dialog |
| P0 | Continue where you left off | Your own recent git activity, newest first |
| P1 | Unread / Archived sub-views | Open as bottom-dock panels, not tabs inside the inbox panel |
| P1 | Click-through | Open the repo panel at the relevant PR/issue |
| P2 (defer) | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
| Out of scope | Greeting header, followed repositories, private repositories, pinned repositories | Not needed in Signed |
Notes:
- There is no greeting header. The screen starts with the inbox panel.
- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel.
3. The Signed screen
InboxView is a center panel, opened by the sidebar's existing Inbox nav item (currently a
placeholder that opens Explore). It is one scrollable two-column flex row:
+--------------------------------------------------+-----------------------+
| Inbox (3 unread) [Unread] [Archived] [Mark all read] |
| [avatar] issue opened on you/repo 2m |
| [avatar] commented on "Fix parser" 1h |
| [avatar] PR update on you/repo 3h |
| [Show all] |
| |
| Continue where you left off |
| [icon] "Fix parser bug" you/repo opened 3d |
| [icon] "Add retry" you/repo PR 5d |
+--------------------------------------------------+-----------------------+
| bottom dock: Unread or Archived list (opened by the header buttons) |
+--------------------------------------------------+-----------------------+
The right column is My repositories, mirroring the sidebar's signed-in repo list:
| My repositories |
| [search] [New] |
| repo row |
| repo row |
4. Data layer
4.1 signed_core: pure logic
filters.rs (extend, next to activity/comments_for):
/// Kinds that notify a user when they tag them directly.
pub const NOTIFICATION_KINDS: [Kind; 9] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
Kind::GitPullRequestUpdate,
COVER_NOTE_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
];
/// Comments on our issues/PRs/patches.
pub fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
/// Activity directed at us: comments on our roots, and git events tagging us.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
/// Git activity authored by `me`, for "Continue where you left off".
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
.author(me)
}
ACTIVITY_KINDS already exists in this file. All builders use existing SDK APIs
(Filter::kind/kinds/pubkey/custom_tags, SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}).
Comments authored by me are not all git comments, so the activity query needs a post-filter:
keep kind 1111 only when its uppercase K tag is a git root kind (1621/1617/1618/30617), matching
gitworkshop's isGitComment.
inbox.rs (new file):
pub struct InboxItem {
pub root: EventId,
pub root_kind: Option<Kind>,
pub address: Option<RepoAddr>,
/// Events in the group, newest first.
pub events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
/// The thread root of a notification event, or `None` if it isn't git-related.
pub fn notification_root(
event: &Event,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Option<EventId>;
/// Group notification events by root, newest activity first, self excluded.
pub fn group(
events: impl IntoIterator<Item = Event>,
me: PublicKey,
state: &InboxReadState,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Vec<InboxItem>;
Root resolution, ported from getNotificationRootId:
- issue (1621) / PR (1618): itself
- patch (1617): its
eparent patch, else itself - NIP-22 comment (1111): uppercase
Eroot pointer (SDKnip22::extract_root) - PR update (1619): uppercase
E - statuses (1630-1633) / cover note (1624): NIP-10 root
e - self-authored events are excluded
Read/archive state, the compact high-water-mark model:
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)] pub read_before: Timestamp,
#[serde(default)] pub read_ids: HashSet<EventId>,
#[serde(default)] pub archived_before: Timestamp,
#[serde(default)] pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
pub fn is_read(&self, event: &Event) -> bool;
pub fn is_archived(&self, event: &Event) -> bool;
pub fn mark_read(&mut self, event: &Event);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
}
activity_subject in model.rs already gives an issue/PR title from the subject tag or first
line; reuse it for the home screen rows.
4.2 Persistence: NIP-78 in the local database, never published
Read state is a normal NIP-78 (kind 30078, Kind::ApplicationSpecificData) addressable event
written to LMDB only. It is never broadcast to a relay, so the read state stays on this device.
It is signed with a random keypair, never the user's signer. The event is local application
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
the signer type. The d tag identifies the owning user, so state does not leak across identities
when the signed-in key changes.
/// d tag identifying the inbox read/archive state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
// No author filter: the signing key is random per save.
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with a fresh random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(())
}
A fresh random key is generated on every save, so each save writes a new event rather than
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
has the same pubkey, so old copies accumulate. Nothing prunes them; load_state reads the
newest by created_at, so the behavior is correct. This is a deliberate trade for not caching a
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
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: a thin global Inbox and a panel-scoped InboxStore
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.
// backend.rs
pub struct Backend {
...
inbox: Entity<Inbox>,
}
// inbox.rs
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
state_loaded: bool,
/// 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);
}
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.
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>,
}
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);
}
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.
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.
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 inNOTIFICATION_KINDS, isKind::Comment, or is a deletion (EventDeletion/RequestToVanish).Synced/Published.- everything else: ignored.
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:
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
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);
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
.read(cx)
.announcements_of(&me)
.into_iter()
.flat_map(|announcement| announcement.relays)
.collect();
if !relays.is_empty() {
let relays: Vec<RelayUrl> = relays.into_iter().collect();
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
}
}
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.) Inbox::activate and Inbox::reset are pub(crate); Inbox has no subscribe_remote
/ connect_own_repo_relays.
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:
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
}
reset performs the same clearing without a state load, and is used on logout.
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 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 (InboxStore::run_refresh, mirrors RepoListStore::run_refresh):
cx.background_spawn: query the notification filters and the activity filter fromclient.database().- Query
filters::deletions(), buildDeletions, skip deleted events. - Build
HashMap<EventId, Event>for root walking; group the notification events withinbox::group. - Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their
Ktag 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 setnotifications,activity,unread_count, publish the unread count to the globalInbox,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 store refreshes. This was
reviewed and left as-is.
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).
4.4 Cargo.toml
signed_core: addserde.workspacefor theInboxReadStatederives.signed_state: addserde_json.workspacefor the NIP-78 content.
5. UI
5.1 InboxView center panel
New crates/workspace/src/views/inbox.rs, a BasePanel + Panel + Render, like RepoListView.
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 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 searchInputState(same pattern asRepoListView) and a New button opening the existingcreate_repo_dialog. Rows openopen_repo_panel. Empty state "No repositories yet."; without a signer it says "Sign in to see your repositories.".
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
Add a bottom-panel helper next to add_center_panel in crates/dock/src/lib.rs:
/// Add an already-wrapped panel handle to the bottom dock of `area`.
pub fn add_bottom_panel(
area: &mut DockArea,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
}
The workspace already supports a bottom dock and prunes it when empty (workspace.rs). Then:
- New
InboxFilterViewpanel taking a modeInboxFilter::Unread | InboxFilter::Archivedand theEntity<InboxStore>. It renders the matching subset ofInboxStore::notificationsas a list. - The Unread and Archived header buttons in
InboxView(added in Phase 3) calladd_bottom_panelwith the requested mode.InboxViewkeepsfilter_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>>(mirrorsexplore) andunread: usize. -
Add
fn open_inbox(&mut self, window, cx)that returns when the panel is already open, else adds a center panel (same shape asopen_explore; there is no dock API to focus an existing tab). -
Point the existing nav item at it and add an unread suffix:
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) .when(unread > 0, |this| this.suffix(...badge...)) .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), -
cx.observeBackend::global(cx).read(cx).inbox()so the badge follows the count the store publishes.
5.4 Click-through (P1)
Reuse the single RepoStore that RepoDetailView already creates instead of making a second one:
-
In
repo_detail/mod.rs, add:pub(crate) enum RepoItem { Issue(EventId), PullRequest(EventId), Patch(EventId), } impl RepoDetailView { pub(crate) fn open_item( &mut self, item: RepoItem, window: &mut Window, cx: &mut Context<Self>, ) { /* open IssueDetailView / PullRequestDetailView in the dock */ } } -
open_repo_panelalready returnsEntity<RepoDetailView>; the caller invokesdetail.update_in(window, cx, |detail, window, cx| detail.open_item(...)). -
InboxViewresolvesitem.addressto anAnnouncementfromRepoListStore, opens the repo panel, then callsopen_itemwith the root id and kind.
Patches have no detail view in Signed (they are only consumed inside PullRequestDetailView), so a
patch-root click opens the repo panel. Note as a known limitation.
6. File-by-file change list
| File | Change |
|---|---|
crates/signed_core/Cargo.toml |
add serde |
crates/signed_core/src/filters.rs |
NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity, deletions |
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 |
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 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/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.
7. Phasing
- Phase 0 - pure logic:
signed_corefilters andinbox.rsplus tests. DONE. Implemented asfilters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}andinbox::{InboxItem, notification_root, group, InboxReadState}. Two deviations from the sketch: the cutoff methods take an explicitnow: Timestampso the pure logic stays deterministic and testable, andauthored_activityresults must pass throughis_git_activitybefore display (comments on non-git roots are matched by the filter).cargo test -p signed_corepasses (66 tests at the end of Phase 0; 68 after the two Phase 1 additions). - Phase 1 - store:
Inboxchild entity, activated byBackend::sync_inboxonce a signer exists; both queries, unread count, and NIP-78 load/save to LMDB. DONE. See the implementation notes below. - Phase 2 - screen:
InboxView(inbox + activity + my repositories), sidebar nav and badge. DONE. See the implementation notes below. - Phase 3 - sub-views:
add_bottom_panelandInboxFilterViewfor Unread / Archived. - Phase 4 - click-through:
open_itemand announcement lookup. - Phase 5 (optional): standalone notifications page, NIP-65 relays, pagination, patch detail view.
Each phase compiles and is usable on its own.
Phase 1 implementation notes
Files: crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}
and two additions to crates/signed_core/src/inbox.rs.
Inboxis a child entity ofBackend(inbox: Entity<Inbox>), created inBackend::newand reached viaBackend::inbox(). Nothing indesktopis wired andsigned_state::initgains no parameters. The dependency is strictly one-way:Inboxholds noBackendhandle.Backend::emitis the single funnel for everyBackendEvent. It updates the inbox throughcx.deferand then emits to the other subscribers. The defer is required: every emit site runs insideBackend::update, and the inbox handlers readBackend, so a synchronous call panics on a re-entrant entity access.- The signer lifecycle lives in
Backend::sync_inbox, called fromcreate_identity,set_signerandlogout. It starts the subscriptions and repo-relay connects, then defersinbox.activate/inbox.reset.SignerChanged/SignerRequiredare still emitted forCheckoutsStoreandSidebarPanel, but no longer drive the inbox. InboxmirrorsRepoListStore:RefreshGatecoalescing,cx.background_spawnfor the database work, plain data applied on the main thread, refresh-on-NostrUpdate/Synced/Published.- Added
state_loaded: bool, not in the sketch. Groups are derived from the read state, so a refresh before the stored state is read would briefly mark everything unread. The first refresh is chained afterload_state, and laterrefreshcalls are ignored untilstate_loadedis set. - Account switches are guarded.
activateandresetboth replaceself.refreshwith a freshRefreshGate, dropping any in-flight or pending run of the previous user, and the apply step ofrun_refreshaborts instead of applying whenBackend::current_user()no longer matches the user the query was started for. - Two additions to
signed_core::inboxthat Phase 1 needs:InboxReadState::mark_archived(mirrorsmark_read) andInboxItem::apply_state(recomputesunread_ids/archived;groupnow uses it). Both are covered by tests. - The thread-root lookup is built by walking every
e/Eancestor transitively (fetch_notifications) rather than a single hop, because a patch series chains through parent patches. Only the notification events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is not mistaken for a notification. - The read/archive state event is written to LMDB only (
database().save_event), signed with a freshKeys::generate()on each save and never published. Filtering is bydtag only, no author, so the random key is irrelevant across sessions.dtag usesme.to_hex()rather thanDisplay. - Actions:
mark_read(root),mark_archived(root),mark_all_read(). Each marks the group, advances the relevant cutoffs against all notification events (matching GitWorkshop's use ofallEvents), re-derives the groups locally so the UI updates immediately, then persists in the background. - The store keeps no derived state. The signing key is generated per save, the current user is read
from
Backend::current_user()where needed, and the relays of the user's own repositories are queried fromRepoListStoreinBackend::sync_inboxrather than cached. There is no prune logic either: the newest state event is selected bycreated_at. Inbox::activate/Inbox::resetarepub(crate); the formersubscribe_remoteandconnect_own_repo_relaysmethods were deleted once their work moved intoBackend::sync_inbox.cargo test -p signed_corepasses (68 tests),cargo test -p signed_statepasses (24 tests);cargo clippy -p signed_state --all-targetsis clean;cargo check --workspacesucceeds.
Phase 2 implementation notes
Files: crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}. No store changes.
InboxViewis a plain center panel likeRepoListView. It owns anEntity<InboxStore>and drives it; the sidebar holds aWeakEntity<InboxView>so there is no cycle. The panel'sBackendsubscription also resetsshow_allon a signer change. Re-rendering relies on GPUI's render-time entity tracking rather than explicit observations.- The layout is one
overflow_y_scrollbarcontainer with a two-columnh_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 roundedv_flexwith 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.addressthrough a linear scan ofRepoListStore::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_labelmap aKindto aCustomIconName/IconNameand a short noun. The cover note is compared with==rather than matched, sinceKindcannot appear in a pattern arm.- Sidebar:
open_inboxmirrorsopen_explore(return if open, else add a center panel); the inbox nav item is repointed and carries aCountBadgesuffix 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_dialogbecamepub(crate) modinsidebarso the inbox New button reuses it.cargo clippy -p workspace --all-targetsis clean andcargo check --workspace --all-targetssucceeds.cargo test -p signed_core(68) andcargo 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
Inboxis now thin:state: InboxReadState,state_loaded, and theunread_countthe sidebar badge reads, plus the NIP-78 load/save and the mark actions. InboxStoreis created byInboxViewand owns the query, grouping, activity list, refresh gate and actions. It holds no subscriptions: the panel observes the globalInboxand subscribes toBackend, driving the store. Re-renders rely on GPUI's render-time entity tracking.Backend::emitis gone. AllBackendEvents are emitted withcx.emitagain, andsync_inboxupdates the inbox synchronously, passing the client in so nothing readsBackendmid-update.InboxViewobserves only its store; the two observations it used to hold moved into the store.signed_coreis unchanged.cargo test -p signed_core(68),cargo test -p signed_state(24) andcargo test -p workspace(7) pass; clippy andcargo check --workspace --all-targetsare 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): theInbox/InboxStorepaths 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-targetsandcargo check --workspace --all-targetsafter 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 /
Publishedevents). Restart to confirm the read state is read back from LMDB.
9. SDK APIs used (verified in the pinned 5c669a4 checkout)
Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}- Non-obvious:
Filter::pubkey/pubkeysset the lowercaseptag, notauthors. UseFilter::author/authorsfor authorship. Thenotificationsfilter relies on this.
- Non-obvious:
SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}: NIP-22 root/parent pointersTags::{event_ids, public_keys, coordinates, identifier, hashtags}iteratorsClient::{database, subscribe, sync, notifications, send_event, add_relay};NostrDatabase::{save_event, query};NostrLmdb,NostrGossipMemoryEventBuilder::{new, tags, finalize},Tag::identifier,Keys::generateTimestamp,EventId(hex serde),PublicKey,Coordinate- Fetch paths converge on the same notification:
client.subscribe(...)and negentropyclient.sync(...)both persist received events to LMDB and surface them asClientNotification::Event, whichBackend's pump batches intoBackendEvent::NostrUpdate. This is why the query right after a fetch is a cache read, not a race.