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

663 lines
31 KiB
Markdown

# 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`:
```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`):
```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<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):
```rust
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:
```rust
#[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.
```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<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.
```rust
// 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: **`Backend``Inbox`**. `Inbox` holds no `Backend` handle, so
there is no reference cycle and no `cx.subscribe`. Two mechanisms connect them.
**`Backend::emit`** is the single funnel for every `BackendEvent`. It updates the inbox on a
deferred effect and then emits to the other subscribers:
```rust
/// Update the inbox, then emit `event` to the other stores.
fn emit(&self, event: BackendEvent, cx: &mut Context<Self>) {
let inbox = self.inbox.downgrade();
let inbox_event = event.clone();
cx.defer(move |cx| {
if let Err(error) = inbox.update(cx, |inbox, cx| {
inbox.handle_backend_event(&inbox_event, cx);
}) {
log::warn!("inbox dropped before handling backend event: {error}");
}
});
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:
```rust
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:
```rust
pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else { return };
// clear notifications, activity, unread_count; state = default; state_loaded = false;
// self.refresh = RefreshGate::default();
self.load_state(me, cx);
}
```
`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`:
```rust
/// 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:
```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` `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:
```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<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.