Files
signed/crates/signed_core/src/inbox.rs
T
2026-09-11 08:52:04 +07:00

700 lines
22 KiB
Rust

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);
}
}