~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
191 lines · 6.5 KB Raw History
//! 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(),
    }))
}