fabrica

hanna/fabrica

6223 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The notifications inbox: list, open (mark read + go to the target), and
6//! mark-all-read.
7
8use axum::Form;
9use axum::extract::{Path, State};
10use axum::http::{HeaderMap, Uri};
11use axum::response::{IntoResponse, Redirect, Response};
12use axum_extra::extract::cookie::CookieJar;
13use maud::html;
14use serde::Deserialize;
15
16use crate::error::{AppError, AppResult};
17use crate::icons::{Icon, icon};
18use crate::layout::page;
19use crate::pages::build_chrome;
20use crate::session::{RequireUser, verify_csrf};
21use crate::{AppState, now_ms};
22
23/// A resolved notification ready to render.
24struct Row {
25 id: String,
26 actor: String,
27 unread: bool,
28 created_at: i64,
29 /// The `owner/repo#n title` label of the target.
30 label: String,
31 kind: String,
32}
33
34/// `GET /notifications` — the viewer's inbox.
35pub async fn list(
36 State(state): State<AppState>,
37 RequireUser(user): RequireUser,
38 jar: CookieJar,
39 uri: Uri,
40) -> AppResult<Response> {
41 let raw = state.store.list_notifications(&user.id, 100).await?;
42 let mut rows = Vec::with_capacity(raw.len());
43 for n in &raw {
44 let actor = match &n.actor_id {
45 Some(id) => state
46 .store
47 .user_by_id(id)
48 .await
49 .ok()
50 .flatten()
51 .map_or_else(|| "someone".to_string(), |u| u.username),
52 None => "someone".to_string(),
53 };
54 let (_, label) = resolve_target(&state, n.issue_id.as_deref()).await;
55 rows.push(Row {
56 id: n.id.clone(),
57 actor,
58 unread: n.read_at.is_none(),
59 created_at: n.created_at,
60 label,
61 kind: n.kind.clone(),
62 });
63 }
64 let unread = rows.iter().filter(|r| r.unread).count();
65
66 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
67 let now = now_ms();
68 let body = html! {
69 div class="notif-page" {
70 div class="notif-head" {
71 h1 { "Notifications" }
72 @if unread > 0 {
73 form method="post" action="/notifications/read-all" {
74 input type="hidden" name="_csrf" value=(chrome.csrf);
75 button class="btn" type="submit" { "Mark all read" }
76 }
77 }
78 }
79 @if rows.is_empty() {
80 div class="card" { p class="muted" { "You have no notifications." } }
81 } @else {
82 ul class="notif-list card" {
83 @for r in &rows {
84 li class=(if r.unread { "notif-row unread" } else { "notif-row" }) {
85 span class="notif-icon" { (icon(notif_icon(&r.kind))) }
86 a class="notif-body" href=(format!("/notifications/{}", r.id)) {
87 span class="notif-text" {
88 strong { "@" (r.actor) } " " (verb(&r.kind)) " "
89 @if r.label.is_empty() { "a conversation" } @else { (r.label) }
90 }
91 span class="notif-time muted" { (crate::activity::fmt_relative(r.created_at, now)) }
92 }
93 }
94 }
95 }
96 }
97 }
98 };
99 Ok((jar, page(&chrome, "Notifications", body)).into_response())
100}
101
102/// `GET /notifications/{id}` — mark one read and go to its target.
103pub async fn open(
104 State(state): State<AppState>,
105 RequireUser(user): RequireUser,
106 Path(id): Path<String>,
107) -> AppResult<Response> {
108 let notif = state
109 .store
110 .list_notifications(&user.id, 500)
111 .await?
112 .into_iter()
113 .find(|n| n.id == id);
114 let Some(notif) = notif else {
115 return Err(AppError::NotFound);
116 };
117 let _ = state.store.mark_notification_read(&id, &user.id).await;
118 let (href, _) = resolve_target(&state, notif.issue_id.as_deref()).await;
119 Ok(Redirect::to(&href.unwrap_or_else(|| "/notifications".to_string())).into_response())
120}
121
122/// The CSRF-only mark-all form.
123#[derive(Debug, Deserialize)]
124pub struct BareForm {
125 #[serde(rename = "_csrf")]
126 csrf: String,
127}
128
129/// `POST /notifications/read-all` — mark every notification read.
130pub async fn read_all(
131 State(state): State<AppState>,
132 RequireUser(user): RequireUser,
133 jar: CookieJar,
134 headers: HeaderMap,
135 Form(form): Form<BareForm>,
136) -> Response {
137 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
138 return AppError::Forbidden.into_response();
139 }
140 let _ = state.store.mark_all_notifications_read(&user.id).await;
141 Redirect::to("/notifications").into_response()
142}
143
144/// Resolve a notification's issue to its `(url, "owner/repo#n title")` label.
145async fn resolve_target(state: &AppState, issue_id: Option<&str>) -> (Option<String>, String) {
146 let Some(issue_id) = issue_id else {
147 return (None, String::new());
148 };
149 let Ok(Some(issue)) = state.store.issue_by_id(issue_id).await else {
150 return (None, String::new());
151 };
152 let Ok(Some(repo)) = state.store.repo_by_id(&issue.repo_id).await else {
153 return (None, String::new());
154 };
155 let owner = state
156 .store
157 .user_by_id(&repo.owner_id)
158 .await
159 .ok()
160 .flatten()
161 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
162 let seg = if issue.is_pull { "pulls" } else { "issues" };
163 let href = format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number);
164 let label = format!("{owner}/{}#{}: {}", repo.path, issue.number, issue.title);
165 (Some(href), label)
166}
167
168/// The verb shown for a notification kind.
169fn verb(kind: &str) -> &'static str {
170 match kind {
171 "assign" => "assigned you to",
172 _ => "mentioned you in",
173 }
174}
175
176/// The icon for a notification kind.
177fn notif_icon(kind: &str) -> Icon {
178 match kind {
179 "assign" => Icon::User,
180 _ => Icon::Issue,
181 }
182}