add inbox view
This commit is contained in:
+194
-126
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user