# 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.
> **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`:
```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.to_hex())
}
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result