| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | use axum::Form; |
| 9 | use axum::extract::{Path, State}; |
| 10 | use axum::http::{HeaderMap, Uri}; |
| 11 | use axum::response::{IntoResponse, Redirect, Response}; |
| 12 | use axum_extra::extract::cookie::CookieJar; |
| 13 | use maud::html; |
| 14 | use serde::Deserialize; |
| 15 | |
| 16 | use crate::error::{AppError, AppResult}; |
| 17 | use crate::icons::{Icon, icon}; |
| 18 | use crate::layout::page; |
| 19 | use crate::pages::build_chrome; |
| 20 | use crate::session::{RequireUser, verify_csrf}; |
| 21 | use crate::{AppState, now_ms}; |
| 22 | |
| 23 | |
| 24 | struct Row { |
| 25 | id: String, |
| 26 | actor: String, |
| 27 | unread: bool, |
| 28 | created_at: i64, |
| 29 | |
| 30 | label: String, |
| 31 | kind: String, |
| 32 | } |
| 33 | |
| 34 | |
| 35 | pub 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 | |
| 103 | pub 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 | |
| 123 | #[derive(Debug, Deserialize)] |
| 124 | pub struct BareForm { |
| 125 | #[serde(rename = "_csrf")] |
| 126 | csrf: String, |
| 127 | } |
| 128 | |
| 129 | |
| 130 | pub 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 | |
| 145 | async 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 | |
| 169 | fn verb(kind: &str) -> &'static str { |
| 170 | match kind { |
| 171 | "assign" => "assigned you to", |
| 172 | _ => "mentioned you in", |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | |
| 177 | fn notif_icon(kind: &str) -> Icon { |
| 178 | match kind { |
| 179 | "assign" => Icon::User, |
| 180 | _ => Icon::Issue, |
| 181 | } |
| 182 | } |