diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs new file mode 100644 index 0000000..131c1b0 --- /dev/null +++ b/crates/signed_core/src/deletions.rs @@ -0,0 +1,79 @@ +use std::collections::HashSet; + +use nostr::prelude::*; + +/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide +/// deleted events before they reach the UI. +/// +/// Built from the kind-5 / kind-62 events stored in the local database; +/// pass any event through [`Deletions::is_deleted`] before displaying it. +pub struct Deletions { + /// `(deleted event id, expected author)` from `e` tags of kind-5 events. + ids: HashSet<(EventId, PublicKey)>, + /// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events. + /// All versions of the addressable event up to `cutoff` are deleted. + coords: Vec<(Coordinate, PublicKey, Timestamp)>, + /// `(author, cutoff)` from kind-62 vanish requests. + vanished: Vec<(PublicKey, Timestamp)>, +} + +impl Deletions { + /// Build the deletion index from raw kind-5 and kind-62 events. + pub fn from_events(events: impl IntoIterator) -> Self { + let mut ids = HashSet::new(); + let mut coords = Vec::new(); + let mut vanished = Vec::new(); + + for event in events { + if event.kind == Kind::EventDeletion { + ids.extend(event.tags.event_ids().map(|id| (id, event.pubkey))); + coords.extend( + event + .tags + .coordinates() + .map(|c| (c, event.pubkey, event.created_at)), + ); + } else if event.kind == Kind::RequestToVanish { + // Client-side we can't verify which relay the request targeted, + // so any vanish request is honored for the author's events. + vanished.push((event.pubkey, event.created_at)); + } + } + + Self { + ids, + coords, + vanished, + } + } + + /// Whether the event is covered by a valid deletion or vanish request. + /// + /// A request is only valid when its author matches the deleted event's + /// author (NIP-09); addressable events are deleted up to the request's + /// `created_at`. + pub fn is_deleted(&self, event: &Event) -> bool { + if self + .vanished + .iter() + .any(|(pk, cutoff)| *pk == event.pubkey && event.created_at <= *cutoff) + { + return true; + } + + if self.ids.contains(&(event.id, event.pubkey)) { + return true; + } + + if event.kind.is_addressable() + && let Some(identifier) = event.tags.identifier() + { + let coordinate = Coordinate::new(event.kind, event.pubkey).identifier(identifier); + return self.coords.iter().any(|(c, pk, cutoff)| { + *c == coordinate && *pk == event.pubkey && event.created_at <= *cutoff + }); + } + + false + } +} diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 8bc26c3..6d2875b 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -72,3 +72,23 @@ pub fn announcements_by(public_key: PublicKey) -> Filter { pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) } + +/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`). +/// +/// Unbounded, like [`all_announcements`]: deletion requests must be known +/// before any other event can be shown. +pub fn deletions() -> Filter { + Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish]) +} + +/// Deletion events relevant to a single repository: requests authored by +/// the repository owner and requests addressed to the repository +/// coordinate (`#a` tag). +pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { + vec![ + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .author(addr.public_key), + Filter::new().kind(Kind::EventDeletion).coordinate(addr), + ] +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index e80f267..6736a64 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,5 +1,6 @@ pub mod addr; pub mod clone_url; +pub mod deletions; pub mod filters; pub mod model; pub mod state; @@ -7,6 +8,7 @@ pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; +pub use deletions::Deletions; pub use model::Announcement; pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 5d189c8..6c85cec 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters, parse_state}; +use signed_core::{Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state}; use crate::backend::{Backend, BackendEvent}; @@ -41,11 +41,14 @@ impl RepoStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { + // Deletions may target any event of this repository. + let deletion = + update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; let coordinate = update.coordinate.as_ref() == Some(&this.addr); let author = update.author == this.addr.public_key; let kind = update.kind == Kind::GitRepoAnnouncement; - coordinate || (author && kind) + deletion || coordinate || (author && kind) } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; @@ -94,14 +97,15 @@ impl RepoStore { let addr = self.addr.clone(); Backend::global(cx).update(cx, |backend, cx| { - backend.subscribe_bootstrap( - vec![ - filters::announcement(&addr), - filters::state(&addr), - filters::activity(&addr), - ], - cx, - ); + let mut repo_filters = vec![ + filters::announcement(&addr), + filters::state(&addr), + filters::activity(&addr), + ]; + // Deletion requests (NIP-09/62) must be known before any + // event of this repository can be shown. + repo_filters.extend(filters::deletions_for_repo(&addr)); + backend.subscribe_bootstrap(repo_filters, cx); }); } @@ -141,31 +145,38 @@ impl RepoStore { let addr = self.addr.clone(); let work = cx.background_spawn(async move { - let queries = async { + let (announcements, states, activity, deletion_events) = async { let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; let states = db.query(filters::state(&addr)).await?; let activity = db.query(filters::activity(&addr)).await?; + let deletion_events = db.query(filters::deletions()).await?; - Ok::<_, Error>((announcements, states, activity)) + Ok::<_, Error>((announcements, states, activity, deletion_events)) } .await?; - let (announcements, states, activity) = queries; + let deletions = Deletions::from_events(deletion_events); // Parse and sort off the main thread; only plain data // crosses back into the entity. - let announcement = latest(announcements) + let all_announcements = announcements + .into_iter() + .filter(|e| !deletions.is_deleted(e)); + let announcement = latest(all_announcements) .as_ref() .and_then(Announcement::from_event); - let state = latest(states).map(|state| parse_state(&state)); + let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); + let state = latest(all_states).map(|state| parse_state(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); for event in activity { + if deletions.is_deleted(&event) { + continue; + } match event.kind { Kind::GitIssue => issues.push(event), Kind::GitPatch => patches.push(event), diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index bb37d6e..5c93224 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, filters, repo_addr}; +use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; use crate::backend::{Backend, BackendEvent}; @@ -40,9 +40,12 @@ impl RepoListStore { let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { - // Activity (patches, issues, ...) is addressed to repos via - // `a` tags, so its author isn't the repo owner; always refresh. - if filters::ACTIVITY_KINDS.contains(&update.kind) { + // Deletions may target anything we list; always refresh. + if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish { + true + } else if filters::ACTIVITY_KINDS.contains(&update.kind) { + // Activity (patches, issues, ...) is addressed to repos via + // `a` tags, so its author isn't the repo owner; always refresh. true } else { let is_announcement = update.kind == Kind::GitRepoAnnouncement; @@ -98,6 +101,9 @@ impl RepoListStore { None => filters::all_announcements(), }; backend.sync_bootstrap(filter, cx); + // Deletion requests (NIP-09/62) must be known before any + // announcement can be shown. + backend.sync_bootstrap(filters::deletions(), cx); }); } @@ -143,12 +149,18 @@ impl RepoListStore { }; let events = client.database().query(filter).await?; + let deletion_events = client.database().query(filters::deletions()).await?; + let deletions = Deletions::from_events(deletion_events); // Dedup and sort off the main thread; only the final list // crosses back into the entity. let mut by_repo: HashMap = HashMap::new(); for event in events { + if deletions.is_deleted(&event) { + continue; + } + let Some(announcement) = Announcement::from_event(&event) else { continue; }; @@ -175,6 +187,9 @@ impl RepoListStore { let state_filter = Filter::new().kind(Kind::RepoState); for event in client.database().query(state_filter).await? { + if deletions.is_deleted(&event) { + continue; + } let Some(id) = event.tags.identifier() else { continue; }; @@ -191,16 +206,10 @@ impl RepoListStore { .kinds(filters::ACTIVITY_KINDS) .since(Timestamp::now() - ACTIVITY_WINDOW); for event in client.database().query(activity_filter).await? { - for tag in event.tags.iter() { - if tag.kind() != "a" { - continue; - } - let Some(content) = tag.content() else { - continue; - }; - let Ok(addr) = Coordinate::parse(content) else { - continue; - }; + if deletions.is_deleted(&event) { + continue; + } + for addr in event.tags.coordinates() { if addr.kind != Kind::GitRepoAnnouncement { continue; }