johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String>,
}
/// `dev.knotless.defs#reactionState`.
#[derive(Serialize)]
pub struct ReactionState {
subject: String,
reactions: Vec<ReactionCountView>,
}
/// 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<String, Vec<ReactionCountView>> {
let index = super::indexed(app, repo);
let rows = index.and_then(|index| index.reactions(subjects, viewer).ok());
let mut by: HashMap<String, Vec<(usize, ReactionCountView)>> = 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<ReactionCountView>) {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<ReactionInput>,
) -> Result<Response> {
reacting(&app, viewer, &headers, input, true).await
}
/// `dev.knotless.removeReaction`.
pub async fn remove_reaction(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<ReactionInput>,
) -> Result<Response> {
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<Response> {
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<ReactionState>,
}
/// `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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
Query(p): Query<ReactionsParams>,
) -> Result<Response> {
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<String> = 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(),
}))
}