31 KiB
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
/notificationspage. It is not. GitWorkshop'sIndexroute (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-targetsclean,cargo check --workspacesucceeds. 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:
if (account) return <Dashboard />;
return <LandingPage />;
Dashboard.tsx layout:
- Desktop: two columns.
- Left column:
GreetingHeader,NotificationsPanel,RecentActivitySection. - Right column:
MyRepositoriesPanel,AccessiblePrivateRepositoriesPanel,FollowedReposPanel.
- Left column:
- 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
Inboxicon).
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):
/// 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):
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
eparent patch, else itself - NIP-22 comment (1111): uppercase
Eroot pointer (SDKnip22::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:
#[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.
/// 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.
// 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:
/// 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 inNOTIFICATION_KINDS, isKind::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:
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:
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 fromclient.database().- Query
filters::deletions(), buildDeletions, skip deleted events. - Build
HashMap<EventId, Event>for root walking; group the notification events withinbox::group. - Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their
Ktag 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 setnotifications,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: addserde.workspacefor theInboxReadStatederives.signed_state: addserde_json.workspacefor 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 searchInputState(same pattern asRepoListView) and a New button opening the existingcreate_repo_dialog. Rows openopen_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:
/// 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
InboxFilterViewpanel taking a modeInboxFilter::Unread | InboxFilter::Archivedand theEntity<Inbox>. It renders the matching subset ofInbox::notificationsas a list. - The Unread and Archived header buttons in
InboxViewcalladd_bottom_panelwith the requested mode.InboxViewkeepsfilter_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>>(mirrorsexplore). -
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:
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.observeBackend::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:
-
In
repo_detail/mod.rs, add: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 */ } } -
open_repo_panelalready returnsEntity<RepoDetailView>; the caller invokesdetail.update_in(window, cx, |detail, window, cx| detail.open_item(...)). -
InboxViewresolvesitem.addressto anAnnouncementfromRepoListStore, opens the repo panel, then callsopen_itemwith 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
- Phase 0 - pure logic:
signed_corefilters andinbox.rsplus tests. DONE. Implemented asfilters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}andinbox::{InboxItem, notification_root, group, InboxReadState}. Two deviations from the sketch: the cutoff methods take an explicitnow: Timestampso the pure logic stays deterministic and testable, andauthored_activityresults must pass throughis_git_activitybefore display (comments on non-git roots are matched by the filter).cargo test -p signed_corepasses (66 tests at the end of Phase 0; 68 after the two Phase 1 additions). - Phase 1 - store:
Inboxchild entity, activated byBackend::sync_inboxonce a signer exists; both queries, unread count, and NIP-78 load/save to LMDB. DONE. See the implementation notes below. - Phase 2 - screen:
InboxView(inbox + activity + my repositories), sidebar nav and badge. - Phase 3 - sub-views:
add_bottom_panelandInboxFilterViewfor Unread / Archived. - Phase 4 - click-through:
open_itemand announcement lookup. - 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.
Inboxis a child entity ofBackend(inbox: Entity<Inbox>), created inBackend::newand reached viaBackend::inbox(). Nothing indesktopis wired andsigned_state::initgains no parameters. The dependency is strictly one-way:Inboxholds noBackendhandle.Backend::emitis the single funnel for everyBackendEvent. It updates the inbox throughcx.deferand then emits to the other subscribers. The defer is required: every emit site runs insideBackend::update, and the inbox handlers readBackend, so a synchronous call panics on a re-entrant entity access.- The signer lifecycle lives in
Backend::sync_inbox, called fromcreate_identity,set_signerandlogout. It starts the subscriptions and repo-relay connects, then defersinbox.activate/inbox.reset.SignerChanged/SignerRequiredare still emitted forCheckoutsStoreandSidebarPanel, but no longer drive the inbox. InboxmirrorsRepoListStore:RefreshGatecoalescing,cx.background_spawnfor 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 afterload_state, and laterrefreshcalls are ignored untilstate_loadedis set. - Account switches are guarded.
activateandresetboth replaceself.refreshwith a freshRefreshGate, dropping any in-flight or pending run of the previous user, and the apply step ofrun_refreshaborts instead of applying whenBackend::current_user()no longer matches the user the query was started for. - Two additions to
signed_core::inboxthat Phase 1 needs:InboxReadState::mark_archived(mirrorsmark_read) andInboxItem::apply_state(recomputesunread_ids/archived;groupnow uses it). Both are covered by tests. - The thread-root lookup is built by walking every
e/Eancestor 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 freshKeys::generate()on each save and never published. Filtering is bydtag only, no author, so the random key is irrelevant across sessions.dtag usesme.to_hex()rather thanDisplay. - 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 ofallEvents), 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 fromRepoListStoreinBackend::sync_inboxrather than cached. There is no prune logic either: the newest state event is selected bycreated_at. Inbox::activate/Inbox::resetarepub(crate); the formersubscribe_remoteandconnect_own_repo_relaysmethods were deleted once their work moved intoBackend::sync_inbox.cargo test -p signed_corepasses (68 tests),cargo test -p signed_statepasses (24 tests);cargo clippy -p signed_state --all-targetsis clean;cargo check --workspacesucceeds.
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): theInboxstore paths that do not need GPUI (state round-trip, grouping helpers).cargo clippy -p signed_state --all-targetsandcargo check --workspaceafter 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 /
Publishedevents). 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/pubkeysset the lowercaseptag, notauthors. UseFilter::author/authorsfor authorship. Thenotificationsfilter relies on this.
- Non-obvious:
SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}: NIP-22 root/parent pointersTags::{event_ids, public_keys, coordinates, identifier, hashtags}iteratorsClient::{database, subscribe, sync, notifications, send_event, add_relay};NostrDatabase::{save_event, query};NostrLmdb,NostrGossipMemoryEventBuilder::{new, tags, finalize},Tag::identifier,Keys::generateTimestamp,EventId(hex serde),PublicKey,Coordinate- Fetch paths converge on the same notification:
client.subscribe(...)and negentropyclient.sync(...)both persist received events to LMDB and surface them asClientNotification::Event, whichBackend's pump batches intoBackendEvent::NostrUpdate. This is why the query right after a fetch is a cache read, not a race.