use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::str::FromStr; use std::sync::LazyLock; use serde::{Deserialize, Serialize, ser::SerializeStruct}; use thiserror::Error; use crate::cob; use crate::cob::common::{Reaction, Timestamp, Uri}; use crate::cob::store::Cob; use crate::cob::{ActorId, Embed, EntryId, Op, op}; use crate::git; use crate::prelude::ReadRepository; /// Type name of a thread, as well as the domain for all thread operations. /// Note that threads are not usually used standalone. They are embedded into other COBs. pub static TYPENAME: LazyLock = LazyLock::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid")); /// Error applying an operation onto a state. #[derive(Error, Debug)] pub enum Error { /// Causal dependency missing. /// /// This error indicates that the operations are not being applied /// in causal order, which is a requirement for this CRDT. /// /// For example, this can occur if an operation references anothern operation /// that hasn't happened yet. #[error("causal dependency {0:?} missing")] Missing(EntryId), /// The identity doc is missing. #[error("identity document missing")] MissingIdentity, /// Error with comment operation. #[error("comment {0} is invalid")] Comment(EntryId), /// Error with edit operation. #[error("edit {0} is invalid")] Edit(EntryId), /// Object initialization failed. #[error("initialization failed: {0}")] Init(&'static str), #[error("op decoding failed: {0}")] Op(#[from] op::OpEncodingError), } /// Identifies a comment. pub type CommentId = EntryId; /// Reactions to a comment or other action. pub type Reactions = BTreeSet<(ActorId, Reaction)>; /// A comment edit is just some text and an edit time. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Edit { /// Edit author. pub author: ActorId, /// When the edit was made. pub timestamp: Timestamp, /// Edit contents. Replaces previous edits. pub body: String, /// Edit embed list. pub embeds: Vec>, } impl Edit { /// Create a new edit. pub fn new( author: ActorId, body: String, timestamp: Timestamp, embeds: Vec>, ) -> Self { Self { author, timestamp, body, embeds, } } } /// The `Infallible` type does not have a `Serialize`/`Deserialize` /// implementation. The `Never` type imitates `Infallible` and /// provides the derived implementations. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Never {} /// A comment on a discussion thread. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Comment { /// Comment author. pub(in crate::cob) author: ActorId, /// The comment body. pub(in crate::cob) edits: Vec, /// Reactions to this comment. pub(in crate::cob) reactions: Reactions, /// Comment this is a reply to. /// Should always be set, except for the root comment. pub(in crate::cob) reply_to: Option, /// Location of comment, if this is an inline comment. pub(in crate::cob) location: Option, /// Whether the comment has been resolved. pub(in crate::cob) resolved: bool, } impl Serialize for Comment { fn serialize(&self, serializer: S) -> Result where S: serde::ser::Serializer, { let mut state = serializer.serialize_struct("Comment", 8)?; state.serialize_field("author", &self.author())?; if let Some(to) = self.reply_to { state.serialize_field("replyTo", &to)?; } state.serialize_field("reactions", &self.reactions)?; state.serialize_field("resolved", &self.resolved)?; state.serialize_field("body", self.body())?; state.serialize_field("edits", &self.edits)?; if let Some(location) = self.location() { state.serialize_field("location", &location)?; } let embeds = self.embeds(); if !embeds.is_empty() { state.serialize_field("embeds", self.embeds())?; } state.end() } } impl Comment { /// Create a new comment. pub fn new( author: ActorId, body: String, reply_to: Option, location: Option, embeds: Vec>, timestamp: Timestamp, ) -> Self { let edit = Edit::new(author, body, timestamp, embeds); Self { author, reactions: BTreeSet::default(), edits: vec![edit], reply_to, location, resolved: false, } } /// Get the comment body. If there are multiple edits, gets the value at the latest edit. pub fn body(&self) -> &str { // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`] // constructor. #[allow(clippy::unwrap_used)] self.edits.last().unwrap().body.as_str() } /// Get the comment timestamp, which is the time of the *original* edit. To get the timestamp /// of the latest edit, use the [`Comment::edits`] function. pub fn timestamp(&self) -> Timestamp { // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`] // constructor. #[allow(clippy::unwrap_used)] self.edits.first().unwrap().timestamp } /// Return the comment author. pub fn author(&self) -> ActorId { self.author } /// Return the comment this is a reply to. Returns nothing if this is the root comment. pub fn reply_to(&self) -> Option { self.reply_to } /// Return the ordered list of edits for this comment, including the original version. pub fn edits(&self) -> impl Iterator { self.edits.iter() } /// Add an edit. pub fn edit( &mut self, author: ActorId, body: String, embeds: Vec>, timestamp: Timestamp, ) { self.edits.push(Edit::new(author, body, timestamp, embeds)); } /// Comment reactions. pub fn reactions(&self) -> BTreeMap<&Reaction, Vec<&ActorId>> { self.reactions .iter() .fold(BTreeMap::new(), |mut acc, (author, reaction)| { acc.entry(reaction).or_default().push(author); acc }) } /// Get comment location, if any. pub fn location(&self) -> Option<&L> { self.location.as_ref() } /// Get comment resolution status. pub fn is_resolved(&self) -> bool { self.resolved } /// Return the embedded media. pub fn embeds(&self) -> &[Embed] { // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`] // constructor. #[allow(clippy::unwrap_used)] &self.edits.last().unwrap().embeds } pub fn resolve(&mut self) { self.resolved = true; } pub fn unresolve(&mut self) { self.resolved = false; } } impl PartialOrd for Comment { fn partial_cmp(&self, other: &Self) -> Option { if self == other { Some(Ordering::Equal) } else { None } } } /// An action that can be carried out in a change. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { /// Comment on a thread. #[serde(rename_all = "camelCase")] Comment { /// Comment body. body: String, /// Comment this is a reply to. /// Should be [`None`] if it's the top-level comment. /// Should be the root [`CommentId`] if it's a top-level comment. reply_to: Option, }, /// Edit a comment. Edit { id: CommentId, body: String }, /// Redact a change. Not all changes can be redacted. Redact { id: CommentId }, /// React to a comment. React { to: CommentId, reaction: Reaction, active: bool, }, } impl cob::store::CobAction for Action { fn produces_identifier(&self) -> bool { matches!(self, Self::Comment { .. }) } } impl From for nonempty::NonEmpty { fn from(action: Action) -> Self { Self::new(action) } } /// A discussion thread. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Thread { /// The comments under the thread. pub(crate) comments: BTreeMap>, /// Comment timeline. pub(crate) timeline: Vec, } impl Default for Thread { fn default() -> Self { Self { comments: BTreeMap::default(), timeline: Vec::default(), } } } impl cob::store::CobWithType for Thread { fn type_name() -> &'static radicle_cob::TypeName { &TYPENAME } } impl Thread { pub fn new(id: CommentId, comment: T) -> Self { Self { comments: BTreeMap::from_iter([(id, Some(comment))]), timeline: vec![id], } } pub fn is_initialized(&self) -> bool { !self.comments.is_empty() } pub fn is_empty(&self) -> bool { self.len() == 0 } pub fn len(&self) -> usize { self.comments.len() } pub fn comment(&self, id: &CommentId) -> Option<&T> { self.comments.get(id).and_then(|o| o.as_ref()) } pub fn root(&self) -> Option<(&CommentId, &T)> { self.first() } pub fn first(&self) -> Option<(&CommentId, &T)> { self.comments().next() } pub fn last(&self) -> Option<(&CommentId, &T)> { self.comments().next_back() } pub fn comments(&self) -> impl DoubleEndedIterator + '_ { self.timeline.iter().filter_map(|id| { self.comments .get(id) .and_then(|o| o.as_ref()) .map(|comment| (id, comment)) }) } pub fn timeline(&self) -> impl DoubleEndedIterator + '_ { self.timeline.iter() } } impl Thread { /// Apply a single action to the thread. fn action( &mut self, action: Action, entry: EntryId, author: ActorId, timestamp: Timestamp, _concurrent: &[&cob::Entry], _identity: git::Oid, _repo: &R, ) -> Result<(), Error> { match action { Action::Comment { body, reply_to } => { comment(self, entry, author, timestamp, body, reply_to, None, vec![])?; } Action::Edit { id, body } => { edit(self, entry, author, id, timestamp, body, vec![])?; } Action::Redact { id } => { redact(self, entry, id)?; } Action::React { to, reaction, active, } => { react(self, entry, author, to, reaction, active)?; } } Ok(()) } } impl Thread> { pub fn replies<'a>( &'a self, to: &'a CommentId, ) -> impl Iterator)> { self.comments().filter_map(move |(id, c)| { if let Some(reply_to) = c.reply_to { if &reply_to == to { return Some((id, c)); } } None }) } } impl cob::store::Cob for Thread { type Action = Action; type Error = Error; fn from_root(op: Op, repo: &R) -> Result { let author = op.author; let entry = op.id; let timestamp = op.timestamp; let identity = op.identity.ok_or(Error::MissingIdentity)?; let mut actions = op.actions.into_iter(); let Some(Action::Comment { body, reply_to: None, }) = actions.next() else { return Err(Error::Init("missing initial comment")); }; let mut thread = Thread::default(); comment( &mut thread, entry, author, timestamp, body, None, None, vec![], )?; for action in actions { thread.action(action, entry, author, timestamp, &[], identity, repo)?; } Ok(thread) } fn op<'a, R: ReadRepository, I: IntoIterator>( &mut self, op: Op, concurrent: I, repo: &R, ) -> Result<(), Error> { let identity = op.identity.ok_or(Error::MissingIdentity)?; let concurrent = concurrent.into_iter().collect::>(); for action in op.actions { self.action( action, op.id, op.author, op.timestamp, &concurrent, identity, repo, )?; } Ok(()) } } impl cob::Evaluate for Thread { type Error = Error; fn init(entry: &cob::Entry, repo: &R) -> Result { let op = Op::try_from(entry)?; let object = ::from_root(op, repo)?; Ok(object) } fn apply<'a, I: Iterator>( &mut self, entry: &cob::Entry, concurrent: I, repo: &R, ) -> Result<(), Self::Error> { let op = Op::try_from(entry)?; self.op(op, concurrent.map(|(_, e)| e), repo) } } pub fn comment( thread: &mut Thread>, id: EntryId, author: ActorId, timestamp: Timestamp, body: String, reply_to: Option, location: Option, embeds: Vec>, ) -> Result<(), Error> { if body.is_empty() { return Err(Error::Comment(id)); } if let Some(id) = reply_to { if !thread.comments.contains_key(&id) { return Err(Error::Missing(id)); } } debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); // Nb. If a comment is already present, it must be redacted, because the // underlying store guarantees exactly-once delivery of ops. thread.comments.insert( id, Some(Comment::new( author, body, reply_to, location, embeds, timestamp, )), ); Ok(()) } pub fn edit( thread: &mut Thread>, id: EntryId, author: ActorId, comment: EntryId, timestamp: Timestamp, body: String, embeds: Vec>, ) -> Result<(), Error> { debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); // It's possible for a comment to be redacted before we're able to edit it, in // case of a concurrent update. // // However, it's *not* possible for the comment to be absent. Therefore we treat // that as an error. if let Some(comment) = thread.comments.get_mut(&comment) { if let Some(comment) = comment { comment.edit(author, body, embeds, timestamp); } } else { return Err(Error::Missing(comment)); } Ok(()) } pub fn redact(thread: &mut Thread, id: EntryId, comment: EntryId) -> Result<(), Error> { if let Some(comment) = thread.comments.get_mut(&comment) { debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); *comment = None; } else { return Err(Error::Missing(id)); } Ok(()) } pub fn react( thread: &mut Thread>, id: EntryId, author: ActorId, comment: EntryId, reaction: Reaction, active: bool, ) -> Result<(), Error> { let key = (author, reaction); let Some(comment) = thread.comments.get_mut(&comment) else { return Err(Error::Missing(comment)); }; if let Some(comment) = comment { debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); if active { comment.reactions.insert(key); } else { comment.reactions.remove(&key); } } Ok(()) } pub fn resolve( thread: &mut Thread>, id: EntryId, comment: EntryId, ) -> Result<(), Error> { let Some(comment) = thread.comments.get_mut(&comment) else { return Err(Error::Missing(comment)); }; if let Some(comment) = comment { debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); comment.resolve(); } Ok(()) } pub fn unresolve( thread: &mut Thread>, id: EntryId, comment: EntryId, ) -> Result<(), Error> { let Some(comment) = thread.comments.get_mut(&comment) else { return Err(Error::Missing(comment)); }; if let Some(comment) = comment { debug_assert!(!thread.timeline.contains(&id)); thread.timeline.push(id); comment.unresolve(); } Ok(()) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::ops::{Deref, DerefMut}; use pretty_assertions::assert_eq; use qcheck_macros::quickcheck; use super::*; use crate as radicle; use crate::cob::store::Cob; use crate::cob::test; use crate::crypto::Signer; use crate::crypto::test::signer::MockSigner; use crate::node::device::Device; use crate::profile::env; use crate::test::arbitrary; use crate::test::arbitrary::r#gen; use crate::test::storage::MockRepository; /// An object that can be used to create and sign changes. pub struct Actor { inner: cob::test::Actor, } impl Default for Actor { fn default() -> Self { Self { inner: cob::test::Actor::::default(), } } } impl Actor { pub fn new(signer: Device) -> Self { Self { inner: cob::test::Actor::new(signer), } } /// Create a new comment. pub fn comment(&mut self, body: &str, reply_to: Option) -> Op { self.op::([Action::Comment { body: String::from(body), reply_to, }]) } /// Create a new redaction. pub fn redact(&mut self, id: CommentId) -> Op { self.op::([Action::Redact { id }]) } /// Edit a comment. pub fn edit(&mut self, id: CommentId, body: &str) -> Op { self.op::([Action::Edit { id, body: body.to_owned(), }]) } } impl Deref for Actor { type Target = cob::test::Actor; fn deref(&self) -> &Self::Target { &self.inner } } impl DerefMut for Actor { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } #[test] fn test_redact_comment() { let radicle::test::setup::Node { signer, .. } = radicle::test::setup::Node::default(); let repo = r#gen::(1); let mut alice = Actor::new(signer); let a0 = alice.comment("First comment", None); let a1 = alice.comment("Second comment", Some(a0.id())); let a2 = alice.comment("Third comment", Some(a0.id())); let mut thread = Thread::from_ops([a0, a1.clone(), a2], &repo).unwrap(); assert_eq!(thread.comments().count(), 3); // Redact the second comment. let a3 = alice.redact(a1.id()); thread.op(a3, [], &repo).unwrap(); let (_, comment0) = thread.comments().next().unwrap(); let (_, comment1) = thread.comments().nth(1).unwrap(); assert_eq!(thread.comments().count(), 2); assert_eq!(comment0.body(), "First comment"); assert_eq!(comment1.body(), "Third comment"); // Second comment was redacted. } #[test] fn test_edit_comment() { let mut alice = Actor::::default(); let repo = r#gen::(1); let c0 = alice.comment("Hello world!", None); let c1 = alice.edit(c0.id(), "Goodbye world."); let c2 = alice.edit(c0.id(), "Goodbye world!"); let t1 = Thread::from_ops([c0.clone(), c1, c2], &repo).unwrap(); let comment = t1.comment(&c0.id()); let edits = comment.unwrap().edits().collect::>(); assert_eq!(edits[0].body.as_str(), "Hello world!"); assert_eq!(edits[1].body.as_str(), "Goodbye world."); assert_eq!(edits[2].body.as_str(), "Goodbye world!"); assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!"); } #[test] fn test_timeline() { let alice = MockSigner::default(); let bob = MockSigner::default(); let eve = MockSigner::default(); let repo = r#gen::(1); let time = env::local_time(); let mut a = test::history::( &[Action::Comment { body: "Thread root".to_owned(), reply_to: None, }], time.into(), &alice, ); a.comment("Alice comment", Some(*a.root().id()), &alice); let mut b = a.clone(); let b1 = b.comment("Bob comment", Some(*a.root().id()), &bob); let mut e = a.clone(); let e1 = e.comment("Eve comment", Some(*a.root().id()), &eve); assert_eq!(a.as_ref().len(), 2); assert_eq!(b.as_ref().len(), 3); assert_eq!(e.as_ref().len(), 3); a.merge(b.clone()); a.merge(e.clone()); assert_eq!(a.as_ref().len(), 4); b.merge(a.clone()); b.merge(e.clone()); e.merge(a.clone()); e.merge(b.clone()); assert_eq!(a, b); assert_eq!(b, e); let t1 = Thread::from_history(&a, &repo).unwrap(); let t2 = Thread::from_history(&b, &repo).unwrap(); let t3 = Thread::from_history(&e, &repo).unwrap(); assert_eq!(t1, t2); assert_eq!(t2, t3); let timeline1 = t1.comments().collect::>(); let timeline2 = t2.comments().collect::>(); let timeline3 = t3.comments().collect::>(); assert_eq!(timeline1, timeline2); assert_eq!(timeline2, timeline3); assert_eq!(timeline1.len(), 4); assert_eq!( timeline1.iter().map(|(_, c)| c.body()).collect::>(), // Since the operations are concurrent, the ordering depends on the ordering between // the operation ids. if e1 > b1 { vec!["Thread root", "Alice comment", "Bob comment", "Eve comment"] } else { vec!["Thread root", "Alice comment", "Eve comment", "Bob comment"] } ); } #[test] fn test_duplicate_comments() { let repo = r#gen::(1); let alice = MockSigner::default(); let bob = MockSigner::default(); let time = env::local_time(); let mut a = test::history::( &[Action::Comment { body: "Thread root".to_owned(), reply_to: None, }], time.into(), &alice, ); let mut b = a.clone(); a.comment("Hello World!", None, &alice); b.comment("Hello World!", None, &bob); a.merge(b); let thread = Thread::from_history(&a, &repo).unwrap(); assert_eq!(thread.comments().count(), 3); let (first_id, first) = thread.comments().nth(1).unwrap(); let (second_id, second) = thread.comments().nth(2).unwrap(); assert!(first_id != second_id); // The ids are not the same, assert_eq!( first .edits .iter() .map(|e| (&e.body, e.timestamp)) .collect::>(), second .edits .iter() .map(|e| (&e.body, e.timestamp)) .collect::>(), ); // despite the content being the same. } #[quickcheck] fn prop_ordering(timestamp: u64) { let repo = r#gen::(1); let alice = MockSigner::default(); let bob = MockSigner::default(); let timestamp = Timestamp::from_secs(timestamp); let h0 = test::history::( &[Action::Comment { body: "Thread root".to_owned(), reply_to: None, }], timestamp, &alice, ); let mut h1 = h0.clone(); let mut h2 = h0.clone(); let e1 = h1.commit( &Action::Edit { id: *h0.root().id(), body: String::from("Bye World."), }, &alice, ); let e2 = h2.commit( &Action::Edit { id: *h0.root().id(), body: String::from("Hi World."), }, &bob, ); h1.merge(h2); let thread = Thread::from_history(&h1, &repo).unwrap(); let (_, comment) = thread.comments().next().unwrap(); // E1 and E2 are concurrent, so the final edit will depend on which is the greater hash. if e2 > e1 { assert_eq!(comment.body(), "Hi World."); } else { assert_eq!(comment.body(), "Bye World."); } let _e3 = h1.commit( &Action::Edit { id: *h0.root().id(), body: String::from("Hoho World!"), }, &alice, ); let thread = Thread::from_history(&h1, &repo).unwrap(); let (_, comment) = thread.comments().next().unwrap(); // E3 is causally dependent on E1 and E2, so it always wins. assert_eq!(comment.body(), "Hoho World!"); } #[test] fn test_comment_redact_missing() { let repo = r#gen::(1); let mut alice = Actor::::default(); let mut t = Thread::default(); let id = arbitrary::entry_id(); t.op(alice.redact(id), [], &repo).unwrap_err(); } #[test] fn test_comment_edit_missing() { let repo = r#gen::(1); let mut alice = Actor::::default(); let mut t = Thread::default(); let id = arbitrary::entry_id(); t.op(alice.edit(id, "Edited"), [], &repo).unwrap_err(); } #[test] fn test_comment_edit_redacted() { let repo = r#gen::(1); let mut alice = Actor::::default(); let a1 = alice.comment("Hi", None); let a2 = alice.redact(a1.id); let a3 = alice.edit(a1.id, "Edited"); let t = Thread::from_ops([a1, a2, a3], &repo).unwrap(); assert_eq!(t.comments().count(), 0); } }