//! Reactions: `dev.knotless.addReaction` and `removeReaction`, as the //! account logged in to the pages, through the same code as `knotless //! react`, and `getReactions`, from the index, which `getThread` counts //! the same way. A private repository's are in its space: counted from //! this server's index of it. use super::repo::{found, invalid}; use super::stars::logged_in; use super::*; use crate::viewer::Viewer; use axum::Extension; use knotless::social::{self, REACTIONS}; /// `dev.knotless.defs#reactionCountView`. #[derive(Serialize, Clone)] pub struct ReactionCountView { content: String, count: u64, #[serde(skip_serializing_if = "Option::is_none")] reaction: Option, } /// `dev.knotless.defs#reactionState`. #[derive(Serialize)] pub struct ReactionState { subject: String, reactions: Vec, } /// The reactions to each of `subjects` on `repo`, naming `viewer`'s own: /// by subject, each in [`REACTIONS`]'s order, leaving out any other /// content. As the index counts them; for a private repository, as this /// server's index of its space does. None, where there is no index. pub(super) fn counted( app: &App, repo: &Repo, subjects: &[String], viewer: Option<&str>, ) -> HashMap> { let index = super::indexed(app, repo); let rows = index.and_then(|index| index.reactions(subjects, viewer).ok()); let mut by: HashMap> = HashMap::new(); for (subject, content, count, reaction) in rows.unwrap_or_default() { let at = REACTIONS.iter().position(|r| *r == content); let view = ReactionCountView { content, count, reaction, }; by.entry(subject) .or_default() .extend(at.map(|at| (at, view))); } by.into_iter().map(ordered).collect() } /// A subject's reactions, in [`REACTIONS`]'s order. fn ordered( (subject, mut views): (String, Vec<(usize, ReactionCountView)>), ) -> (String, Vec) { views.sort_by_key(|(at, _)| *at); (subject, views.into_iter().map(|(_, v)| v).collect()) } #[derive(Deserialize)] pub struct ReactionInput { repo: String, subject: String, content: String, } /// `dev.knotless.addReaction`. pub async fn add_reaction( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { reacting(&app, viewer, &headers, input, true).await } /// `dev.knotless.removeReaction`. pub async fn remove_reaction( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { reacting(&app, viewer, &headers, input, false).await } /// Reacts to `input.subject` as the account logged in to the pages, or /// takes the reaction back, and indexes what that wrote at once, so the /// counts it answers, and the page after, have it: for a private /// repository, in this server's index of its space, if it keeps one. async fn reacting( app: &App, viewer: Viewer, headers: &HeaderMap, input: ReactionInput, on: bool, ) -> Result { let did = logged_in(app, viewer, headers, "react")?; if !REACTIONS.contains(&input.content.as_str()) { let why = format!("a reaction is one of {}", REACTIONS.join(" ")); return Err(invalid(why)); } let repo = found(app, &input.repo).await?; let subject = repo.named(&input.subject); let subject = subject.as_str(); // Only what the repository's pages show is reacted to: not what its // owner keeps out. let made = assembled(app, &repo).await?; if on && !made.entries.iter().any(|e| e.uri == subject) { let why = anyhow::anyhow!("no pull, issue or comment {subject} on {}", input.repo); return Err(not_found(why)); } let ctx = crate::login::ctx_for(app, &did); let url = RepoUrl { authority: repo.did.clone(), name: repo.name.clone(), }; let reacted = social::react(&ctx, &url, subject, &input.content, on) .await .map_err(AppError::upstream)?; let space = repo.in_space.as_deref(); let wrote = super::indexed(app, &repo).map(|index| indexed(index, space, &did, &reacted)); wrote.transpose().map_err(AppError::internal)?; let subjects = [subject.to_string()]; let mut counts = counted(app, &repo, &subjects, Some(&did)); Ok(answer(ReactionState { subject: subject.to_string(), reactions: counts.remove(subject).unwrap_or_default(), })) } /// Puts what `reacted` wrote in `index`, and takes out what it deleted: /// as written in the space `space`, if it is one's. fn indexed( index: &Index, space: Option<&str>, did: &str, reacted: &social::Reacted, ) -> anyhow::Result<()> { for rkey in &reacted.deleted { index.delete_in(space, did, nsid::REACTION, rkey)?; } if let Some((rkey, record)) = &reacted.mark { let value = knotless::records::typed(nsid::REACTION, record); index.put_in(space, did, nsid::REACTION, rkey, &value)?; } Ok(()) } #[derive(Deserialize)] pub struct ReactionsParams { repo: String, uri: String, } /// `dev.knotless.getReactions`'s answer. #[derive(Serialize)] pub struct Reactions { subjects: Vec, } /// `dev.knotless.getReactions`: the reactions to a pull or an issue, and /// to each comment on it that the repository's pages show. pub async fn get_reactions( State(app): State>, Extension(viewer): Extension, Query(p): Query, ) -> Result { let Ok((did, collection, rkey)) = forge::parse_uri(&p.uri) else { return Err(invalid(format!("{} is no pull or issue", p.uri))); }; let kind = collection.trim_start_matches("dev.knotless."); let repo = found(&app, &p.repo).await?; let (thread, ..) = thread(&app, &repo, kind, did, rkey).await?; let comments = thread.comments.iter().map(|c| c.uri.clone()); let subjects: Vec = std::iter::once(thread.item.uri).chain(comments).collect(); let viewer = viewer.0.as_deref(); let mut counts = counted(&app, &repo, &subjects, viewer); let subjects = subjects.into_iter().filter_map(|subject| { let reactions = counts.remove(&subject).filter(|r| !r.is_empty())?; Some(ReactionState { subject, reactions }) }); Ok(answer(Reactions { subjects: subjects.collect(), })) }