Compare commits

8 Commits
Author SHA1 Message Date
reya f62fa9884d update
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-09-11 21:52:56 +07:00
reya 81e421d121 update ui 2026-09-11 21:35:03 +07:00
reya 625587fd52 . 2026-09-11 15:08:24 +07:00
reya 568b6c0e41 update
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-09-11 10:46:31 +07:00
reya 1e7cf0020c update 2026-09-11 10:22:13 +07:00
reya e63e58c125 add inbox view 2026-09-11 09:35:38 +07:00
reya b7221ef814 refacotr 2026-09-11 08:52:04 +07:00
reya b7a020767e wip 2026-09-10 20:26:17 +07:00
16 changed files with 3278 additions and 243 deletions
Generated
+197 -152
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -26,6 +26,19 @@ pub fn add_center_panel(
area.add_panel_view(panel, DockPlacement::Center, None, window, cx); area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
} }
/// Add an already-wrapped panel handle to the bottom dock of `area`.
///
/// Used for sub-views that hang under the center, such as the inbox's Unread
/// and Archived lists.
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 fixed height of the tab bar, which doubles as the window title bar. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
+4
View File
@@ -6,3 +6,7 @@ publish.workspace = true
[dependencies] [dependencies]
nostr.workspace = true nostr.workspace = true
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true
+189 -1
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use nostr::prelude::*; use nostr::prelude::*;
use crate::RepoAddr; use crate::{COVER_NOTE_KIND, RepoAddr};
/// Kinds that make up the activity of a repository. /// Kinds that make up the activity of a repository.
pub const ACTIVITY_KINDS: [Kind; 9] = [ pub const ACTIVITY_KINDS: [Kind; 9] = [
@@ -17,6 +17,41 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
Kind::GitStatusDraft, Kind::GitStatusDraft,
]; ];
/// Kinds that notify a user when they tag them via their `p` tag.
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,
];
/// Git root kinds that make a comment or cover note count as git activity.
const GIT_ROOT_KINDS: [Kind; 4] = [
Kind::GitIssue,
Kind::GitPatch,
Kind::GitPullRequest,
Kind::GitRepoAnnouncement,
];
/// Value of the first tag named `name` on `event`.
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
event
.tags
.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
}
/// Kind named by the first tag `name` on `event`.
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
tag_value(event, name)?.parse::<Kind>().ok()
}
/// Latest announcement event for a repository. /// Latest announcement event for a repository.
pub fn announcement(addr: &RepoAddr) -> Filter { pub fn announcement(addr: &RepoAddr) -> Filter {
Filter::new() Filter::new()
@@ -94,6 +129,68 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
] ]
} }
/// NIP-22 comments on our issues, patches and pull requests.
/// They are matched via the uppercase `P` and `K` tags, not authorship.
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
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
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".
///
/// A comment on an unrelated kind is matched too, so results must be filtered
/// through [`is_git_activity`] before display.
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds(
ACTIVITY_KINDS
.into_iter()
.chain(std::iter::once(COVER_NOTE_KIND)),
)
.author(me)
}
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
fn is_git_comment(event: &Event) -> bool {
event.kind == Kind::Comment
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
fn is_git_cover_note(event: &Event) -> bool {
event.kind == COVER_NOTE_KIND
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether a status event references a git root, checked via its `k` tag.
fn is_git_status(event: &Event) -> bool {
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
/// Whether `event` is git activity worth showing in the activity list.
pub fn is_git_activity(event: &Event) -> bool {
match event.kind {
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
Kind::Comment => is_git_comment(event),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => is_git_status(event),
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
}
}
/// All repository announcements, for global discovery. /// All repository announcements, for global discovery.
pub fn all_announcements() -> Filter { pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement) Filter::new().kind(Kind::GitRepoAnnouncement)
@@ -134,3 +231,94 @@ pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
Filter::new().kind(Kind::EventDeletion).coordinate(addr), Filter::new().kind(Kind::EventDeletion).coordinate(addr),
] ]
} }
#[cfg(test)]
mod tests {
use super::*;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.finalize(author)
.expect("signed event")
}
fn kind_tag(name: &str, kind: Kind) -> Tag {
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
}
#[test]
fn root_git_kinds_are_activity() {
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
}
}
#[test]
fn comment_activity_depends_on_the_uppercase_k_tag() {
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
let on_repo = signed(
&keys(1),
Kind::Comment,
vec![kind_tag("K", Kind::GitRepoAnnouncement)],
);
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
assert!(is_git_activity(&on_git));
assert!(is_git_activity(&on_repo));
assert!(!is_git_activity(&on_note));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::Comment,
Vec::new()
)));
}
#[test]
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
let status = signed(
&keys(1),
Kind::GitStatusClosed,
vec![kind_tag("k", Kind::GitPullRequest)],
);
let cover = signed(
&keys(1),
COVER_NOTE_KIND,
vec![kind_tag("k", Kind::GitPatch)],
);
let unrelated = signed(
&keys(1),
Kind::GitStatusClosed,
vec![kind_tag("k", Kind::Metadata)],
);
assert!(is_git_activity(&status));
assert!(is_git_activity(&cover));
assert!(!is_git_activity(&unrelated));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::GitStatusClosed,
Vec::new()
)));
}
#[test]
fn non_git_kinds_are_not_activity() {
assert!(!is_git_activity(&signed(
&keys(1),
Kind::TextNote,
Vec::new()
)));
assert!(!is_git_activity(&signed(
&keys(1),
Kind::GitPullRequestUpdate,
Vec::new(),
)));
}
}
+699
View File
@@ -0,0 +1,699 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{COVER_NOTE_KIND, RepoAddr};
/// Window before `now` that an advanced cutoff retreats to.
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
/// Window before `now` that a mark-all cutoff retreats to.
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
/// A thread of notification events sharing one root
#[derive(Debug, Clone)]
pub struct InboxItem {
/// The root issue, patch or pull request the notifications belong to.
pub root: EventId,
/// Kind of the root event, when it is known locally.
pub root_kind: Option<Kind>,
/// Repository the root belongs to, from the root's `a` tag.
pub address: Option<RepoAddr>,
/// Events in the group, newest first.
pub events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
/// Whether every event in the group is archived.
pub archived: bool,
}
impl InboxItem {
/// Timestamp of the newest event in the group.
pub fn latest_activity(&self) -> Timestamp {
self.events
.first()
.map(|event| event.created_at)
.unwrap_or_default()
}
/// Whether the group has an unread event still visible in the inbox.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
/// Recompute the unread and archived flags from `state`.
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
self.archived = self.events.iter().all(|event| state.is_archived(event));
}
}
/// Root issue, patch or pull request of a notification event.
///
/// Returns `None` when the event is not git-related, or when its root is a
/// coordinate rather than an event.
///
/// - issue (1621) / PR (1618): itself
/// - patch (1617): its `e` parent patch, else itself
/// - NIP-22 comment (1111): uppercase `E` root pointer
/// - PR update (1619): uppercase `E`
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
where
L: Fn(EventId) -> Option<Event>,
{
if event.kind == COVER_NOTE_KIND {
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
}
match event.kind {
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
Kind::GitPatch => Some(match first_e_id(event) {
Some(parent) => resolve_thread_root(parent, lookup),
None => event.id,
}),
Kind::Comment => match nip22::extract_root(event) {
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
_ => None,
},
Kind::GitPullRequestUpdate => {
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
}
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => {
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
}
_ => None,
}
}
/// Group notification events by root, newest activity first.
pub fn group<E, L>(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in events {
if event.pubkey == me {
continue;
}
let Some(root) = notification_root(&event, lookup) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut items: Vec<InboxItem> = groups
.into_iter()
.map(|(root, mut events)| {
events.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
let root_event = lookup(root);
let mut item = InboxItem {
root,
root_kind: root_event.as_ref().map(|event| event.kind),
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next()),
events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
/// Read and archive state of the inbox, a high-water-mark model.
#[derive(Clone, Debug, Default, PartialEq, Eq, 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 {
/// Whether `event` is at or before the read cutoff, or marked read.
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
/// Whether `event` is at or before the archived cutoff, or marked archived.
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
/// Mark one event read. Events at or before the cutoff are already read.
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
/// Mark one event archived. Events at or before the cutoff are already archived.
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
/// Mark every non-self event read, anchoring the cutoff ten days back.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advance the read cutoff to the newest point that keeps unread events
/// unread, then prune the id set.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
self.read_before = cutoff;
prune_ids(&mut self.read_ids, all, cutoff);
}
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
self.is_archived(event)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
/// Drop ids whose event is unknown or now covered by the cutoff.
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
let mut seen = HashSet::new();
let mut root = id;
loop {
if !seen.insert(root) {
return id;
}
let Some(event) = lookup(root) else {
return root;
};
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
return root;
}
match parent_id(&event) {
Some(parent) => root = parent,
None => return root,
}
}
}
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
fn parent_id(event: &Event) -> Option<EventId> {
for marker in ["reply", "root"] {
if let Some(id) = event
.tags
.iter()
.find_map(|tag| e_tag_with_marker(tag, marker))
{
return Some(id);
}
}
if let Some(id) = event.tags.iter().find_map(|tag| {
if tag.kind() != "e" {
return None;
}
let slice = tag.as_slice();
let is_mention = slice.len() == 4 && slice[3] == "mention";
if is_mention {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}) {
return Some(id);
}
first_uppercase_e_id(event)
}
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
fn nip10_root_id(event: &Event) -> Option<EventId> {
event
.tags
.iter()
.find_map(|tag| e_tag_with_marker(tag, "root"))
.or_else(|| first_e_id(event))
}
/// First `e` tag id, in document order.
fn first_e_id(event: &Event) -> Option<EventId> {
first_tag_id(event, "e")
}
/// First uppercase `E` tag id, in document order.
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
first_tag_id(event, "E")
}
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
event.tags.iter().find_map(|tag| {
if tag.kind() != name {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
/// Event id from a four-element `e` tag carrying `marker`.
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
let slice = tag.as_slice();
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
}
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
#[test]
fn issue_and_pull_request_are_their_own_root() {
let events = [
issue(&keys(1), 100),
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
];
let lookup = lookup(&events);
for event in &events {
assert_eq!(notification_root(event, &lookup), Some(event.id));
}
}
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
notification_root(&comment, &lookup(&events)),
Some(issue.id)
);
}
#[test]
fn comment_without_root_pointer_has_no_root() {
let comment = signed(
&keys(2),
Kind::Comment,
vec![e_tag(&issue(&keys(1), 100))],
200,
);
assert_eq!(notification_root(&comment, &lookup(&[])), None);
}
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
notification_root(&child_patch, &lookup(&events)),
Some(root_patch.id)
);
}
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
}
#[test]
fn pull_request_update_resolves_via_uppercase_e() {
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
let update = signed(
&keys(2),
Kind::GitPullRequestUpdate,
vec![uppercase_e_tag(&pr)],
200,
);
let events = [pr.clone(), update.clone()];
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
}
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
let events = [issue.clone(), reply, nested.clone()];
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
}
#[test]
fn group_excludes_self_and_sorts_groups_newest_first() {
let me = keys(1);
let issue = issue(&keys(2), 100);
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
let other_issue = signed(
&keys(2),
Kind::GitIssue,
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
200,
);
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
let items = group(
events,
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
);
assert_eq!(items.len(), 2);
assert_eq!(items[0].root, issue.id);
// The issue itself plus the comment; the self-authored comment is out.
assert_eq!(items[0].events.len(), 2);
assert_eq!(items[1].root, other_issue.id);
}
#[test]
fn group_reports_unread_oldest_first_and_archived() {
let me = keys(1);
let issue = issue(&keys(2), 100);
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
let events = [issue.clone(), older.clone(), newer.clone()];
let items = group(
events,
me.public_key(),
&InboxReadState::default(),
&lookup(&[]),
);
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
assert!(!items[0].archived);
assert!(items[0].is_unread());
let state = InboxReadState {
archived_before: Timestamp::from_secs(1000),
..Default::default()
};
let items = group(
[issue.clone(), older, newer],
me.public_key(),
&state,
&lookup(&[]),
);
assert!(items[0].archived);
assert!(!items[0].unread_ids.is_empty());
assert!(!items[0].is_unread());
}
#[test]
fn group_reads_root_kind_and_address_from_the_root_event() {
let me = keys(1);
let owner_keys = keys(2);
let owner = owner_keys.public_key();
let issue = signed(
&owner_keys,
Kind::GitIssue,
vec![a_tag(&owner, "my-repo")],
100,
);
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
let events = [issue.clone(), comment];
let items = group(
events.clone(),
me.public_key(),
&InboxReadState::default(),
&lookup(&events),
);
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
assert_eq!(items[0].address, issue.tags.coordinates().next());
}
#[test]
fn mark_all_read_marks_known_recent_events() {
let me = keys(1);
let now = Timestamp::from_secs(1_000_000_000);
let recent = issue(&keys(2), now.as_secs() - 1000);
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
let mine = issue(&keys(1), now.as_secs() - 100);
let mut state = InboxReadState::default();
state.mark_all_read(
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
me.public_key(),
now,
);
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
assert!(state.is_read(&recent));
assert!(state.is_read(&ancient));
assert!(!state.is_read(&mine));
}
#[test]
fn advance_read_never_moves_the_cutoff_backwards() {
let me = keys(1);
let unread = issue(&keys(2), 1_000);
let all = [unread];
let now = Timestamp::from_secs(1_000_000_000);
let mut state = InboxReadState {
read_before: Timestamp::from_secs(999_999_999),
..Default::default()
};
state.advance_read(&all, me.public_key(), now);
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
}
#[test]
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
let me = keys(1);
let now = Timestamp::from_secs(1_000_000_000);
let five_days = 5 * 24 * 60 * 60;
let old_unread = issue(&keys(2), now.as_secs() - five_days);
// Read ids that fall before and after the new cutoff.
let stale = signed(
&keys(2),
Kind::GitIssue,
Vec::new(),
now.as_secs() - five_days - 1000,
);
let fresh = signed(
&keys(2),
Kind::GitIssue,
Vec::new(),
now.as_secs() - 100_000,
);
let mut state = InboxReadState {
read_ids: HashSet::from([stale.id, fresh.id]),
..Default::default()
};
state.advance_read(
&[old_unread.clone(), stale.clone(), fresh.clone()],
me.public_key(),
now,
);
assert_eq!(state.read_before, old_unread.created_at - 1);
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
}
#[test]
fn mark_archived_skips_events_at_or_before_the_cutoff() {
let now = Timestamp::from_secs(1_000_000_000);
let event = issue(&keys(2), now.as_secs() - 1000);
let mut state = InboxReadState {
archived_before: now,
..Default::default()
};
state.mark_archived(&event);
assert!(state.archived_ids.is_empty());
let mut state = InboxReadState::default();
state.mark_archived(&event);
assert_eq!(state.archived_ids, HashSet::from([event.id]));
}
#[test]
fn apply_state_recomputes_unread_and_archived() {
let now = Timestamp::from_secs(1_000_000_000);
let first = issue(&keys(2), now.as_secs() - 2000);
let second = issue(&keys(2), now.as_secs() - 1000);
let mut item = InboxItem {
root: first.id,
root_kind: None,
address: None,
events: vec![second.clone(), first.clone()],
unread_ids: Vec::new(),
archived: false,
};
let state = InboxReadState {
read_before: first.created_at,
..Default::default()
};
item.apply_state(&state);
assert_eq!(item.unread_ids, vec![second.id]);
assert!(!item.archived);
}
#[test]
fn serde_round_trip_preserves_state() {
let first = issue(&keys(1), 100);
let second = issue(&keys(2), 200);
let state = InboxReadState {
read_before: Timestamp::from_secs(150),
read_ids: HashSet::from([second.id]),
archived_before: Timestamp::from_secs(50),
archived_ids: HashSet::from([first.id]),
};
let json = serde_json::to_string(&state).expect("serialized");
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
assert_eq!(parsed, state);
}
}
+5
View File
@@ -3,6 +3,7 @@ pub mod annotations;
pub mod clone_url; pub mod clone_url;
pub mod deletions; pub mod deletions;
pub mod filters; pub mod filters;
pub mod inbox;
pub mod model; pub mod model;
pub mod state; pub mod state;
pub mod status; pub mod status;
@@ -11,6 +12,10 @@ pub use addr::{RepoAddr, identifier_from_name, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override}; pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url}; pub use clone_url::{CloneTarget, parse_clone_url};
pub use deletions::Deletions; pub use deletions::Deletions;
pub use filters::{
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
};
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
pub use model::{ pub use model::{
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
+1
View File
@@ -22,6 +22,7 @@ flume.workspace = true
futures.workspace = true futures.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true log.workspace = true
serde_json.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23" rustls = "0.23"
+91 -55
View File
@@ -14,6 +14,8 @@ use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
use crate::inbox::Inbox;
use crate::repos::RepoListStore;
/// Keyring entry for the user credential. /// Keyring entry for the user credential.
pub const USER_KEYRING: &str = "Signed Safe Storage"; pub const USER_KEYRING: &str = "Signed Safe Storage";
@@ -32,10 +34,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// Delay the notification pump waits for more events before emitting a batch. /// Delay the notification pump waits for more events before emitting a batch.
///
/// A negentropy sync can deliver hundreds of events in a burst; batching
/// them here means every subscriber debounces the burst once, not once per
/// subscriber.
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -77,21 +75,17 @@ impl BackendEvent {
} }
} }
/// The global backend entity.
///
/// Owns the nostr client, the signer and the notification pump.
pub struct Backend { pub struct Backend {
client: Client, client: Client,
signer: UniversalSigner, signer: UniversalSigner,
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
/// User's inbox, including notifications and recent activity.
inbox: Entity<Inbox>,
/// The progress of the current sync operation, if any.
sync_progress: Option<(u64, u64)>, sync_progress: Option<(u64, u64)>,
/// True when the stored credential is NIP-49 encrypted. /// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool, passphrase_required: bool,
/// Repositories with a push in flight, mirror or checkout based. /// Repositories with a push in flight, mirror or checkout based.
///
/// A child entity: views that only care whether one repository is
/// pushing can `cx.observe` it without being invoked on unrelated
/// `Backend` changes (a `sync_progress` tick, a new relay connecting).
pushing_repos: Entity<HashSet<RepoAddr>>, pushing_repos: Entity<HashSet<RepoAddr>>,
} }
@@ -112,6 +106,7 @@ impl Backend {
} }
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self { pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
let pump_client = client.clone(); let pump_client = client.clone();
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
@@ -133,13 +128,17 @@ impl Backend {
loop { loop {
let now = Instant::now(); let now = Instant::now();
if now >= deadline { if now >= deadline {
break; break;
} }
let timer = cx.background_executor().timer(deadline - now); let timer = cx.background_executor().timer(deadline - now);
futures::pin_mut!(timer); futures::pin_mut!(timer);
let next = notifications.next(); let next = notifications.next();
futures::pin_mut!(next); futures::pin_mut!(next);
match futures::future::select(next, timer).await { match futures::future::select(next, timer).await {
futures::future::Either::Left(( futures::future::Either::Left((
Some(ClientNotification::Event { event, .. }), Some(ClientNotification::Event { event, .. }),
@@ -156,7 +155,9 @@ impl Backend {
// Collect and emit the collected events. // Collect and emit the collected events.
let batch = std::mem::take(&mut pending); let batch = std::mem::take(&mut pending);
if let Err(e) = this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))) { if let Err(e) =
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
{
log::warn!("failed to emit nostr update: {e}"); log::warn!("failed to emit nostr update: {e}");
} }
} }
@@ -167,7 +168,6 @@ impl Backend {
pump.detach(); pump.detach();
// Bootstrap the client. // Bootstrap the client.
let weak = cx.entity().downgrade();
cx.defer(move |cx| { cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) { if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
log::warn!("backend dropped before bootstrap could run: {error}"); log::warn!("backend dropped before bootstrap could run: {error}");
@@ -178,53 +178,54 @@ impl Backend {
client, client,
signer, signer,
current_user: None, current_user: None,
inbox: cx.new(|_| Inbox::default()),
sync_progress: None, sync_progress: None,
passphrase_required: false, passphrase_required: false,
pushing_repos: cx.new(|_| HashSet::new()), pushing_repos: cx.new(|_| HashSet::new()),
} }
} }
/// Bootstrap the client. /// Bootstrap the client and restore the saved session, if any.
///
/// Restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) { fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
let task = cx.background_spawn(async move { let task = cx.background_spawn(async move {
for url in BOOTSTRAP_RELAYS { for url in BOOTSTRAP_RELAYS {
client.add_relay(url).and_connect().await?; client.add_relay(url).await?;
} }
for url in INDEXER_RELAYS { for url in INDEXER_RELAYS {
client client
.add_relay(url) .add_relay(url)
.capabilities(RelayCapabilities::DISCOVERY) .capabilities(RelayCapabilities::DISCOVERY)
.and_connect()
.await?; .await?;
} }
client.connect().await;
Ok::<(), Error>(()) Ok::<(), Error>(())
}); });
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok(()) => { Ok(()) => {
this.update(cx, |_this, cx| cx.notify())?; this.update(cx, |this, cx| {
this.restore_session(cx);
})?;
} }
Err(e) => { Err(e) => {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
} }
} }
Ok(()) Ok::<(), Error>(())
}); });
notify_task.detach(); notify_task.detach();
self.restore_session(cx);
} }
/// Restore the saved session from the keyring. /// Restore the saved session from the Keyring.
/// ///
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored. /// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
/// /// - Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
pub fn restore_session(&mut self, cx: &mut Context<Self>) { pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
@@ -237,7 +238,7 @@ impl Backend {
let content = match user.await { let content = match user.await {
Ok(Some((_username, secret))) => String::from_utf8(secret)?, Ok(Some((_username, secret))) => String::from_utf8(secret)?,
_ => { _ => {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
return Ok(()); return Ok(());
} }
}; };
@@ -258,15 +259,13 @@ impl Backend {
signer.auth_url_handler(SignedAuthUrlHandler); signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?; this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") { } else if content.starts_with("ncryptsec1") {
// Encrypted identity.
// A passphrase is required to decrypt it before the session can resume. // A passphrase is required to decrypt it before the session can resume.
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.passphrase_required = true; this.passphrase_required = true;
cx.emit(BackendEvent::PassphraseRequired); cx.emit(BackendEvent::PassphraseRequired);
})?; })?;
} else { } else {
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
} }
Ok::<_, Error>(()) Ok::<_, Error>(())
@@ -274,7 +273,7 @@ impl Backend {
.await; .await;
if let Err(e) = result { if let Err(e) = result {
this.update(cx, |_, cx| { this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::error(e.to_string()));
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
})?; })?;
@@ -360,7 +359,10 @@ impl Backend {
this.signer.swap_inner(keys); this.signer.swap_inner(keys);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
this.sync_inbox(cx);
cx.notify(); cx.notify();
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [ let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
@@ -968,9 +970,7 @@ impl Backend {
} else if credential.starts_with("bunker://") { } else if credential.starts_with("bunker://") {
self.login_with_bunker(credential, cx); self.login_with_bunker(credential, cx);
} else { } else {
cx.emit(BackendEvent::error( cx.emit(BackendEvent::error("Unsupported credential."));
"Unsupported credential, expected nsec1... or bunker://...",
));
} }
} }
@@ -999,7 +999,7 @@ impl Backend {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
if let Err(e) = write.await { if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(()); return Ok(());
} }
this.update(cx, |this, cx| this.set_signer(keys, cx))?; this.update(cx, |this, cx| this.set_signer(keys, cx))?;
@@ -1045,7 +1045,7 @@ impl Backend {
.await; .await;
if let Err(e) = result { if let Err(e) = result {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
} }
Ok(()) Ok(())
@@ -1066,6 +1066,7 @@ impl Backend {
this.passphrase_required = false; this.passphrase_required = false;
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
cx.emit(BackendEvent::SignerRequired); cx.emit(BackendEvent::SignerRequired);
this.sync_inbox(cx);
cx.notify(); cx.notify();
})?; })?;
@@ -1096,7 +1097,7 @@ impl Backend {
.await; .await;
if let Err(e) = result { if let Err(e) = result {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
} }
Ok(()) Ok(())
@@ -1121,6 +1122,13 @@ impl Backend {
self.pushing_repos.clone() self.pushing_repos.clone()
} }
/// The inbox child entity backing the home screen.
///
/// A child entity: `cx.observe` it to react only to inbox changes.
pub fn inbox(&self) -> Entity<Inbox> {
self.inbox.clone()
}
/// Get the current user's public key. /// Get the current user's public key.
pub fn current_user(&self) -> Option<PublicKey> { pub fn current_user(&self) -> Option<PublicKey> {
self.current_user self.current_user
@@ -1136,6 +1144,35 @@ impl Backend {
cx.emit(BackendEvent::error(message)); cx.emit(BackendEvent::error(message));
} }
/// Attach the inbox to the current signer and activate or clear it.
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
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);
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);
}
}
self.inbox.update(cx, |inbox, cx| match me {
Some(me) => inbox.activate(me, client, cx),
None => inbox.reset(cx),
});
}
/// Progress of the in-flight negentropy sync, if any. /// Progress of the in-flight negentropy sync, if any.
pub fn sync_progress(&self) -> Option<(u64, u64)> { pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress self.sync_progress
@@ -1149,7 +1186,7 @@ impl Backend {
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static, <T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static, <T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
{ {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
match new_signer.get_public_key_async().await { match new_signer.get_public_key_async().await {
Ok(public_key) => { Ok(public_key) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -1158,6 +1195,7 @@ impl Backend {
this.passphrase_required = false; this.passphrase_required = false;
this.bootstrap_user(public_key, cx); this.bootstrap_user(public_key, cx);
cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerChanged);
this.sync_inbox(cx);
cx.notify(); cx.notify();
})?; })?;
} }
@@ -1168,15 +1206,12 @@ impl Backend {
} }
} }
Ok(()) Ok::<(), Error>(())
}); })
task.detach(); .detach();
} }
/// Connect to a repository's announced relays, its NIP-34 `relays` tag. /// Connect to a repository's announced relays, its NIP-34 `relays` tag.
///
/// Callers are responsible for not repeating this for relays they already
/// connected, e.g. `RepoStore::repo_relays`.
pub fn connect_repo_relays( pub fn connect_repo_relays(
&mut self, &mut self,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
@@ -1185,13 +1220,13 @@ impl Backend {
) { ) {
let client = self.client.clone(); let client = self.client.clone();
let task: Task<Result<(), Error>> = cx.spawn(async move |_this, _cx| { cx.spawn(async move |_this, _cx| {
if let Err(e) = connect_repo_relays(&client, relays, filters).await { if let Err(e) = connect_repo_relays(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}"); log::warn!("repo relay fetch failed: {e}");
} }
Ok(()) Ok::<(), Error>(())
}); })
task.detach(); .detach();
} }
/// One-shot subscription on the bootstrap relays only. /// One-shot subscription on the bootstrap relays only.
@@ -1201,24 +1236,25 @@ impl Backend {
let fetch = let fetch =
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
if let Err(e) = fetch.await { if let Err(e) = fetch.await {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})?;
} }
Ok(()) Ok::<(), Error>(())
}); })
task.detach(); .detach();
} }
/// Negentropy-sync the given filter against the bootstrap relays. /// Negentropy-sync the given filter against the bootstrap relays.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel();
self.sync_progress = Some((0, 0)); self.sync_progress = Some((0, 0));
cx.notify(); cx.notify();
let (tx, mut rx) = SyncProgress::channel();
let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| { let progress_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0; let mut last_percent: u64 = 0;
+269
View File
@@ -0,0 +1,269 @@
use std::collections::{HashMap, HashSet};
use anyhow::Error;
use gpui::{AppContext, Context, Task};
use nostr_sdk::prelude::*;
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
use crate::backend::Backend;
/// The user's persisted inbox read state.
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
/// Set once the stored state has been read for the current user.
loaded: bool,
}
impl Inbox {
/// The current read/archive cutoffs.
pub fn state(&self) -> &InboxReadState {
&self.state
}
/// Whether the stored state has been read for the current user.
pub fn is_loaded(&self) -> bool {
self.loaded
}
/// Mark the events of one notification group read, then bound the id sets.
pub fn mark_read(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_read(event);
}
self.state.advance_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Archive one notification group. Archived events are always read too.
pub fn mark_archived(
&mut self,
group: &[Event],
all: &[Event],
me: PublicKey,
cx: &mut Context<Self>,
) {
for event in group {
self.state.mark_archived(event);
self.state.mark_read(event);
}
let now = Timestamp::now();
self.state.advance_archived(all, me, now);
self.state.advance_read(all, me, now);
self.persist(cx);
cx.notify();
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
self.state.mark_all_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Load the stored state for current user.
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
cx.notify();
let backend = Backend::global(cx);
let work = cx.background_spawn(async move { load_state(&client, me).await });
cx.spawn(async move |this, cx| {
let loaded = work.await;
this.update(cx, |this, cx| {
if backend.read(cx).current_user() != Some(me) {
return;
}
match loaded {
Ok(Some(state)) => this.state = state,
Ok(None) => this.state = InboxReadState::default(),
Err(error) => log::warn!("failed to load inbox state: {error}"),
}
this.loaded = true;
cx.notify();
})?;
Ok::<(), Error>(())
})
.detach();
}
/// Clear the state of the signed-out user.
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
cx.notify();
}
/// Sign the state with a random key and store it locally.
fn persist(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let client = Backend::global(cx).read(cx).client();
let state = self.state.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
if let Err(error) = save_state(&client, me, &state).await {
log::warn!("failed to save inbox state: {error}");
}
Ok(())
});
task.detach();
}
}
/// Derive the inbox home screen's lists for `me` from the local database.
///
/// Returns the notification groups, the user's own git activity and the number
/// of non-archived groups with an unread event.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error> {
let deletion_events = client.database().query(filters::deletions()).await?;
let deletions = Deletions::from_events(deletion_events);
let (notification_events, by_id) = fetch_notifications(client, me, &deletions).await?;
let notifications = inbox::group(notification_events, me, state, &|id| {
by_id.get(&id).cloned()
});
let unread_count = notifications.iter().filter(|item| item.is_unread()).count();
let mut activity = Vec::new();
for event in client
.database()
.query(filters::authored_activity(me))
.await?
{
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
continue;
}
activity.push(event);
}
activity.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
Ok((notifications, activity, unread_count))
}
/// `d` tag identifying the inbox state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
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 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())?;
client.database().save_event(&event).await?;
Ok(())
}
/// Notification events and a lookup of every ancestor they reference.
async fn fetch_notifications(
client: &Client,
me: PublicKey,
deletions: &Deletions,
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
let mut notifications: Vec<Event> = Vec::new();
let mut by_id: HashMap<EventId, Event> = HashMap::new();
for filter in filters::notifications(me) {
for event in client.database().query(filter).await? {
if deletions.is_deleted(&event) {
continue;
}
if by_id.insert(event.id, event.clone()).is_none() {
notifications.push(event);
}
}
}
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
loop {
// Keep only ids not walked yet, and remember them.
pending.retain(|id| seen.insert(*id));
if pending.is_empty() {
break;
}
let ancestors = client
.database()
.query(Filter::new().ids(pending.iter().copied()))
.await?;
let mut next = Vec::new();
for event in ancestors {
if deletions.is_deleted(&event) {
continue;
}
next.extend(event_references(&event).filter(|id| !seen.contains(id)));
by_id.entry(event.id).or_insert(event);
}
pending = next;
}
Ok((notifications, by_id))
}
/// Event ids referenced by `event` through its `e` and `E` tags.
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
event.tags.iter().filter_map(|tag| {
if tag.kind() != "e" && tag.kind() != "E" {
return None;
}
tag.content()
.and_then(|content| EventId::from_hex(content).ok())
})
}
+8 -11
View File
@@ -1,6 +1,7 @@
mod backend; mod backend;
mod checkouts; mod checkouts;
mod git_store; mod git_store;
mod inbox;
mod profile; mod profile;
mod refresh; mod refresh;
mod repo; mod repo;
@@ -11,22 +12,23 @@ use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent, user_grasp_list_servers}; pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore; pub use git_store::GitStore;
use gpui::{App, AppContext, Entity}; use gpui::{App, AppContext};
pub use inbox::{Inbox, query_inbox};
pub use nostr_sdk::prelude::Timestamp; pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore}; pub use profile::{Profile, ProfileStore};
pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore; pub use repo::RepoStore;
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend; use signed_nostr::new_backend;
/// Initialize the backend and stores, and install them as globals. /// Initialize the backend and stores, and install them as globals.
/// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn init( pub fn init(
db_path: impl AsRef<Path>, db_path: impl AsRef<Path>,
repos_root: impl Into<PathBuf>, repos_root: impl Into<PathBuf>,
scan_paths: Vec<PathBuf>, scan_paths: Vec<PathBuf>,
cx: &mut App, cx: &mut App,
) -> Entity<Backend> { ) {
// rustls uses the `aws_lc_rs` provider by default. // rustls uses the `aws_lc_rs` provider by default.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
@@ -37,27 +39,22 @@ pub fn init(
.expect("failed to initialize nostr backend") .expect("failed to initialize nostr backend")
}); });
let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(RepoListStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx);
GitStore::set_global(repos_root, cx); GitStore::set_global(repos_root, cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
entity
} }
/// Initialize the backend with an in-memory database on wasm. /// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> { pub fn init(cx: &mut App) {
let (client, signer) = new_backend().expect("failed to initialize nostr backend"); let (client, signer) = new_backend().expect("failed to initialize nostr backend");
let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(RepoListStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx);
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
entity
} }
-5
View File
@@ -1,9 +1,4 @@
/// Refresh coalescing shared by the event stores. /// Refresh coalescing shared by the event stores.
///
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
/// re-query their inputs on a debounce timer with the same policy:
/// a request arriving while a run is in flight is folded into a follow-up run,
/// a request arriving while the debounce timer is pending is dropped by it.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct RefreshGate { pub struct RefreshGate {
/// A run is in flight. /// A run is in flight.
+837
View File
@@ -0,0 +1,837 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState,
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId, Kind, Timestamp};
use signed_core::{
COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, activity_subject, filters,
};
use signed_state::{
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
};
use signed_ui::{CountBadge, UserAvatar};
use utils::relative_time;
use super::{RepoItem, open_repo_item, open_repo_panel};
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Extra list rows measured above and below the visible area.
const LIST_OVERDRAW: Pixels = px(400.);
/// A repository's slice of the inbox: its notification groups and own activity.
struct InboxSection {
/// Repository the section groups, `None` for items without one.
address: Option<RepoAddr>,
/// Number of notification groups with an unread event.
unread: usize,
/// Notification and activity rows, newest first.
entries: Vec<InboxEntry>,
/// Timestamp of the newest entry, used to order the sections.
latest: Timestamp,
}
/// One row inside a repository section, as an index into the inbox's own lists.
#[derive(Clone, Copy)]
enum InboxEntry {
/// Index into the notification groups.
Notification(usize),
/// Index into the user's own activity.
Activity(usize),
}
/// One row of the flattened inbox: a repository header, one of its entries, or
/// the empty state of a repository without any.
#[derive(Clone, Copy)]
enum InboxRow {
Repo(usize),
Entry(usize, usize),
Empty,
}
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
/// Notifications grouped by thread root, newest activity first.
notifications: Arc<Vec<InboxItem>>,
/// The user's own recent git activity, newest first.
activity: Arc<Vec<Event>>,
/// The notification and activity lists grouped by repository, newest first.
sections: Arc<Vec<InboxSection>>,
/// The flattened repository headers and rows of the list.
rows: Arc<Vec<InboxRow>>,
/// Number of non-archived groups with an unread event.
unread_count: usize,
/// Copy of the global read state the current lists were derived with.
state: InboxReadState,
/// Set once the global state has been read for the current user.
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let repos = RepoListStore::global(cx);
let weak = cx.entity().downgrade();
let list = ListState::new(0, ListAlignment::Top, LIST_OVERDRAW);
let mut subscriptions = vec![];
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
this.sync_state(cx);
}));
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
this.handle_backend_event(event, cx);
}));
// Rebuild when the user's own repositories load or change,
// so a repository without any activity still gets an empty section.
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.rebuild(cx);
cx.notify();
}));
// Derive the sections once the panel exists.
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox dropped before bootstrap could run: {error}");
}
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
notifications: Arc::new(Vec::new()),
activity: Arc::new(Vec::new()),
sections: Arc::new(Vec::new()),
rows: Arc::new(Vec::new()),
unread_count: 0,
state: InboxReadState::default(),
state_loaded: false,
refresh: RefreshGate::default(),
list,
tasks: vec![],
_subscriptions: subscriptions,
}
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let all = self.all_notification_events();
let inbox = Backend::global(cx).read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
}
/// Re-derive from the global state when it is loaded or changes.
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
let inbox = Backend::global(cx).read(cx).inbox();
let (loaded, state) = {
let inbox = inbox.read(cx);
(inbox.is_loaded(), inbox.state().clone())
};
if !loaded {
let was_present = self.state_loaded
|| !self.notifications.is_empty()
|| !self.activity.is_empty()
|| !self.sections.is_empty();
self.clear();
if was_present {
cx.notify();
}
return;
}
if !self.state_loaded {
self.state_loaded = true;
self.state = state;
self.refresh_initial(cx);
return;
}
if self.state != state {
self.state = state;
self.regroup(cx);
cx.notify();
}
}
/// Handle a backend event that can change the derived sections.
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::NostrUpdate(updates) => {
let relevant = updates.iter().any(|update| {
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
let is_comment = update.kind == Kind::Comment;
let is_event_deletion = update.kind == Kind::EventDeletion;
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
is_notification || is_comment || is_event_deletion || is_request_to_vanish
});
if relevant {
self.refresh(cx);
}
}
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
_ => {}
}
}
/// One-shot initial load, no debounce.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database.
fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
}));
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let Some(me) = backend.read(cx).current_user() else {
self.refresh.abort();
return;
};
let client = backend.read(cx).client();
let state = self.state.clone();
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let (notifications, activity, unread_count) = match work.await {
Ok(results) => results,
Err(error) => {
log::warn!("inbox refresh failed: {error}");
return this.update(cx, |this, _cx| this.refresh.abort());
}
};
let again = this.update(cx, |this, cx| {
if backend.read(cx).current_user() != Some(me) {
this.refresh.abort();
return false;
}
this.notifications = Arc::new(notifications);
this.activity = Arc::new(activity);
this.unread_count = unread_count;
this.rebuild(cx);
cx.notify();
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
}));
}
/// Recompute the unread and archived flags from the current state.
fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.notifications).clone();
for item in items.iter_mut() {
item.apply_state(&self.state);
}
self.unread_count = items.iter().filter(|item| item.is_unread()).count();
self.notifications = Arc::new(items);
self.rebuild(cx);
}
/// Regroup the current lists by repository and flatten them into rows.
fn rebuild(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let repo_list = RepoListStore::global(cx);
let mut sections = group_sections(&self.notifications, &self.activity);
if let Some(me) = backend.read(cx).current_user() {
for announcement in repo_list.read(cx).announcements_of(&me) {
let address = announcement.addr();
let known = sections
.iter()
.any(|section| section.address.as_ref() == Some(&address));
if !known {
sections.push(InboxSection {
address: Some(address),
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
}
}
}
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
let rows = flatten_rows(&sections);
self.sections = Arc::new(sections);
self.rows = Arc::new(rows);
}
/// Forget everything derived for the current user.
fn clear(&mut self) {
self.notifications = Arc::new(Vec::new());
self.activity = Arc::new(Vec::new());
self.sections = Arc::new(Vec::new());
self.rows = Arc::new(Vec::new());
self.unread_count = 0;
self.state = InboxReadState::default();
self.state_loaded = false;
// Drop any in-flight or pending run belonging to the previous user.
self.refresh = RefreshGate::default();
}
/// Every event in every group, archived groups included.
fn all_notification_events(&self) -> Vec<Event> {
self.notifications
.iter()
.flat_map(|item| item.events.iter().cloned())
.collect()
}
fn render_entry(&self, ix: usize, cx: &App) -> AnyElement {
let Some(row) = self.rows.get(ix) else {
return div().into_any_element();
};
match *row {
InboxRow::Empty => empty_section_row(cx),
InboxRow::Repo(section_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
repo_header(section, cx)
}
InboxRow::Entry(section_ix, entry_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
let Some(entry) = section.entries.get(entry_ix) else {
return div().into_any_element();
};
match *entry {
InboxEntry::Notification(item_ix) => {
let Some(item) = self.notifications.get(item_ix) else {
return div().into_any_element();
};
let root = item.root;
let kind = item.root_kind;
let address = section.address.clone();
let dock_area = self.dock_area.clone();
notification_row("inbox-row", ix, item, cx)
.on_click(move |_, window, cx| {
open_item(&dock_area, root, kind, address.clone(), window, cx);
})
.into_any_element()
}
InboxEntry::Activity(event_ix) => {
let Some(event) = self.activity.get(event_ix) else {
return div().into_any_element();
};
activity_row(ix, event, cx)
}
}
}
}
}
}
/// Group the notification groups and own activity into one section per repository.
fn group_sections(notifications: &[InboxItem], activity: &[Event]) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
for (ix, item) in notifications.iter().enumerate() {
if item.archived {
continue;
}
let address = item.address.clone();
let section = by_repo
.entry(address.clone())
.or_insert_with(move || InboxSection {
address,
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
if item.is_unread() {
section.unread += 1;
}
section.latest = section.latest.max(item.latest_activity());
section.entries.push(InboxEntry::Notification(ix));
}
for (ix, event) in activity.iter().enumerate() {
let address = repo_address(event);
let section = by_repo
.entry(address.clone())
.or_insert_with(move || InboxSection {
address,
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
section.latest = section.latest.max(event.created_at);
section.entries.push(InboxEntry::Activity(ix));
}
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
for section in &mut sections {
section.entries.sort_by(|a, b| {
entry_time(b, notifications, activity).cmp(&entry_time(a, notifications, activity))
});
}
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
sections
}
/// Timestamp an entry is ordered by.
fn entry_time(entry: &InboxEntry, notifications: &[InboxItem], activity: &[Event]) -> Timestamp {
match entry {
InboxEntry::Notification(ix) => notifications
.get(*ix)
.map(InboxItem::latest_activity)
.unwrap_or_default(),
InboxEntry::Activity(ix) => activity
.get(*ix)
.map(|event| event.created_at)
.unwrap_or_default(),
}
}
/// Flatten the sections into the list of repository headers and their rows.
fn flatten_rows(sections: &[InboxSection]) -> Vec<InboxRow> {
let mut rows = Vec::new();
for (section_ix, section) in sections.iter().enumerate() {
rows.push(InboxRow::Repo(section_ix));
if section.entries.is_empty() {
rows.push(InboxRow::Empty);
continue;
}
rows.extend(
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
);
}
rows
}
/// Repository an activity event belongs to, from its `a` tag.
fn repo_address(event: &Event) -> Option<RepoAddr> {
event
.tags
.coordinates()
.find(|address| address.kind == Kind::GitRepoAnnouncement)
}
/// Display name of the repository at `addr`, from the announcement store.
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option<SharedString> {
let repo_list = RepoListStore::global(cx);
let addr = addr?;
repo_list
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == *addr)
.map(|announcement| announcement.name().map(SharedString::from))
}
/// Header of a repository section.
fn repo_header(section: &InboxSection, cx: &App) -> AnyElement {
let name =
repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled"));
h_flex()
.h_12()
.w_full()
.gap_1()
.items_center()
.child(
div()
.min_w_0()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.when(section.unread > 0, |this| {
this.child(CountBadge::new(section.unread))
})
.into_any_element()
}
/// Placeholder under a repository header that has nothing to show.
fn empty_section_row(cx: &App) -> AnyElement {
h_flex()
.h_12()
.w_full()
.px_3()
.text_xs()
.text_color(cx.theme().muted_foreground)
.bg(cx.theme().secondary)
.rounded(cx.theme().radius)
.child(SharedString::from("No activity yet."))
.into_any_element()
}
/// Open the repository of a notification group,
/// and the issue or pull request detail when the group's root is one.
fn open_item(
dock_area: &WeakEntity<DockArea>,
root: EventId,
kind: Option<Kind>,
address: Option<RepoAddr>,
window: &mut Window,
cx: &mut App,
) {
let Some(address) = address else {
return;
};
let Some(announcement) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == address)
.cloned()
else {
return;
};
let detail = open_repo_panel(dock_area, &announcement, window, cx);
let Some(store) = detail.read(cx).store() else {
return;
};
let item = match kind {
Some(Kind::GitIssue) => RepoItem::Issue(root),
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
Some(Kind::GitPatch) => RepoItem::Patch,
_ => return,
};
open_repo_item(dock_area, store, item, window, cx);
}
fn notification_row(prefix: &'static str, ix: usize, item: &InboxItem, cx: &App) -> Stateful<Div> {
let Some(newest) = item.events.first() else {
return div().id((prefix, ix));
};
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&newest.pubkey);
let author = profile.name();
let picture = profile.picture();
let kind = item.root_kind.unwrap_or(newest.kind);
let subject = SharedString::from(activity_subject(newest));
let age = relative_time(item.latest_activity());
let unread = item.is_unread();
v_flex()
.id((prefix, ix))
.h_16()
.w_full()
.px_3()
.gap_1()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(kind_icon(kind)),
)
.child(
div()
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.child(subject),
)
.child(div().flex_1())
.when(unread, |this| {
this.child(
div()
.flex_shrink_0()
.size_2()
.rounded_full()
.bg(cx.theme().primary),
)
}),
)
.child(
h_flex()
.w_full()
.gap_2()
.child(div().w_6().flex_shrink_0())
.child(
h_flex()
.flex_1()
.gap_1()
.items_center()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_1()
.child(UserAvatar::new(author.clone()).picture(picture))
.child(author.clone()),
)
.child("opened")
.child(SharedString::from(kind_label(kind)))
.child(div().flex_1())
.child(SharedString::from(age)),
),
)
}
fn activity_row(ix: usize, event: &Event, cx: &App) -> AnyElement {
let kind = event.kind;
let subject = SharedString::from(activity_subject(event));
let age = relative_time(event.created_at);
v_flex()
.id(("activity-row", ix))
.h_16()
.w_full()
.px_3()
.gap_1()
.justify_center()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.child(
h_flex()
.gap_2()
.text_sm()
.whitespace_nowrap()
.text_ellipsis()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(kind_icon(kind)),
)
.child(subject),
)
.child(
h_flex().gap_2().child(div().w_6().flex_shrink_0()).child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(kind_label(kind)))
.child("·")
.child(SharedString::from(age)),
),
)
.into_any_element()
}
/// Leading icon for a notification or activity kind.
fn kind_icon(kind: Kind) -> Icon {
if kind == COVER_NOTE_KIND {
return Icon::new(IconName::File).small();
}
match kind {
Kind::GitIssue => Icon::new(CustomIconName::GitIssueOngoing),
Kind::GitPullRequest | Kind::GitPullRequestUpdate => {
Icon::new(CustomIconName::GitPullRequest)
}
Kind::GitPatch => Icon::new(CustomIconName::GitCommit),
Kind::Comment => Icon::new(IconName::Star),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => Icon::new(CustomIconName::GitIssueOpen),
_ => Icon::new(IconName::Bell),
}
.small()
}
/// Short noun for a notification or activity kind.
fn kind_label(kind: Kind) -> &'static str {
if kind == COVER_NOTE_KIND {
return "note";
}
match kind {
Kind::GitIssue => "issue",
Kind::GitPullRequest => "PR",
Kind::GitPullRequestUpdate => "PR update",
Kind::GitPatch => "patch",
Kind::Comment => "comment",
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => "status",
_ => "activity",
}
}
/// Centered muted icon and message filling its container.
fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement {
v_flex()
.w_full()
.flex_1()
.min_h_0()
.items_center()
.justify_center()
.gap_2()
.py_8()
.child(
Icon::new(icon)
.large()
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(message)),
)
.into_any_element()
}
impl BasePanel for InboxView {
fn panel_name(&self) -> &'static str {
"inbox"
}
}
impl Panel for InboxView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from("Inbox"))
}
}
impl EventEmitter<PanelEvent> for InboxView {}
impl Focusable for InboxView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows.clone();
if self.list.item_count() != rows.len() {
self.list.reset(rows.len());
}
v_flex()
.image_cache(gpui::retain_all("inbox"))
.size_full()
.gap_2()
.child(
h_flex()
.px_4()
.h_12()
.w_full()
.gap_1()
.items_center()
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::from("Inbox")),
)
.child(div().flex_1())
.child(
Button::new("mark-all")
.icon(IconName::CircleCheck)
.secondary()
.tooltip("Mark all as read")
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.mark_all_read(cx);
})),
),
)
.child(div().relative().flex_1().min_h_0().px_4().pb_4().when_else(
rows.is_empty(),
|this| this.child(empty_state(IconName::Inbox, "You're all caught up.", cx)),
|this| {
this.child(
list(
self.list.clone(),
cx.processor(|this, ix, _window, cx| this.render_entry(ix, cx)),
)
.size_full()
.min_h_0()
.into_any_element(),
)
},
))
}
}
+3 -1
View File
@@ -1,9 +1,11 @@
mod dialog_state; mod dialog_state;
mod inbox;
mod repo_detail; mod repo_detail;
mod repo_list; mod repo_list;
pub(crate) mod sidebar; pub(crate) mod sidebar;
pub use inbox::InboxView;
pub use repo_detail::RepoDetailView; pub use repo_detail::RepoDetailView;
pub(crate) use repo_detail::open_repo_panel; pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel};
pub use repo_list::RepoListView; pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel; pub use sidebar::SidebarPanel;
+49 -1
View File
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::Error;
@@ -13,6 +14,7 @@ use gpui::{
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity,
Window, div, px, relative, size, transparent_white, Window, div, px, relative, size, transparent_white,
}; };
use gpui_base::dock::PanelView;
use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::alert::Alert; use gpui_component::alert::Alert;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
@@ -24,7 +26,7 @@ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
VirtualListScrollHandle, h_flex, v_flex, VirtualListScrollHandle, h_flex, v_flex,
}; };
use nostr::prelude::{RelayUrl, ToBech32, Url}; use nostr::prelude::{EventId, RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; use signed_core::{Announcement, RepoAddr, RepoStatus, filters};
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{ use signed_state::{
@@ -57,7 +59,9 @@ use helpers::{
ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger,
tree_items, tree_items,
}; };
use issue_detail::IssueDetailView;
use issues::{IssuesView, open_new_issue_dialog}; use issues::{IssuesView, open_new_issue_dialog};
use pull_request_detail::PullRequestDetailView;
use pull_requests::PullRequestsView; use pull_requests::PullRequestsView;
use send_patch::open_send_patch_panel; use send_patch::open_send_patch_panel;
@@ -227,6 +231,12 @@ impl RepoDetailView {
view view
} }
/// The per-repository nostr store, so another panel can open one of its
/// items. `None` until a local repository is initialized to NIP-34.
pub(crate) fn store(&self) -> Option<Entity<RepoStore>> {
self.store.clone()
}
/// Open a local repository discovered by the scan. /// Open a local repository discovered by the scan.
pub fn new_local( pub fn new_local(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
@@ -2649,3 +2659,41 @@ pub(crate) fn open_repo_panel(
detail detail
} }
/// An item of a repository to open from outside its detail panel.
/// A patch has no detail view in Signed, so it opens nothing.
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
/// Open the detail panel of `item` in `store`'s repository, in the dock's center.
///
/// A patch opens nothing: patches are only consumed inside a pull request's
/// detail panel, and have no panel of their own.
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
store: Entity<RepoStore>,
item: RepoItem,
window: &mut Window,
cx: &mut App,
) {
let panel: Arc<dyn PanelView> = match item {
RepoItem::Issue(issue_id) => {
panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx)))
}
RepoItem::PullRequest(pr_id) => panel_handle(
cx.new(|cx| PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx)),
),
RepoItem::Patch => return,
};
let Some(dock_area) = dock_area.upgrade() else {
return;
};
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel, window, cx);
});
}
+21 -17
View File
@@ -23,7 +23,7 @@ use signed_state::{
}; };
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel}; use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog; mod create_repo_dialog;
pub(crate) mod grasp_servers; pub(crate) mod grasp_servers;
@@ -37,6 +37,7 @@ use self::onboarding_dialog::OnboardingState;
pub struct SidebarPanel { pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
inbox: Option<WeakEntity<InboxView>>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
/// Artwork for the sign-in screen. /// Artwork for the sign-in screen.
banner: SharedString, banner: SharedString,
@@ -71,6 +72,7 @@ impl SidebarPanel {
if signer_required { if signer_required {
this.banner = pick_banner(); this.banner = pick_banner();
cx.notify();
} }
if this.refresh(cx) || signer_required { if this.refresh(cx) || signer_required {
@@ -99,9 +101,10 @@ impl SidebarPanel {
} }
})); }));
let mut this = Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
inbox: None,
explore: None, explore: None,
banner: pick_banner(), banner: pick_banner(),
announcements: Arc::new(Vec::new()), announcements: Arc::new(Vec::new()),
@@ -109,22 +112,9 @@ impl SidebarPanel {
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; }
// Seed the snapshot right away.
// The stores may already hold data from before the panel opened.
// The first render must not depend on a later store update.
this.refresh(cx);
this
} }
/// The sidebar renders only its own derived fields, never the stores
/// directly. Because the panel is a cached view, a store update alone does
/// not re-render it: the observers notify this panel, which re-runs
/// `render` over the fresh snapshot.
///
/// Returns `true` when a rendered field changed.
fn refresh(&mut self, cx: &mut Context<Self>) -> bool { fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let user = backend.read(cx).current_user(); let user = backend.read(cx).current_user();
@@ -203,6 +193,20 @@ impl SidebarPanel {
}); });
} }
/// Open the inbox home panel in the dock area's center.
pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() {
return;
}
let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx));
self.inbox = Some(panel.downgrade());
let _ = self.dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
/// Open the Explore repository list panel in the dock area's center. /// Open the Explore repository list panel in the dock area's center.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self if self
@@ -616,7 +620,7 @@ impl Render for SidebarPanel {
.child( .child(
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(cx.listener(|this, _ev, window, cx| { .on_click(cx.listener(|this, _ev, window, cx| {
this.open_explore(window, cx) this.open_inbox(window, cx)
})), })),
) )
.child( .child(
+892
View File
@@ -0,0 +1,892 @@
# Inbox (home screen) implementation plan
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
> an account is active, and that home screen is the inbox.
> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to
> group notifications and activity **by repository** (see the repository-grouping note in §7).
> `cargo test -p signed_core` (68), `cargo test -p signed_state` (24),
> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
> clean, `cargo check --workspace --all-targets` succeeds.
> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
> Phase 4 click-through, and the repository-grouped list. The Phase 3 bottom-dock sub-views were
> removed before the redesign; their implementation notes in §7 are historical.
## 1. What the GitWorkshop home screen is
`Index.tsx`:
```tsx
if (account) return <Dashboard />;
return <LandingPage />;
```
`Dashboard.tsx` layout:
- Desktop: two columns.
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
`FollowedReposPanel`.
- Mobile: a single column in a different order.
The panel that gives the screen its inbox identity is `NotificationsPanel`:
- heading **Notifications** with a bell icon and an unread count badge,
- a **Mark all read** action and a **View all** link to `/notifications`,
- a compact list of the first 5 **non-archived** notification items,
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
inline on the home screen. The home screen also shows your own recent activity and your repositories.
Data hooks:
| Section | Hook | What it loads |
|---|---|---|
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
## 2. Scope for Signed
| Priority | Section | Notes |
|---|---|---|
| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown |
| **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, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed |
Notes:
- There is **no greeting header**. The screen starts with the inbox panel.
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead.
## 3. The Signed screen
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
card holding a single virtual list. Every row is either a **repository header** or one of that
repository's **notifications / own activity**, newest first:
The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other
repository that has notifications or activity. Owned repositories with nothing to show render an
empty state ("No activity yet.") under their header, and sort after the ones with activity (newest
announcement first). Items with no repository address fall into a single "Other repository" section.
```
+-------------------------------------------------------------------------+
| Inbox (3 unread) [Mark all read] |
|-------------------------------------------------------------------------|
| [repo] you/repo-a (2) |
| [avatar] issue opened issue 2m |
| [avatar] commented on "..." comment 1h |
| [icon] "Add retry" patch 3d |
|-------------------------------------------------------------------------|
| [repo] you/repo-b |
| No activity yet. |
|-------------------------------------------------------------------------|
| [repo] you/repo-c |
| No activity yet. |
+-------------------------------------------------------------------------+
```
The sections are the repositories that actually have notifications or activity, ordered by their
newest row. A repository the user owns but that has no items is not shown. Items with no repository
address fall into a single "Other repository" section.
## 4. Data layer
### 4.1 `signed_core`: pure logic
**`filters.rs`** (extend, next to `activity`/`comments_for`):
```rust
/// Kinds that notify a user when they tag them directly.
pub const NOTIFICATION_KINDS: [Kind; 9] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
Kind::GitPullRequestUpdate,
COVER_NOTE_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
];
/// Comments on our issues/PRs/patches.
pub fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
/// Activity directed at us: comments on our roots, and git events tagging us.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
/// Git activity authored by `me`, for "Continue where you left off".
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
.author(me)
}
```
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
gitworkshop's `isGitComment`.
**`inbox.rs`** (new file):
```rust
pub struct InboxItem {
pub root: EventId,
pub root_kind: Option<Kind>,
pub address: Option<RepoAddr>,
/// Events in the group, newest first.
pub events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
/// The thread root of a notification event, or `None` if it isn't git-related.
pub fn notification_root(
event: &Event,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Option<EventId>;
/// Group notification events by root, newest activity first, self excluded.
pub fn group(
events: impl IntoIterator<Item = Event>,
me: PublicKey,
state: &InboxReadState,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Vec<InboxItem>;
```
Root resolution, ported from `getNotificationRootId`:
- issue (1621) / PR (1618): itself
- patch (1617): its `e` parent patch, else itself
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
- PR update (1619): uppercase `E`
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
- self-authored events are excluded
Read/archive state, the compact high-water-mark model:
```rust
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)] pub read_before: Timestamp,
#[serde(default)] pub read_ids: HashSet<EventId>,
#[serde(default)] pub archived_before: Timestamp,
#[serde(default)] pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
pub fn is_read(&self, event: &Event) -> bool;
pub fn is_archived(&self, event: &Event) -> bool;
pub fn mark_read(&mut self, event: &Event);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
}
```
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
line; reuse it for the home screen rows.
### 4.2 Persistence: NIP-78 in the local database, never published
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
It is signed with a **random keypair**, never the user's signer. The event is local application
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
when the signed-in key changes.
```rust
/// d tag identifying the inbox read/archive state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
// No author filter: the signing key is random per save.
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with a fresh random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(())
}
```
A fresh random key is generated on every save, so each save writes a new event rather than
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
id across saves; that was removed as more derived state than it was worth.
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
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 and the NIP-78
load/save.
```rust
// backend.rs
pub struct Backend {
...
inbox: Entity<Inbox>,
}
// inbox.rs
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
state_loaded: bool,
}
impl Inbox {
pub fn state(&self) -> &InboxReadState;
pub fn is_loaded(&self) -> bool;
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);
}
```
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
state they were computed with, and the refresh coalescing. There is no separate store entity: the
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header
badge only; there is no global count and no sidebar badge.
```rust
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
notifications: Arc<Vec<InboxItem>>,
activity: Arc<Vec<Event>>,
sections: Arc<Vec<InboxSection>>, // grouped by repository
rows: Arc<Vec<InboxRow>>, // flattened list
unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
pub fn sync_state(&mut self, cx); // observes the global Inbox
pub fn mark_all_read(&mut self, cx);
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
fn refresh(&mut self, cx);
fn run_refresh(&mut self, cx);
fn regroup(&mut self, cx); // re-apply read state
fn rebuild(&mut self, cx); // group by repository, seed owned, flatten
fn clear(&mut self);
}
```
The panel owns three subscriptions that carry logic: it observes the global `Inbox`
(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back
to the global.
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
re-exported for the panel's debounce.
```rust
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
```
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
`signed_state::init` gains no parameters.
**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
derivation was removed with it). The unread count lives entirely in the panel, which shows it in
its header and per repository section. The trade-off is that the count is only current while the
panel is open, which is acceptable now that nothing outside it displays one.
The dependency chain is `Backend``Inbox` and `InboxView``query_inbox`.
`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
directly.
`InboxView::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.
**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>) {
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);
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 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.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
/ `connect_own_repo_relays`.
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel 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, me: PublicKey, client: Client, cx: &mut Context<Self>) {
// state = default; state_loaded = false; cx.notify();
// spawn load_state(client, me), then set state and state_loaded = true
}
```
`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` through
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
read, not a wait on the network; see the note below.
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
- `cx.background_spawn`: query the notification filters and the activity filter from
`client.database()`.
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
`inbox::group`.
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their `K` tag is a git kind; sort newest first.
- 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`, rebuild the
repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
`InboxView::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`).
**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 store refreshes. This was
reviewed and left as-is.
**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
global `Inbox`. The global marks them, 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
panel then re-derives and publishes the unread count.
**Repository names need no new store**: `RepoListStore` already holds every announcement and
`repo_name` resolves an address to a display name.
### 4.4 `Cargo.toml`
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
## 5. UI
### 5.1 `InboxView` center panel
`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
The list count is reset from `render` whenever the rendered row count changes.
- **Header**: the unread count badge and **Mark all read**.
- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
unread dot (the subject is semibold while unread). A repository with nothing to show renders
"No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
no sections at all (no owned repositories and no items).
No greeting header, and no **My repositories** column - the sidebar already lists the user's
repositories.
### 5.2 Grouping by repository
The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called
from `run_refresh` and `regroup`), never per frame:
```rust
struct InboxSection {
address: Option<RepoAddr>, // repository, None for items without one
unread: usize, // unread notification groups
entries: Vec<InboxEntry>, // newest first
latest: Timestamp, // orders the sections
}
enum InboxEntry { // indices into the panel's own lists
Notification(usize),
Activity(usize),
}
enum InboxRow { // the flattened list
Repo(usize),
Entry(usize, usize),
Empty, // "No activity yet." under an empty section
}
```
The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list`
closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies.
Notification groups carry their repository in `InboxItem::address`; activity events carry it in a
`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out.
`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
`RepoListStore` so a repository that loads after the last refresh still appears (its own empty
section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
late announcement still labels its section without re-deriving the grouping.
### 5.3 Sidebar
In `views/sidebar/mod.rs`:
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
- 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).
`InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open a repo for a row.
- Point the existing nav item at it:
```rust
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
```
- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
`Inbox`. The unread count lives in the panel only.
### 5.4 Click-through (P1)
Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one.
The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`,
which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than
a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
```rust
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
store: Entity<RepoStore>,
item: RepoItem,
window: &mut Window,
cx: &mut App,
) { /* new IssueDetailView / PullRequestDetailView, added to the center */ }
```
- `RepoDetailView::store()` exposes its `Option<Entity<RepoStore>>`, so the caller reuses the repo
panel's store rather than building one. `views/mod.rs` re-exports `RepoItem` and `open_repo_item`.
- `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, calls
`open_repo_panel` (which returns `Entity<RepoDetailView>`), takes its store, and calls
`open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder
until the store's fetch lands, then re-renders.
- The repository panel and the detail panel are two tabs of the center group; the detail is
activated. This matches the sidebar, which also opens a fresh repo panel per click.
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
patch-root click opens the repo panel only. `RepoItem::Patch` carries no id for that reason. A group
whose root is not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
## 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` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, grouping, activity) |
| `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` and `query_inbox`; re-export `RefreshGate` (no global install) |
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) |
| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the derived lists, the repository grouping, and the notification click-through |
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` |
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring |
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item`, `RepoDetailView::store()` |
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
activates the `Inbox` child entity at each signer transition.
## 7. Phasing
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
and `authored_activity` results must pass through `is_git_activity` before display (comments on
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
end of Phase 0; 68 after the two Phase 1 additions).
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below.
3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item.
**DONE.** See the implementation notes below.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
**Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the
notes below are historical.
5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
notes below.
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
view.
Each phase compiles and is usable on its own.
### Phase 1 implementation notes
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
and two additions to `crates/signed_core/src/inbox.rs`.
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
a re-entrant entity access.
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
`SidebarPanel`, but no longer drive the inbox.
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
before the stored state is read would briefly mark everything unread. The first refresh is chained
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
user the query was started for.
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
Both are covered by tests.
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
rather than a single hop, because a patch series chains through parent patches. Only the notification
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
not mistaken for a notification.
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
re-derives the groups locally so the UI updates immediately, then persists in the background.
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
either: the newest state event is selected by `created_at`.
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
### 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`; the sidebar holds a
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
was later folded into the panel - see the store-merge note below.)
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
repositories, so the panel is a single column.
- 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 only **Mark all read**,
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. 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.
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
- `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`, plus the NIP-78 load/save
and the mark actions.
- `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.
- The panel became the client-side owner of the derivation, initially through a panel-scoped
`Entity<InboxStore>`.
- `signed_core` is unchanged.
### Store merged into the panel (after Phase 2)
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
methods. The two subscriptions call them directly, with no `update` indirection.
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
until the Phase 3 sub-views wire them up.
- `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 unread count is derived by the panel, so it is only current while the panel is open.
(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
badge removed" below.)
### Phase 3 implementation notes
> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single
> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale.
Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
store changes.
- `add_bottom_panel` sits next to `add_center_panel` and wraps
`DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open,
and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock,
so a closed sub-view leaves no strip behind.
- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity<InboxView>` (strong; the
panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own
`ListState`. There is no subscription: it reads the inbox entity during render, which is enough for
GPUI to invalidate the window when the inbox notifies.
- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title
comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of
opening a second one.
- `InboxView` regained a `dock_area: WeakEntity<DockArea>` (removed with the My-repositories column)
and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom
dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called
as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`.
- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone:
Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button
(`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()`
so it does not also trigger the row's mark-read click. Archived rows are display-only; the read
state has no un-archive operation.
- `notification_row` takes an id `prefix` and returns `Stateful<Div>` rather than `AnyElement`, so
callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and
the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would
otherwise collide on `(str, ix)` ids.
- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets`
succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7).
### Phase 4 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes.
- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live
in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` takes the store as a
parameter, avoiding a second `RepoStore`.
- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and
a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is
not available. `InboxView` already has the window in the list's `on_click`, so it drives the free
function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
- `RepoDetailView::store()` (`pub(crate)`) exposes the panel's `Option<Entity<RepoStore>>`. The repo
panel is opened first and its store reused, so the detail panel shares one store with the repo it
came from.
- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures
from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves
`item.address` through `RepoListStore`, returns silently when the repository is unknown, opens the
repo panel, then maps the root kind to a `RepoItem` and calls `open_repo_item`.
- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read /
archive row behaviour is gone with the sub-views.
- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so
`open_repo_item` returns before doing anything and only the repository panel opens.
- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds,
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
### Repository grouping redesign (after Phase 4)
Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes.
The two-card layout (notifications over activity) was replaced by a single repository-grouped list.
- The panel now derives `sections: Vec<InboxSection>` and a flattened `rows: Vec<InboxRow>` in
`rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the
`gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no
data duplicated between the section list and the source lists.
- `InboxSection` groups a repository's non-archived notification groups and the user's own activity,
newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the
panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the
list renders.
- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`,
so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts
after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's
repositories load or change.
- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`).
Items without an address share the "Other repository" section.
- `notification_row` / `activity_row` no longer render the repository name - the section header does.
That also drops one `RepoListStore` scan per row.
- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the
`render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still
takes an id prefix so rows stay unique within the list.
- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state
-p workspace -p dock` passes (68 / 24 / 7 / 1).
### Sidebar badge removed (after the repository grouping redesign)
Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`,
`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`.
An intermediate change made the sidebar badge live by moving the unread count into the global
`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge
itself, so the global is thin again.
- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and
its observe of the global `Inbox`.
- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` /
`refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count
refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it.
- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for
the repository headers; `publish_unread_count` stays deleted.
- Consequence: the unread count is only current while the panel is open, and there is no unread
indication anywhere else in the app.
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
`cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
## 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` / `query_inbox` 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, and that no
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
state is read back from LMDB. Confirm the sidebar has no unread badge.
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
`client.sync(...)` both persist received events to LMDB and surface them as
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
This is why the query right after a fetch is a cache read, not a race.