# 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 `` when > an account is active, and that home screen is the inbox. ## 1. What the GitWorkshop home screen is `Index.tsx`: ```tsx if (account) return ; return ; ``` `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`): ```rust /// 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 { 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): ```rust pub struct InboxItem { pub root: EventId, pub root_kind: Option, pub address: Option, /// Events in the group, newest first. pub events: Vec, /// Unread event ids, oldest first. pub unread_ids: Vec, 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, ) -> Option; /// Group notification events by root, newest activity first, self excluded. pub fn group( events: impl IntoIterator, me: PublicKey, state: &InboxReadState, lookup: &impl Fn(EventId) -> Option, ) -> Vec; ``` 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: ```rust #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct InboxReadState { #[serde(default)] pub read_before: Timestamp, #[serde(default)] pub read_ids: HashSet, #[serde(default)] pub archived_before: Timestamp, #[serde(default)] pub archived_ids: HashSet, } 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. ```rust /// d tag identifying the inbox read/archive state event of `me`. fn inbox_state_d_tag(me: PublicKey) -> String { format!("signed-inbox-state:{me}") } /// Newest stored read state for `me`, with the id of the event it came from. async fn load_state( client: &Client, me: PublicKey, ) -> Result> { // No author filter: the signing key is random per session. 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(|e| e.created_at) else { return Ok(None); }; Ok(serde_json::from_str(&event.content) .ok() .map(|state| (state, event.id))) } /// Sign with the random `keys` and store locally. async fn save_state( client: &Client, keys: &Keys, me: PublicKey, state: &InboxReadState, ) -> Result { let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) .tags([Tag::identifier(inbox_state_d_tag(me))]) .finalize(keys)?; // synchronous: random keys, no user signer // Local-only: no `send_event`, no broadcast. The event lives in LMDB. client.database().save_event(&event).await?; Ok(event.id) } ``` Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because `keys` is random per session, the first save of a session creates a new coordinate; the store then deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains. `NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. ### 4.3 `signed_state`: one `InboxStore` One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`): ```rust pub struct InboxStore { /// Activity directed at the user, grouped by thread root, newest first. pub notifications: Arc>, /// The user's own recent git activity, newest first. pub activity: Arc>, /// Unread notification count (non-archived). pub unread_count: usize, state: InboxReadState, user: Option, /// Random keypair signing the local NIP-78 storage event. keys: Keys, /// Event id of the loaded state event, pruned on the next save. loaded_state: Option, refresh: RefreshGate, _subscription: Subscription, } ``` **Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the other stores, but it does nothing until the user has a signer. It is never wired from the `desktop` crate, and `signed_state::init` gains no parameters. - `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks `Backend::current_user()`: - signer already present (session restored before the store was created): activate now; - no signer: do nothing, wait for the event. - `BackendEvent::SignerChanged`: activate. - `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`, `activity`, `unread_count`. - `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is a deletion. - `BackendEvent::Synced`: refresh. - `BackendEvent::Published`: refresh. **Activation** (only on signer): ```rust fn activate(&mut self, me: PublicKey, cx: &mut Context) { self.user = Some(me); // Load the NIP-78 state from LMDB, then subscribe and refresh. // All run on background tasks; only plain data crosses back. self.load_state(me, cx); self.subscribe_remote(cx); self.refresh(cx); } ``` Loading uses the random `keys` only for signing on save; reading the state event needs no signer at all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey. **Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`): ```rust fn subscribe_remote(&mut self, cx: &mut Context) { let Some(me) = self.user else { return }; let backend = Backend::global(cx); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(filters::notifications(me), cx); backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); }); // Relays of the user's own repos, so their activity there is found too. let relays = own_repo_relays(me, cx); backend.update(cx, |backend, cx| { backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx); backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); }); } ``` `own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their `relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.) **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` 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: set `notifications`, `activity`, `unread_count`, `cx.notify()`, `refresh.finish()`. **Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the `read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB, then delete the previously loaded copy (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**: `InboxStore::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`: ```rust /// Add an already-wrapped panel handle to the bottom dock of `area`. pub fn add_bottom_panel( area: &mut DockArea, panel: Arc, window: &mut Window, cx: &mut Context, ) { 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 `InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list. - The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the requested mode. `InboxView` keeps `filter_view: Option>`; when it already exists, update its mode and focus instead of adding a duplicate. ### 5.3 Sidebar In `views/sidebar/mod.rs`: - Add `inbox: Option>` (mirrors `explore`). - Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel. - Point the existing nav item at it and add an unread suffix: ```rust 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` the `InboxStore` 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: ```rust 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, ) { /* open IssueDetailView / PullRequestDetailView in the dock */ } } ``` 2. `open_repo_panel` already returns `Entity`; 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` | | `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**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions | | `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` | | `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; the store bootstraps itself via `cx.defer` once a signer is present. ## 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 (62 tests). 2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and NIP-78 load/save to LMDB, global install. 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. ## 8. Validation - `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip. - `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is factored out. - `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`