//! The inbox, notification preferences and search (hub spec §10, §11). use super::settings::{csrf, require}; use super::{filters, Error, Page, Shell}; use crate::auth::Viewer; use crate::db::{self, accounts, notify as ndb}; use crate::Shared; use askama::Template; use axum::extract::{Form, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use hub_api::Visibility; use serde::Deserialize; #[derive(Template)] #[template(path = "notifications.html")] pub struct InboxPage { shell: Shell, threads: Vec, unread: i64, email_on: bool, notice: Option, q: InboxQuery, /// Kind families and repositories among the threads in the inbox, with counts. kinds: Vec<(String, usize)>, repos: Vec<(String, usize)>, } /// `?view=unread|done&reason=participating|watching&kind=issue&repo=owner/name`. #[derive(Clone, Debug, Default, Deserialize)] pub struct InboxQuery { #[serde(default)] pub view: String, #[serde(default)] pub reason: String, #[serde(default)] pub kind: String, #[serde(default)] pub repo: String, } impl InboxQuery { /// This query with one key changed, as a URL (empty values drop out). pub fn with(&self, key: &str, value: &str) -> String { let mut q = self.clone(); match key { "view" => q.view = value.into(), "reason" => q.reason = value.into(), "kind" => q.kind = value.into(), "repo" => q.repo = value.into(), _ => {} } let parts: Vec = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)] .iter() .filter(|(_, v)| !v.is_empty()) .map(|(k, v)| format!("{k}={}", super::settings::urlencode(v))) .collect(); if parts.is_empty() { "/notifications".into() } else { format!("/notifications?{}", parts.join("&")) } } /// This query as it is, for forms that come back to it. pub fn here(&self) -> String { self.with("", "") } } /// `issue.closed` → `issue`. fn family(kind: &str) -> &str { kind.split('.').next().unwrap_or(kind) } async fn inbox_page( app: &Shared, shell: Shell, me: &accounts::Account, q: InboxQuery, notice: Option, ) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let repo_id = match q.repo.split_once('/') { Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)), None => None, }; let filter = ndb::Filter { done: q.view == "done", unread_only: q.view == "unread", reason: (!q.reason.is_empty()).then(|| q.reason.clone()), kind: (!q.kind.is_empty()).then(|| q.kind.clone()), repo_id, }; let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?; // Facets over the whole inbox, so the choices do not vanish as you pick. let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?; let mut kinds: std::collections::BTreeMap = Default::default(); let mut repos: std::collections::BTreeMap = Default::default(); for t in &all { *kinds.entry(family(&t.kind).to_string()).or_default() += 1; if let Some(r) = &t.repo { *repos.entry(r.clone()).or_default() += 1; } } let unread = ndb::unread(&app.db, me.id).await.map_err(e)?; let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten(); Ok(Page(InboxPage { shell, threads, unread, email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), notice, q, kinds: kinds.into_iter().collect(), repos: repos.into_iter().collect(), })) } pub async fn inbox(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; inbox_page(&app, shell, &me.account, q, None).await } #[derive(Deserialize)] pub struct ReadForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] id: Option, } pub async fn mark_read(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await } #[derive(Deserialize)] pub struct ThreadForm { #[serde(rename = "_csrf")] csrf_token: String, thread: String, /// `read`, `unread`, `done` or `undone`. action: String, /// Where to go back to: the inbox with its filters. #[serde(default)] back: String, } /// `POST /notifications/thread`. pub async fn thread(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (read, done) = match f.action.as_str() { "read" => (Some(true), None), "unread" => (Some(false), None), "done" => (None, Some(true)), "undone" => (None, Some(false)), _ => return Err(Error::bad(shell, "unknown action")), }; ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?; let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() }; Ok(Redirect::to(&back).into_response()) } /// `GET /notifications/open/{id}`: mark the thread read and go to it. pub async fn open(State(app): State, viewer: Viewer, axum::extract::Path(id): axum::extract::Path) -> Result { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; let n = ndb::get(&app.db, me.account.id, id) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?; // Only local pages: a notification's URL is a path on this hub. let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() }; Ok(Redirect::to(&to).into_response()) } #[derive(Deserialize)] pub struct PrefsForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] email: Option, } pub async fn prefs(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() }; sqlx::query("UPDATE accounts SET email_notifications = $1, unsubscribe_key = $2 WHERE id = $3") .bind(f.email.is_some()) .bind(&key) .bind(me.account.id) .execute(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; inbox_page( &app, shell, &me.account, InboxQuery::default(), Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }), ) .await } #[derive(Deserialize)] pub struct UnsubQuery { unsubscribe: Option, } /// The one-click link in every email: no session needed, the key is the proof. pub async fn unsubscribe(State(app): State, Query(q): Query) -> Response { if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = $1").bind(key).execute(&app.db).await.ok(); return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); } Redirect::to("/notifications").into_response() } // ------------------------------------------------------------------ search pub struct HitView { pub kind: String, pub slug: String, pub number: i64, pub title: String, pub snippet: String, pub url: String, } #[derive(Template)] #[template(path = "search.html")] pub struct SearchPage { shell: Shell, q: String, repos: Vec, accounts: Vec, hits: Vec, } #[derive(Deserialize)] pub struct SearchQuery { q: Option, } pub async fn search(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/search", &viewer); let query = q.q.unwrap_or_default().trim().to_string(); if query.is_empty() { return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] })); } let e = |e| Error::from_db(shell.clone(), e); // What the viewer may see. let all = db::repos::list(&app.db).await.map_err(e)?; let mut visible = Vec::new(); for r in all { let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?; if crate::auth::roles::allows(role, crate::auth::Access::Read) { visible.push(r); } } let lower = query.to_lowercase(); let repos: Vec = visible .iter() .filter(|r| { r.name.to_lowercase().contains(&lower) || r.description.to_lowercase().contains(&lower) || r.owner.to_lowercase().contains(&lower) }) .cloned() .collect(); let accounts: Vec = accounts::accounts(&app.db) .await .map_err(e)? .into_iter() .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower)) .map(|a| a.username) .collect(); let mut hits = Vec::new(); for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? { let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue }; let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number); hits.push(HitView { kind: h.kind, slug: r.slug(), number: h.number, title: h.title, snippet: crate::render::escape(&h.snippet).replace("<mark>", "").replace("</mark>", ""), url, }); } let _ = Visibility::Public; Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) }