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, /// Repository the root belongs to, from the root's `a` tag. pub address: Option, /// Events in the group, newest first. pub events: Vec, /// Unread event ids, oldest first. pub unread_ids: Vec, /// 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(event: &Event, lookup: &L) -> Option where L: Fn(EventId) -> Option, { 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(events: E, me: PublicKey, state: &InboxReadState, lookup: &L) -> Vec where E: IntoIterator, L: Fn(EventId) -> Option, { let mut groups: HashMap> = 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 = 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, #[serde(default)] pub archived_before: Timestamp, #[serde(default)] pub archived_ids: HashSet, } 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( 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, all: &[Event], cutoff: Timestamp) { let created_at: HashMap = 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) -> 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 { 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 { 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 { first_tag_id(event, "e") } /// First uppercase `E` tag id, in document order. fn first_uppercase_e_id(event: &Event) -> Option { first_tag_id(event, "E") } fn first_tag_id(event: &Event, name: &str) -> Option { 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 { 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, 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 + '_ { 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); } }