22 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.
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}")
}
/// Newest stored read state for `me`, with the id of the event it came from.
async fn load_state(
client: &Client,
me: PublicKey,
) -> Result<Option<(InboxReadState, EventId)>> {
// No author filter: the signing key is random per session.
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(|e| e.created_at) else {
return Ok(None);
};
Ok(serde_json::from_str(&event.content)
.ok()
.map(|state| (state, event.id)))
}
/// Sign with the random `keys` and store locally.
async fn save_state(
client: &Client,
keys: &Keys,
me: PublicKey,
state: &InboxReadState,
) -> Result<EventId> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(keys)?; // synchronous: random keys, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(event.id)
}
Kind 30078 is addressable, so saves signed by the same keys replace the previous event. Because
keys is random per session, the first save of a session creates a new coordinate; the store then
deletes the event it loaded (its id is kept from load_state) so exactly one state event remains.
NostrDatabase::{save_event, query} and Client::database() are existing SDK APIs.
4.3 signed_state: one InboxStore
One store backs the whole screen, modelled on RepoListStore (repos.rs):
pub struct InboxStore {
/// 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,
user: Option<PublicKey>,
/// Random keypair signing the local NIP-78 storage event.
keys: Keys,
/// Event id of the loaded state event, pruned on the next save.
loaded_state: Option<EventId>,
refresh: RefreshGate,
_subscription: Subscription,
}
Lifespan: idle until a signer exists. The store is created in signed_state::init like the
other stores, but it does nothing until the user has a signer. It is never wired from the desktop
crate, and signed_state::init gains no parameters.
newschedulescx.defer, like the other stores. The deferred bootstrap checksBackend::current_user():- signer already present (session restored before the store was created): activate now;
- no signer: do nothing, wait for the event.
BackendEvent::SignerChanged: activate.BackendEvent::SignerRequired(logout / no credential): clearuser,notifications,activity,unread_count.BackendEvent::NostrUpdate(updates): refresh when any update kind is inNOTIFICATION_KINDS, isKind::Comment, or is a deletion.BackendEvent::Synced: refresh.BackendEvent::Published: refresh.
Activation (only on signer):
fn activate(&mut self, me: PublicKey, cx: &mut Context<Self>) {
self.user = Some(me);
// Load the NIP-78 state from LMDB, then subscribe and refresh.
// All run on background tasks; only plain data crosses back.
self.load_state(me, cx);
self.subscribe_remote(cx);
self.refresh(cx);
}
Loading uses the random keys only for signing on save; reading the state event needs no signer at
all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey.
Fetch (reuses Backend::subscribe_bootstrap / connect_repo_relays):
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let Some(me) = self.user else { return };
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(filters::notifications(me), cx);
backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
});
// Relays of the user's own repos, so their activity there is found too.
let relays = own_repo_relays(me, cx);
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
});
}
own_repo_relays reads RepoListStore::global(cx).read(cx).announcements_of(&me) and unions their
relays. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.)
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: set
notifications,activity,unread_count,cx.notify(),refresh.finish().
Actions: mark_read(root), mark_all_read(), plus advance_read after marking to bound the
read_ids set. After each change, sign with the random keys and save the NIP-78 event to LMDB,
then delete the previously loaded copy (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:
InboxStore::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 theInboxStore. It renders the matching subset ofInboxStore::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.observetheInboxStoreso 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 |
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: InboxStore, global, signer-gated activation, NIP-78 load/save, actions |
crates/signed_state/src/lib.rs |
mod inbox;, set global in init |
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; the store
bootstraps itself via cx.defer once a signer is present.
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 (62 tests). - Phase 1 - store:
InboxStorewith signer-gated activation, both queries, unread count, and NIP-78 load/save to LMDB, global install. - 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.
8. Validation
cargo test -p signed_core: root resolution, grouping, read-state cutoff, serde round-trip.cargo test -p signed_state: NIP-78 content round-trip (serde_json), if a non-GPUI path is factored out.cargo 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