wip
This commit is contained in:
@@ -1,164 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// A NIP-22 comment thread, a top-level comment on the root event,
|
||||
/// nested replies are ordered oldest first at every level.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentThread {
|
||||
/// The thread's top-level comment.
|
||||
pub comment: Event,
|
||||
/// Replies to [`Self::comment`], nested recursively.
|
||||
pub replies: Vec<CommentThread>,
|
||||
}
|
||||
|
||||
/// The direct parent id of a comment, from its NIP-22 lowercase `e` tag.
|
||||
/// `None` when no `e` tag is present.
|
||||
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "e")
|
||||
.and_then(Tag::content)
|
||||
.and_then(|id| EventId::parse(id).ok())
|
||||
}
|
||||
|
||||
/// Group the comments on a root issue, patch or PR into NIP-22 threads,
|
||||
/// a comment whose parent is the root starts a thread.
|
||||
///
|
||||
/// Other comments nest under their parent comment.
|
||||
///
|
||||
/// Threads and replies are ordered oldest first,
|
||||
/// replies with a missing parent are made top-level threads, so none are dropped.
|
||||
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
// Index comments by their parent id.
|
||||
// Comments without a parent tag reply to the root event itself.
|
||||
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for comment in comments {
|
||||
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||
children.entry(parent).or_default().push(comment);
|
||||
}
|
||||
for list in children.values_mut() {
|
||||
list.sort_by_key(|event| event.created_at);
|
||||
}
|
||||
|
||||
let mut visited: HashSet<EventId> = HashSet::new();
|
||||
|
||||
fn build(
|
||||
id: EventId,
|
||||
children: &HashMap<EventId, Vec<&Event>>,
|
||||
visited: &mut HashSet<EventId>,
|
||||
) -> Vec<CommentThread> {
|
||||
let Some(list) = children.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut threads = Vec::new();
|
||||
for event in list {
|
||||
// Guards against malformed reply cycles.
|
||||
if visited.insert(event.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: (*event).clone(),
|
||||
replies: build(event.id, children, visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
threads
|
||||
}
|
||||
|
||||
let mut threads = build(root.id, &children, &mut visited);
|
||||
|
||||
// Orphan replies have an unknown parent comment, so they never reach the root tree.
|
||||
// Surface them as top-level threads so they are not dropped.
|
||||
let mut orphans: Vec<&Event> = comments
|
||||
.iter()
|
||||
.filter(|event| !visited.contains(&event.id))
|
||||
.collect();
|
||||
orphans.sort_by_key(|event| event.created_at);
|
||||
for comment in orphans {
|
||||
if visited.insert(comment.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: comment.clone(),
|
||||
replies: build(comment.id, &children, &mut visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
threads
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
|
||||
let tags = parent
|
||||
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
|
||||
.unwrap_or_default();
|
||||
EventBuilder::new(Kind::Comment, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn flatten(threads: &[CommentThread]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for thread in threads {
|
||||
out.push(thread.comment.content.clone());
|
||||
out.extend(flatten(&thread.replies));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nests_replies_under_their_parents() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
let a1 = comment(&keys, Some(&a), "a1", 200);
|
||||
let a2 = comment(&keys, Some(&a), "a2", 300);
|
||||
let b = comment(&keys, Some(&root), "b", 150);
|
||||
|
||||
let threads = comment_threads(&root, &[a2, b, a, a1]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_parent_tag_attach_to_the_root() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
// Old-style comments carried no `e` tag at all.
|
||||
let orphan = comment(&keys, None, "no parent", 100);
|
||||
|
||||
let threads = comment_threads(&root, &[orphan]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["no parent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_replies_are_surfaced_as_top_level_threads() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
|
||||
// `missing` is not in the comment set.
|
||||
// Its reply should still show up.
|
||||
let missing = EventBuilder::new(Kind::Comment, "missing")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
|
||||
|
||||
let threads = comment_threads(&root, &[a, reply_to_missing]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod comments;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod model;
|
||||
@@ -11,7 +10,6 @@ pub mod status;
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use comments::{CommentThread, comment_threads};
|
||||
pub use deletions::Deletions;
|
||||
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
||||
pub use state::{build_state, parse_state};
|
||||
|
||||
Reference in New Issue
Block a user