wip
This commit is contained in:
Generated
+196
-152
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 unread_ids = events
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|event| !state.is_read(event))
|
||||||
|
.map(|event| event.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let archived = events.iter().all(|event| state.is_archived(event));
|
||||||
|
|
||||||
|
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,
|
||||||
|
archived,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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, grouped by thread root; unread badge; mark all read; top N + show all |
|
||||||
|
| **P0** | My repositories | Reuse `RepoListStore::announcements_of(me)`; search filter; existing New-repo dialog |
|
||||||
|
| **P0** | Continue where you left off | Your own recent git activity, newest first |
|
||||||
|
| **P1** | Unread / Archived sub-views | Open as **bottom-dock panels**, not tabs inside the inbox panel |
|
||||||
|
| **P1** | Click-through | Open the repo panel at the relevant PR/issue |
|
||||||
|
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
|
||||||
|
| **Out of scope** | Greeting header, followed repositories, private repositories, pinned repositories | Not needed in Signed |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- There is **no greeting header**. The screen starts with the inbox panel.
|
||||||
|
- Unread and Archived are separate panels opened in the bottom dock, not tabs in the inbox panel.
|
||||||
|
|
||||||
|
## 3. The Signed screen
|
||||||
|
|
||||||
|
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item (currently a
|
||||||
|
placeholder that opens Explore). It is one scrollable two-column flex row:
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------------------------------------+-----------------------+
|
||||||
|
| Inbox (3 unread) [Unread] [Archived] [Mark all read] |
|
||||||
|
| [avatar] issue opened on you/repo 2m |
|
||||||
|
| [avatar] commented on "Fix parser" 1h |
|
||||||
|
| [avatar] PR update on you/repo 3h |
|
||||||
|
| [Show all] |
|
||||||
|
| |
|
||||||
|
| Continue where you left off |
|
||||||
|
| [icon] "Fix parser bug" you/repo opened 3d |
|
||||||
|
| [icon] "Add retry" you/repo PR 5d |
|
||||||
|
+--------------------------------------------------+-----------------------+
|
||||||
|
| bottom dock: Unread or Archived list (opened by the header buttons) |
|
||||||
|
+--------------------------------------------------+-----------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
The right column is **My repositories**, mirroring the sidebar's signed-in repo list:
|
||||||
|
|
||||||
|
```
|
||||||
|
| My repositories |
|
||||||
|
| [search] [New] |
|
||||||
|
| repo row |
|
||||||
|
| repo row |
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Data layer
|
||||||
|
|
||||||
|
### 4.1 `signed_core`: pure logic
|
||||||
|
|
||||||
|
**`filters.rs`** (extend, next to `activity`/`comments_for`):
|
||||||
|
|
||||||
|
```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}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest stored read state for `me`, with the id of the event it came from.
|
||||||
|
async fn load_state(
|
||||||
|
client: &Client,
|
||||||
|
me: PublicKey,
|
||||||
|
) -> Result<Option<(InboxReadState, EventId)>> {
|
||||||
|
// No author filter: the signing key is random per session.
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(inbox_state_d_tag(me));
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|e| e.created_at) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_str(&event.content)
|
||||||
|
.ok()
|
||||||
|
.map(|state| (state, event.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign with the random `keys` and store locally.
|
||||||
|
async fn save_state(
|
||||||
|
client: &Client,
|
||||||
|
keys: &Keys,
|
||||||
|
me: PublicKey,
|
||||||
|
state: &InboxReadState,
|
||||||
|
) -> Result<EventId> {
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||||
|
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||||
|
.finalize(keys)?; // synchronous: random keys, no user signer
|
||||||
|
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
Ok(event.id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kind 30078 is addressable, so saves signed by the same `keys` replace the previous event. Because
|
||||||
|
`keys` is random per session, the first save of a session creates a new coordinate; the store then
|
||||||
|
deletes the event it loaded (its id is kept from `load_state`) so exactly one state event remains.
|
||||||
|
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
|
||||||
|
|
||||||
|
### 4.3 `signed_state`: one `InboxStore`
|
||||||
|
|
||||||
|
One store backs the whole screen, modelled on `RepoListStore` (`repos.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct InboxStore {
|
||||||
|
/// Activity directed at the user, grouped by thread root, newest first.
|
||||||
|
pub notifications: Arc<Vec<InboxItem>>,
|
||||||
|
/// The user's own recent git activity, newest first.
|
||||||
|
pub activity: Arc<Vec<Event>>,
|
||||||
|
/// Unread notification count (non-archived).
|
||||||
|
pub unread_count: usize,
|
||||||
|
state: InboxReadState,
|
||||||
|
user: Option<PublicKey>,
|
||||||
|
/// Random keypair signing the local NIP-78 storage event.
|
||||||
|
keys: Keys,
|
||||||
|
/// Event id of the loaded state event, pruned on the next save.
|
||||||
|
loaded_state: Option<EventId>,
|
||||||
|
refresh: RefreshGate,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lifespan: idle until a signer exists.** The store is created in `signed_state::init` like the
|
||||||
|
other stores, but it does nothing until the user has a signer. It is never wired from the `desktop`
|
||||||
|
crate, and `signed_state::init` gains no parameters.
|
||||||
|
|
||||||
|
- `new` schedules `cx.defer`, like the other stores. The deferred bootstrap checks
|
||||||
|
`Backend::current_user()`:
|
||||||
|
- signer already present (session restored before the store was created): activate now;
|
||||||
|
- no signer: do nothing, wait for the event.
|
||||||
|
- `BackendEvent::SignerChanged`: activate.
|
||||||
|
- `BackendEvent::SignerRequired` (logout / no credential): clear `user`, `notifications`,
|
||||||
|
`activity`, `unread_count`.
|
||||||
|
- `BackendEvent::NostrUpdate(updates)`: refresh when any update kind is in `NOTIFICATION_KINDS`,
|
||||||
|
is `Kind::Comment`, or is a deletion.
|
||||||
|
- `BackendEvent::Synced`: refresh.
|
||||||
|
- `BackendEvent::Published`: refresh.
|
||||||
|
|
||||||
|
**Activation** (only on signer):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn activate(&mut self, me: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
self.user = Some(me);
|
||||||
|
// Load the NIP-78 state from LMDB, then subscribe and refresh.
|
||||||
|
// All run on background tasks; only plain data crosses back.
|
||||||
|
self.load_state(me, cx);
|
||||||
|
self.subscribe_remote(cx);
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Loading uses the random `keys` only for signing on save; reading the state event needs no signer at
|
||||||
|
all. Activation itself is still gated on the signer because the fetch filters need the user's pubkey.
|
||||||
|
|
||||||
|
**Fetch** (reuses `Backend::subscribe_bootstrap` / `connect_repo_relays`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(me) = self.user else { return };
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
|
backend.subscribe_bootstrap(filters::notifications(me), cx);
|
||||||
|
backend.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
||||||
|
});
|
||||||
|
// Relays of the user's own repos, so their activity there is found too.
|
||||||
|
let relays = own_repo_relays(me, cx);
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
|
backend.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
||||||
|
backend.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`own_repo_relays` reads `RepoListStore::global(cx).read(cx).announcements_of(&me)` and unions their
|
||||||
|
`relays`. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind 10002 yet.)
|
||||||
|
|
||||||
|
**Refresh** (mirrors `RepoListStore::run_refresh`):
|
||||||
|
|
||||||
|
- `cx.background_spawn`: query the notification filters and the activity filter 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; take the top N.
|
||||||
|
- Cross back to the main thread: set `notifications`, `activity`, `unread_count`, `cx.notify()`,
|
||||||
|
`refresh.finish()`.
|
||||||
|
|
||||||
|
**Actions**: `mark_read(root)`, `mark_all_read()`, plus `advance_read` after marking to bound the
|
||||||
|
`read_ids` set. After each change, sign with the random `keys` and save the NIP-78 event to LMDB,
|
||||||
|
then delete the previously loaded copy (see 4.2).
|
||||||
|
|
||||||
|
**My repositories needs no new store**: `RepoListStore` already holds every announcement and exposes
|
||||||
|
`announcements_of(user)`.
|
||||||
|
|
||||||
|
### 4.4 `Cargo.toml`
|
||||||
|
|
||||||
|
- `signed_core`: 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
|
||||||
|
|
||||||
|
New `crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
|
||||||
|
One scrollable two-column flex row.
|
||||||
|
|
||||||
|
- **Inbox column**: header with the unread count badge and actions **Unread**, **Archived**,
|
||||||
|
**Mark all read**; then the non-archived notification items (top 5, with a **Show all** toggle
|
||||||
|
expanding inline). Rows show the actor avatar, a kind badge, the subject, the repo name, a
|
||||||
|
relative time, and an unread dot. Empty state: "You're all caught up." with `IconName::Inbox`.
|
||||||
|
- **Continue where you left off**: `InboxStore::activity`, top 15, each row a kind icon, subject,
|
||||||
|
repo name, and relative time.
|
||||||
|
- **My repositories**: `RepoListStore::announcements_of(me)` with a small search `InputState` (same
|
||||||
|
pattern as `RepoListView`) and a **New** button opening the existing `create_repo_dialog`. Rows
|
||||||
|
open `open_repo_panel`.
|
||||||
|
|
||||||
|
No greeting header.
|
||||||
|
|
||||||
|
### 5.2 Unread / Archived as bottom-dock panels
|
||||||
|
|
||||||
|
Add a bottom-panel helper next to `add_center_panel` in `crates/dock/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Add an already-wrapped panel handle to the bottom dock of `area`.
|
||||||
|
pub fn add_bottom_panel(
|
||||||
|
area: &mut DockArea,
|
||||||
|
panel: Arc<dyn PanelView>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<DockArea>,
|
||||||
|
) {
|
||||||
|
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace already supports a bottom dock and prunes it when empty (`workspace.rs`). Then:
|
||||||
|
|
||||||
|
- New `InboxFilterView` panel taking a mode `InboxFilter::Unread | InboxFilter::Archived` and the
|
||||||
|
`InboxStore`. It renders the matching subset of `InboxStore::notifications` as a list.
|
||||||
|
- The **Unread** and **Archived** header buttons in `InboxView` call `add_bottom_panel` with the
|
||||||
|
requested mode. `InboxView` keeps `filter_view: Option<WeakEntity<InboxFilterView>>`; when it
|
||||||
|
already exists, update its mode and focus instead of adding a duplicate.
|
||||||
|
|
||||||
|
### 5.3 Sidebar
|
||||||
|
|
||||||
|
In `views/sidebar/mod.rs`:
|
||||||
|
|
||||||
|
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
|
||||||
|
- Add `fn open_inbox(&mut self, window, cx)` that focuses the existing panel or adds a center panel.
|
||||||
|
- Point the existing nav item at it and add an unread suffix:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
|
||||||
|
.when(unread > 0, |this| this.suffix(...badge...))
|
||||||
|
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cx.observe` the `InboxStore` so the badge updates.
|
||||||
|
|
||||||
|
### 5.4 Click-through (P1)
|
||||||
|
|
||||||
|
Reuse the single `RepoStore` that `RepoDetailView` already creates instead of making a second one:
|
||||||
|
|
||||||
|
1. In `repo_detail/mod.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) enum RepoItem {
|
||||||
|
Issue(EventId),
|
||||||
|
PullRequest(EventId),
|
||||||
|
Patch(EventId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoDetailView {
|
||||||
|
pub(crate) fn open_item(
|
||||||
|
&mut self,
|
||||||
|
item: RepoItem,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) { /* open IssueDetailView / PullRequestDetailView in the dock */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `open_repo_panel` already returns `Entity<RepoDetailView>`; the caller invokes
|
||||||
|
`detail.update_in(window, cx, |detail, window, cx| detail.open_item(...))`.
|
||||||
|
3. `InboxView` resolves `item.address` to an `Announcement` from `RepoListStore`, opens the repo
|
||||||
|
panel, then calls `open_item` with the root id and kind.
|
||||||
|
|
||||||
|
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
|
||||||
|
patch-root click opens the repo panel. Note as a known limitation.
|
||||||
|
|
||||||
|
## 6. File-by-file change list
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `crates/signed_core/Cargo.toml` | add `serde` |
|
||||||
|
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity` |
|
||||||
|
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem`, `notification_root`, `group`, `InboxReadState`, tests |
|
||||||
|
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
|
||||||
|
| `crates/signed_state/Cargo.toml` | add `serde_json` |
|
||||||
|
| `crates/signed_state/src/inbox.rs` | **new**: `InboxStore`, global, signer-gated activation, NIP-78 load/save, actions |
|
||||||
|
| `crates/signed_state/src/lib.rs` | `mod inbox;`, set global in `init` |
|
||||||
|
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper |
|
||||||
|
| `crates/workspace/src/views/inbox.rs` | **new**: `InboxView` home panel and `InboxFilterView` |
|
||||||
|
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;` |
|
||||||
|
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring and badge |
|
||||||
|
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `RepoDetailView::open_item` (P1) |
|
||||||
|
|
||||||
|
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; the store
|
||||||
|
bootstraps itself via `cx.defer` once a signer is present.
|
||||||
|
|
||||||
|
## 7. Phasing
|
||||||
|
|
||||||
|
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 (62 tests).
|
||||||
|
2. **Phase 1 - store**: `InboxStore` with signer-gated activation, both queries, unread count, and
|
||||||
|
NIP-78 load/save to LMDB, global install.
|
||||||
|
3. **Phase 2 - screen**: `InboxView` (inbox + activity + my repositories), sidebar nav and badge.
|
||||||
|
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
|
||||||
|
5. **Phase 4 - click-through**: `open_item` and announcement lookup.
|
||||||
|
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
|
||||||
|
view.
|
||||||
|
|
||||||
|
Each phase compiles and is usable on its own.
|
||||||
|
|
||||||
|
## 8. Validation
|
||||||
|
|
||||||
|
- `cargo test -p signed_core`: root resolution, grouping, read-state cutoff, serde round-trip.
|
||||||
|
- `cargo test -p signed_state`: NIP-78 content round-trip (`serde_json`), if a non-GPUI path is
|
||||||
|
factored out.
|
||||||
|
- `cargo check --workspace` after each phase.
|
||||||
|
- Manual: log in with a repo-owning identity; confirm the inbox panel populates from another
|
||||||
|
identity's issue/comment, the activity list shows your own items, the repositories panel matches
|
||||||
|
the sidebar, and that no kind-30078 event is broadcast (watch the relays / `Published` events).
|
||||||
|
Restart to confirm the read state is read back from LMDB.
|
||||||
|
|
||||||
|
## 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`
|
||||||
Reference in New Issue
Block a user