Files
signed/docs/inbox-plan.md
T
2026-09-11 08:52:04 +07:00

31 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 /notifications 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: 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).

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.
  • 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 Inbox icon).

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 e parent patch, else itself
  • NIP-22 comment (1111): uppercase E root pointer (SDK nip22::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: Inbox, a child entity of Backend

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.

// backend.rs
pub struct Backend {
    ...
    inbox: Entity<Inbox>,
}

// inbox.rs
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,
}

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: BackendInbox. 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:

/// 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);
}

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(...).

Inbox::handle_backend_event reacts to only three shapes:

  • NostrUpdate(updates): refresh when any update kind is in NOTIFICATION_KINDS, is Kind::Comment, or is a deletion (EventDeletion / RequestToVanish).
  • Synced / Published: refresh.
  • 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:

fn sync_inbox(&mut self, cx: &mut Context<Self>) {
    if let Some(me) = self.current_user {
        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 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}");
        }
    });
}

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.

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:

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);
}

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, 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.

Refresh (mirrors RepoListStore::run_refresh):

  • cx.background_spawn: query the notification filters and the activity filter from client.database().
  • Query filters::deletions(), build Deletions, skip deleted events.
  • Build HashMap<EventId, Event> for root walking; group the notification events with inbox::group.
  • Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when 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().

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 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).

My repositories needs no new store: RepoListStore already holds every announcement and exposes announcements_of(user).

4.4 Cargo.toml

  • signed_core: add serde.workspace for the InboxReadState derives.
  • signed_state: add serde_json.workspace for the NIP-78 content.

5. UI

5.1 InboxView center panel

New crates/workspace/src/views/inbox.rs, a BasePanel + Panel + Render, like RepoListView. One scrollable two-column flex row.

  • 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 pattern as RepoListView) and a New button opening the existing create_repo_dialog. Rows open open_repo_panel.

No greeting header.

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 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.

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.

  • 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.observe Backend::global(cx).read(cx).inbox() so the badge updates.

5.4 Click-through (P1)

Reuse the single RepoStore that RepoDetailView already creates instead of making a second one:

  1. 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 */ }
    }
    
  2. open_repo_panel already returns Entity<RepoDetailView>; the caller invokes detail.update_in(window, cx, |detail, window, cx| detail.open_item(...)).

  3. InboxView resolves item.address to an Announcement from RepoListStore, opens the repo panel, then calls open_item with 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 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/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/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/repo_detail/mod.rs RepoItem, RepoDetailView::open_item (P1)

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

  1. Phase 0 - pure logic: signed_core filters and inbox.rs plus tests. DONE. Implemented as filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity} and inbox::{InboxItem, notification_root, group, InboxReadState}. Two deviations from the sketch: the cutoff methods take an explicit now: Timestamp so the pure logic stays deterministic and testable, and authored_activity results must pass through is_git_activity before display (comments on non-git roots are matched by the filter). cargo test -p signed_core passes (66 tests at the end of Phase 0; 68 after the two Phase 1 additions).
  2. Phase 1 - store: Inbox child entity, activated by Backend::sync_inbox once a signer 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.
  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 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.

  • Inbox is a child entity of Backend (inbox: Entity<Inbox>), created in Backend::new and reached via Backend::inbox(). Nothing in desktop is wired and signed_state::init gains no parameters. The dependency is strictly one-way: Inbox holds no Backend handle.
  • Backend::emit is the single funnel for every BackendEvent. It updates the inbox through cx.defer and then emits to the other subscribers. The defer is required: every emit site runs inside Backend::update, and the inbox handlers read Backend, so a synchronous call panics on a re-entrant entity access.
  • The signer lifecycle lives in Backend::sync_inbox, called from create_identity, set_signer and logout. It starts the subscriptions and repo-relay connects, then defers inbox.activate / inbox.reset. SignerChanged / SignerRequired are still emitted for CheckoutsStore and SidebarPanel, but no longer drive the inbox.
  • Inbox mirrors RepoListStore: RefreshGate coalescing, cx.background_spawn for 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 after load_state, and later refresh calls are ignored until state_loaded is set.
  • Account switches are guarded. activate and reset both replace self.refresh with a fresh RefreshGate, dropping any in-flight or pending run of the previous user, and the apply step of run_refresh aborts instead of applying when Backend::current_user() no longer matches the user the query was started for.
  • Two additions to signed_core::inbox that Phase 1 needs: InboxReadState::mark_archived (mirrors mark_read) and InboxItem::apply_state (recomputes unread_ids/archived; group now uses it). Both are covered by tests.
  • The thread-root lookup is built by walking every e/E ancestor 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 fresh Keys::generate() on each save and never published. Filtering is by d tag only, no author, so the random key is irrelevant across sessions. d tag uses me.to_hex() rather than Display.
  • 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 of allEvents), 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 from RepoListStore in Backend::sync_inbox rather than cached. There is no prune logic either: the newest state event is selected by created_at.
  • Inbox::activate / Inbox::reset are pub(crate); the former subscribe_remote and connect_own_repo_relays methods were deleted once their work moved into Backend::sync_inbox.
  • 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.

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.

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/pubkeys set the lowercase p tag, not authors. Use Filter::author/authors for authorship. The notifications filter relies on this.
  • SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}
  • nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}: NIP-22 root/parent pointers
  • Tags::{event_ids, public_keys, coordinates, identifier, hashtags} iterators
  • Client::{database, subscribe, sync, notifications, send_event, add_relay}; NostrDatabase::{save_event, query}; NostrLmdb, NostrGossipMemory
  • EventBuilder::{new, tags, finalize}, Tag::identifier, Keys::generate
  • Timestamp, EventId (hex serde), PublicKey, Coordinate
  • Fetch paths converge on the same notification: client.subscribe(...) and negentropy client.sync(...) both persist received events to LMDB and surface them as ClientNotification::Event, which Backend's pump batches into BackendEvent::NostrUpdate. This is why the query right after a fetch is a cache read, not a race.