// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! The notifications inbox: list, open (mark read + go to the target), and //! mark-all-read. use axum::Form; use axum::extract::{Path, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use maud::html; use serde::Deserialize; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::page; use crate::pages::build_chrome; use crate::session::{RequireUser, verify_csrf}; use crate::{AppState, now_ms}; /// A resolved notification ready to render. struct Row { id: String, actor: String, unread: bool, created_at: i64, /// The `owner/repo#n title` label of the target. label: String, kind: String, } /// `GET /notifications` — the viewer's inbox. pub async fn list( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, uri: Uri, ) -> AppResult { let raw = state.store.list_notifications(&user.id, 100).await?; let mut rows = Vec::with_capacity(raw.len()); for n in &raw { let actor = match &n.actor_id { Some(id) => state .store .user_by_id(id) .await .ok() .flatten() .map_or_else(|| "someone".to_string(), |u| u.username), None => "someone".to_string(), }; let (_, label) = resolve_target(&state, n.issue_id.as_deref()).await; rows.push(Row { id: n.id.clone(), actor, unread: n.read_at.is_none(), created_at: n.created_at, label, kind: n.kind.clone(), }); } let unread = rows.iter().filter(|r| r.unread).count(); let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); let now = now_ms(); let body = html! { div class="notif-page" { div class="notif-head" { h1 { "Notifications" } @if unread > 0 { form method="post" action="/notifications/read-all" { input type="hidden" name="_csrf" value=(chrome.csrf); button class="btn" type="submit" { "Mark all read" } } } } @if rows.is_empty() { div class="card" { p class="muted" { "You have no notifications." } } } @else { ul class="notif-list card" { @for r in &rows { li class=(if r.unread { "notif-row unread" } else { "notif-row" }) { span class="notif-icon" { (icon(notif_icon(&r.kind))) } a class="notif-body" href=(format!("/notifications/{}", r.id)) { span class="notif-text" { strong { "@" (r.actor) } " " (verb(&r.kind)) " " @if r.label.is_empty() { "a conversation" } @else { (r.label) } } span class="notif-time muted" { (crate::activity::fmt_relative(r.created_at, now)) } } } } } } } }; Ok((jar, page(&chrome, "Notifications", body)).into_response()) } /// `GET /notifications/{id}` — mark one read and go to its target. pub async fn open( State(state): State, RequireUser(user): RequireUser, Path(id): Path, ) -> AppResult { let notif = state .store .list_notifications(&user.id, 500) .await? .into_iter() .find(|n| n.id == id); let Some(notif) = notif else { return Err(AppError::NotFound); }; let _ = state.store.mark_notification_read(&id, &user.id).await; let (href, _) = resolve_target(&state, notif.issue_id.as_deref()).await; Ok(Redirect::to(&href.unwrap_or_else(|| "/notifications".to_string())).into_response()) } /// The CSRF-only mark-all form. #[derive(Debug, Deserialize)] pub struct BareForm { #[serde(rename = "_csrf")] csrf: String, } /// `POST /notifications/read-all` — mark every notification read. pub async fn read_all( State(state): State, RequireUser(user): RequireUser, jar: CookieJar, headers: HeaderMap, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let _ = state.store.mark_all_notifications_read(&user.id).await; Redirect::to("/notifications").into_response() } /// Resolve a notification's issue to its `(url, "owner/repo#n title")` label. async fn resolve_target(state: &AppState, issue_id: Option<&str>) -> (Option, String) { let Some(issue_id) = issue_id else { return (None, String::new()); }; let Ok(Some(issue)) = state.store.issue_by_id(issue_id).await else { return (None, String::new()); }; let Ok(Some(repo)) = state.store.repo_by_id(&issue.repo_id).await else { return (None, String::new()); }; let owner = state .store .user_by_id(&repo.owner_id) .await .ok() .flatten() .map_or_else(|| repo.owner_id.clone(), |u| u.username); let seg = if issue.is_pull { "pulls" } else { "issues" }; let href = format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number); let label = format!("{owner}/{}#{}: {}", repo.path, issue.number, issue.title); (Some(href), label) } /// The verb shown for a notification kind. fn verb(kind: &str) -> &'static str { match kind { "assign" => "assigned you to", _ => "mentioned you in", } } /// The icon for a notification kind. fn notif_icon(kind: &str) -> Icon { match kind { "assign" => Icon::User, _ => Icon::Issue, } }