//! What an account chose to hear of a repository, a pull or an issue: //! `dev.knotless.putSubscription` and `listSubscriptions`, for the account //! a service token or the pages' session cookie names. The appview keeps //! each choice for that account alone, as it keeps its preferences: it is //! how one reader wants its notifications read, nothing anyone else needs, //! and a record would publish it. [`Index::notifications`] reads them. use super::notifications::asking; use super::repo::{found, invalid}; use super::*; use crate::index::{PARTICIPATING, Subscription}; use crate::viewer::Viewer; use axum::Extension; /// The most choices the appview keeps for one account. pub const MAX: usize = 1000; /// What a choice past [`MAX`] is answered. const FULL: &str = "1000 choices at most: forget one first, as participating"; /// The levels a repository takes, past [`PARTICIPATING`]. const REPO_LEVELS: [&str; 3] = ["all", "releases", "ignore"]; /// The levels a pull or an issue takes, past [`PARTICIPATING`]. const THREAD_LEVELS: [&str; 2] = ["subscribed", "muted"]; /// The procedure and the query. pub fn routes() -> Router> { Router::new() .route("/xrpc/dev.knotless.putSubscription", post(put_subscription)) .route( "/xrpc/dev.knotless.listSubscriptions", get(list_subscriptions), ) } #[derive(Deserialize)] pub struct PutInput { subject: String, level: String, } /// `dev.knotless.listSubscriptions#subscription`. #[derive(Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SubscriptionView { subject: String, level: String, #[serde(skip_serializing_if = "Option::is_none")] created_at: Option, } /// What the index said, as the pages' error if it failed. fn indexed(done: rusqlite::Result) -> Result { let done = done.map_err(anyhow::Error::from); Ok(done.map_err(AppError::internal)?) } /// `dev.knotless.putSubscription`. With the pages' cookie, only from the /// site itself. pub async fn put_subscription( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let token = headers.contains_key(header::AUTHORIZATION); if !token && !crate::login::same_site(&headers) { return Err(Shown(StatusCode::FORBIDDEN, "not from this site".into())); } let (did, index) = asking(&app, viewer)?; let (subject, levels) = match forge::parse_uri(&input.subject) { Ok((_, nsid::REPO, _)) => { let repo = found(&app, &input.subject).await?; (Subscription::repo(&repo.did, &repo.key), &REPO_LEVELS[..]) } Ok((author, collection @ (nsid::PULL | nsid::ISSUE), rkey)) => ( format!("at://{author}/{collection}/{rkey}"), &THREAD_LEVELS[..], ), _ => { let why = format!("{} is no repository, pull or issue", input.subject); return Err(invalid(why)); } }; let level = input.level.as_str(); if level != PARTICIPATING && !levels.contains(&level) { let why = format!( "{level:?} is no level for it: {PARTICIPATING}, {}", levels.join(", ") ); return Err(invalid(why)); } let kept = indexed(index.subscribe(&did, &subject, level, &knotless::records::now(), MAX))?; kept.then_some(()).ok_or(invalid(FULL.into()))?; let kept = indexed(index.subscriptions(&did))?; let now = kept.into_iter().find(|s| s.subject == subject); Ok(answer(SubscriptionView { subject: input.subject, level: now .as_ref() .map_or(PARTICIPATING.into(), |s| s.level.clone()), created_at: now.map(|s| s.at), })) } #[derive(Deserialize)] pub struct ListParams { limit: Option, cursor: Option, } /// `dev.knotless.listSubscriptions`. pub async fn list_subscriptions( State(app): State>, Extension(viewer): Extension, Query(p): Query, ) -> Result { let (did, index) = asking(&app, viewer)?; let limit = p.limit.unwrap_or(50).clamp(1, 100); // An empty cursor is none, as a client paging from the start sends. let given = p.cursor.as_deref().filter(|c| !c.is_empty()); let skip = match given.map(str::parse::) { None => 0, Some(Ok(skip)) => skip, Some(Err(_)) => return Err(invalid("cursor is no count".into())), }; let all = indexed(index.subscriptions(&did))?; let more = all.len() > skip + limit; let mut subscriptions = Vec::new(); for s in all.into_iter().skip(skip).take(limit) { subscriptions.extend(shown(&app, &index, s)); } let cursor = more.then(|| (skip + limit).to_string()); Ok(answer(ListOutput { subscriptions, cursor, })) } #[derive(Serialize)] pub struct ListOutput { subscriptions: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } /// `s` as `listSubscriptions` gives it: a repository by the name it has /// now, in the index or this server's index of its space; none for one /// neither has. fn shown(app: &App, index: &Index, s: Subscription) -> Option { let subject = match s.repo_of() { Some((did, key)) => { let indexes = std::iter::once(index).chain(app.spaces.as_deref()); let name = indexes .filter_map(|i| i.name(did, key).ok().flatten()) .next()?; forge::repo_uri(did, &name) } None => s.subject.clone(), }; Some(SubscriptionView { subject, level: s.level, created_at: Some(s.at), }) }