diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs index 2d4d7d0f..562d71fd 100644 --- a/radicle-httpd/src/api/json.rs +++ b/radicle-httpd/src/api/json.rs @@ -1,10 +1,11 @@ //! Utilities for building JSON responses of our API. +use std::collections::BTreeMap; use std::path::Path; use std::str; use base64::prelude::{Engine, BASE64_STANDARD}; -use radicle::cob::CodeLocation; +use radicle::cob::{CodeLocation, Reaction}; use serde_json::{json, Value}; use radicle::cob::issue::{Issue, IssueId}; @@ -130,6 +131,15 @@ pub(crate) fn patch( "author": author(rev.author(), aliases.alias(rev.author().id())), "description": rev.description(), "edits": rev.edits().map(|e| edit(e, aliases)).collect::>(), + "reactions": rev.reactions().iter().flat_map(|(location, reaction)| { + let reactions = reaction.iter().fold(BTreeMap::new(), |mut acc: BTreeMap<&Reaction, Vec<_>>, (author, emoji)| { + acc.entry(emoji).or_default().push(author); + acc + }); + reactions.iter().map(|(emoji, authors)| + json!({"location": location, "emoji": emoji, "authors": authors }) + ).collect::>() + }).collect::>(), "base": rev.base(), "oid": rev.head(), "refs": get_refs(repo, patch.author().id(), &rev.head()).unwrap_or_default(), @@ -191,7 +201,9 @@ fn issue_comment(id: &CommentId, comment: &Comment, aliases: &impl AliasStore) - "body": comment.body(), "edits": comment.edits().map(|e| edit(e, aliases)).collect::>(), "embeds": comment.embeds().to_vec(), - "reactions": comment.reactions().collect::>(), + "reactions": comment.reactions().iter().map(|(emoji, authors)| + json!({ "emoji": emoji, "authors": authors }) + ).collect::>(), "timestamp": comment.timestamp().as_secs(), "replyTo": comment.reply_to(), "resolved": comment.resolved(), @@ -210,7 +222,9 @@ fn patch_comment( "body": comment.body(), "edits": comment.edits().map(|e| edit(e, aliases)).collect::>(), "embeds": comment.embeds().to_vec(), - "reactions": comment.reactions().collect::>(), + "reactions": comment.reactions().iter().map(|(emoji, authors)| + json!({ "emoji": emoji, "authors": authors }) + ).collect::>(), "timestamp": comment.timestamp().as_secs(), "replyTo": comment.reply_to(), "location": comment.location(), @@ -230,7 +244,9 @@ fn review_comment( "body": comment.body(), "edits": comment.edits().map(|e| edit(e, aliases)).collect::>(), "embeds": comment.embeds().to_vec(), - "reactions": comment.reactions().collect::>(), + "reactions": comment.reactions().iter().map(|(emoji, authors)| + json!({ "emoji": emoji, "authors": authors }) + ).collect::>(), "timestamp": comment.timestamp().as_secs(), "replyTo": comment.reply_to(), "location": comment.location(), diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index f2734a06..35d40294 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -869,6 +869,12 @@ async fn patch_update_handler( patch.edit_revision(revision, description, embeds, &signer)? } patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?, + patch::Action::RevisionReact { + revision, + reaction, + active, + location, + } => patch.react(revision, reaction, location, active, &signer)?, patch::Action::RevisionComment { revision, body, @@ -903,9 +909,6 @@ async fn patch_update_handler( patch::Action::RevisionCommentRedact { revision, comment } => { patch.comment_redact(revision, comment, &signer)? } - _ => { - todo!(); - } }; announce_refs(node, repo.id())?; @@ -2281,10 +2284,10 @@ mod routes { } ], "reactions": [ - [ - "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8", - "🚀", - ], + { + "emoji": "🚀", + "authors": ["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"] + }, ], "timestamp": TIMESTAMP, "replyTo": null, @@ -2433,6 +2436,7 @@ mod routes { "revisions": [ { "id": CONTRIBUTOR_PATCH_ID, + "reactions": [], "author": { "id": CONTRIBUTOR_DID, }, @@ -2485,6 +2489,7 @@ mod routes { "revisions": [ { "id": CONTRIBUTOR_PATCH_ID, + "reactions": [], "author": { "id": CONTRIBUTOR_DID, }, @@ -2576,6 +2581,7 @@ mod routes { "revisions": [ { "id": CREATED_PATCH_ID, + "reactions": [], "author": { "id": CONTRIBUTOR_DID, }, @@ -2662,6 +2668,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -2736,6 +2743,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -2809,6 +2817,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -2834,6 +2843,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -2893,6 +2903,7 @@ mod routes { "revisions": [ { "id": CONTRIBUTOR_PATCH_ID, + "reactions": [], "author": { "id": CONTRIBUTOR_DID, }, @@ -2943,6 +2954,52 @@ mod routes { assert_eq!(response.status(), StatusCode::OK); + let body = serde_json::to_vec(&json!({ + "type": "revision.react", + "revision": CONTRIBUTOR_PATCH_ID, + "reaction": "🚀", + "location": { + "commit": INITIAL_COMMIT, + "path": "./README.md", + "new": { + "type": "lines", + "range": { + "start": 0, + "end": 1 + } + } + }, + "active": true, + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(body)), + Some(SESSION_ID.to_string()), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + + let body = serde_json::to_vec(&json!({ + "type": "revision.react", + "revision": CONTRIBUTOR_PATCH_ID, + "reaction": "🙏", + "location": null, + "active": true, + })) + .unwrap(); + let response = patch( + &app, + format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), + Some(Body::from(body)), + Some(SESSION_ID.to_string()), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let response = get( &app, format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"), @@ -2987,6 +3044,29 @@ mod routes { "embeds": [], }, ], + "reactions": [ + { + "location": null, + "emoji": "🙏", + "authors": ["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"], + }, + { + "location": { + "commit": INITIAL_COMMIT, + "path": "./README.md", + "old": null, + "new": { + "type": "lines", + "range": { + "start": 0, + "end": 1 + } + } + }, + "emoji": "🚀", + "authors": ["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"] + }, + ], "base": PARENT, "oid": HEAD, "refs": [ @@ -3123,6 +3203,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -3169,7 +3250,12 @@ mod routes { "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc", } ], - "reactions": [["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8","🚀"]], + "reactions": [ + { + "emoji": "🚀", + "authors": ["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"] + }, + ], "timestamp": TIMESTAMP, "replyTo": null, "location": null, @@ -3350,6 +3436,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ @@ -3406,10 +3493,10 @@ mod routes { }, ], "reactions": [ - [ - "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8", - "🚀", - ], + { + "emoji": "🚀", + "authors": ["z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"], + }, ], "timestamp": 1671125284, "replyTo": null, @@ -3506,6 +3593,7 @@ mod routes { "embeds": [], }, ], + "reactions": [], "base": PARENT, "oid": HEAD, "refs": [ diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index 5058fa84..e9b8d04b 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -1167,9 +1167,10 @@ mod test { let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); - let (_, r) = issue.comment(&comment).unwrap().reactions().next().unwrap(); + let reactions = issue.comment(&comment).unwrap().reactions(); + let authors = reactions.get(&reaction).unwrap(); - assert_eq!(r, &reaction); + assert_eq!(authors.first().unwrap(), &node.signer.public_key()); // TODO: Test multiple reactions from same author and different authors } diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index d6983af2..7972cd6c 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -1332,6 +1332,10 @@ impl Revision { &self.description.last().embeds } + pub fn reactions(&self) -> &BTreeMap, BTreeSet<(PublicKey, Reaction)>> { + &self.reactions + } + /// Author of the revision. pub fn author(&self) -> &Author { &self.author @@ -1593,6 +1597,22 @@ impl store::Transaction { }) } + /// React on a patch revision. + pub fn react( + &mut self, + revision: RevisionId, + reaction: Reaction, + location: Option, + active: bool, + ) -> Result<(), store::Error> { + self.push(Action::RevisionReact { + revision, + reaction, + location, + active, + }) + } + /// Comment on a patch revision. pub fn comment( &mut self, @@ -1924,6 +1944,20 @@ where }) } + /// React on a patch revision. + pub fn react( + &mut self, + revision: RevisionId, + reaction: Reaction, + location: Option, + active: bool, + signer: &G, + ) -> Result { + self.transaction("React", signer, |tx| { + tx.react(revision, reaction, location, active) + }) + } + /// Edit a comment on a patch revision. pub fn comment_edit( &mut self, diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs index e71f6597..0ed50d4d 100644 --- a/radicle/src/cob/thread.rs +++ b/radicle/src/cob/thread.rs @@ -196,8 +196,13 @@ impl Comment { } /// Comment reactions. - pub fn reactions(&self) -> impl Iterator { - self.reactions.iter().map(|(a, r)| (a, r)) + 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.