add inbox view

This commit is contained in:
2026-09-11 09:35:38 +07:00
parent b7221ef814
commit e63e58c125
7 changed files with 1119 additions and 346 deletions
+194 -126
View File
@@ -6,11 +6,13 @@ Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for
> 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`:
> **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 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).
> `cargo test -p workspace` (7), `cargo clippy -p workspace --all-targets` clean,
> `cargo check --workspace --all-targets` succeeds.
> 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 global `Inbox` and a panel-scoped
> `InboxStore`.
## 1. What the GitWorkshop home screen is
@@ -267,12 +269,14 @@ key in the store (see §4.3). An earlier implementation deleted the previous eve
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`
### 4.3 `signed_state`: a thin global `Inbox` and a panel-scoped `InboxStore`
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.
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.
```rust
// backend.rs
@@ -282,80 +286,90 @@ pub struct Backend {
}
// inbox.rs
#[derive(Default)]
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,
/// 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);
}
```
`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:
**`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.
```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();
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>,
}
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);
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);
}
```
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(...)`.
`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.
`Inbox::handle_backend_event` reacts to only three shapes:
**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.
- `NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`, is
`Kind::Comment`, or is a deletion (`EventDeletion` / `RequestToVanish`).
- `Synced` / `Published`: refresh.
**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 in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
a deletion (`EventDeletion` / `RequestToVanish`).
- `Synced` / `Published`.
- 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:
**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:
```rust
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
if let Some(me) = self.current_user {
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);
@@ -373,38 +387,26 @@ fn sync_inbox(&mut self, cx: &mut Context<Self>) {
}
}
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}");
}
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.) 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`.
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `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:
**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:
```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);
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
}
```
@@ -413,11 +415,11 @@ pub(crate) fn activate(&mut self, cx: &mut Context<Self>) {
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.
**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** (mirrors `RepoListStore::run_refresh`):
**Refresh** (`InboxStore::run_refresh`, mirrors `RepoListStore::run_refresh`):
- `cx.background_spawn`: query the notification filters and the activity filter from
`client.database()`.
@@ -428,21 +430,26 @@ wait on the network; see the note below.
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()`.
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, publish
the unread count to the global `Inbox`, `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 inbox refreshes. This was
`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()`. 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).
**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)`.
@@ -457,19 +464,24 @@ signed with a fresh random key (see 4.2).
### 5.1 `InboxView` center panel
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
One scrollable two-column flex row.
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 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
- **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 search `InputState` (same
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
open `open_repo_panel`.
open `open_repo_panel`. Empty state "No repositories yet."; without a signer it says
"Sign in to see your repositories.".
No greeting header.
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
@@ -490,17 +502,19 @@ pub fn add_bottom_panel(
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.
`Entity<InboxStore>`. It renders the matching subset of `InboxStore::notifications` as a list.
- The **Unread** and **Archived** header buttons in `InboxView` (added in Phase 3) 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. Until then the inbox header has only **Mark all read**.
### 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.
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`) and `unread: usize`.
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds
a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab).
- Point the existing nav item at it and add an unread suffix:
```rust
@@ -509,7 +523,8 @@ In `views/sidebar/mod.rs`:
.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.
- `cx.observe` `Backend::global(cx).read(cx).inbox()` so the badge follows the count the store
publishes.
### 5.4 Click-through (P1)
@@ -551,16 +566,19 @@ patch-root click opens the repo panel. Note as a known limitation.
| `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/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 and `InboxFilterView` |
| `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` field, `open_inbox`, nav wiring and badge |
| `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.
@@ -577,6 +595,7 @@ activates the `Inbox` child entity at each signer transition.
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.
**DONE.** See the implementation notes below.
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
@@ -631,16 +650,65 @@ and two additions to `crates/signed_core/src/inbox.rs`.
- `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.
### Phase 2 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
- `InboxView` is a plain center panel like `RepoListView`. It owns an `Entity<InboxStore>` and
drives it; the sidebar holds a `WeakEntity<InboxView>` so there is no cycle. The panel's `Backend`
subscription also resets `show_all` on a signer change. Re-rendering relies on GPUI's render-time
entity tracking rather than explicit observations.
- The layout is one `overflow_y_scrollbar` container with a two-column `h_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 rounded `v_flex` with 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.address` through a linear scan of
`RepoListStore::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_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
nav item is repointed and carries a `CountBadge` suffix 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_dialog` became `pub(crate) mod` in `sidebar` so the inbox New button reuses it.
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
succeeds. `cargo test -p signed_core` (68) and `cargo 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 `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, and the `unread_count` the
sidebar badge reads, plus the NIP-78 load/save and the mark actions.
- `InboxStore` is created by `InboxView` and owns the query, grouping, activity list, refresh gate
and actions. It holds no subscriptions: the panel observes the global `Inbox` and subscribes to
`Backend`, driving the store. Re-renders rely on GPUI's render-time entity tracking.
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
- `InboxView` observes only its store; the two observations it used to hold moved into the store.
- `signed_core` is unchanged. `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and
`cargo test -p workspace` (7) pass; clippy and `cargo check --workspace --all-targets` are 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): 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.
- `cargo test -p signed_state` (24 tests): the `Inbox` / `InboxStore` paths 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-targets` and
`cargo check --workspace --all-targets` after 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 / `Published` events). Restart to confirm the read state is read back from LMDB.
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)