Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +55 −2
| @@ -2114,10 +2114,63 @@ pre.code { | |||
| 2114 | color: var(--fb-accent); | 2114 | color: var(--fb-accent); |
| 2115 | border-color: var(--fb-accent); | 2115 | border-color: var(--fb-accent); |
| 2116 | } | 2116 | } |
| 2117 | /* An inline #n issue/PR cross-reference link. */ | 2117 | /* Inline #n / @user references in rendered markdown. */ |
| 2118 | .issue-ref { | 2118 | .issue-ref, |
| 2119 | .user-ref { | ||
| 2119 | font-weight: 600; | 2120 | font-weight: 600; |
| 2120 | } | 2121 | } |
| 2122 | /* Notifications inbox. */ | ||
| 2123 | .notif-head { | ||
| 2124 | display: flex; | ||
| 2125 | align-items: center; | ||
| 2126 | justify-content: space-between; | ||
| 2127 | gap: 1rem; | ||
| 2128 | margin-bottom: 1rem; | ||
| 2129 | } | ||
| 2130 | .notif-list { | ||
| 2131 | list-style: none; | ||
| 2132 | margin: 0; | ||
| 2133 | padding: 0; | ||
| 2134 | } | ||
| 2135 | .notif-row { | ||
| 2136 | display: flex; | ||
| 2137 | align-items: center; | ||
| 2138 | gap: 0.75rem; | ||
| 2139 | padding: 0.7rem 1rem; | ||
| 2140 | border-bottom: 1px solid var(--fb-border); | ||
| 2141 | } | ||
| 2142 | .notif-row:last-child { | ||
| 2143 | border-bottom: none; | ||
| 2144 | } | ||
| 2145 | .notif-row.unread { | ||
| 2146 | background: var(--fb-bg-inset); | ||
| 2147 | } | ||
| 2148 | .notif-icon { | ||
| 2149 | flex: none; | ||
| 2150 | color: var(--fb-fg-muted); | ||
| 2151 | } | ||
| 2152 | .notif-row.unread .notif-icon { | ||
| 2153 | color: var(--fb-accent); | ||
| 2154 | } | ||
| 2155 | .notif-body { | ||
| 2156 | display: flex; | ||
| 2157 | align-items: center; | ||
| 2158 | justify-content: space-between; | ||
| 2159 | gap: 1rem; | ||
| 2160 | flex: 1; | ||
| 2161 | min-width: 0; | ||
| 2162 | color: var(--fb-fg); | ||
| 2163 | } | ||
| 2164 | .notif-text { | ||
| 2165 | min-width: 0; | ||
| 2166 | overflow: hidden; | ||
| 2167 | text-overflow: ellipsis; | ||
| 2168 | white-space: nowrap; | ||
| 2169 | } | ||
| 2170 | .notif-time { | ||
| 2171 | flex: none; | ||
| 2172 | font-size: 0.85em; | ||
| 2173 | } | ||
| 2121 | .repo-tabs a, | 2174 | .repo-tabs a, |
| 2122 | .profile-tabs a, | 2175 | .profile-tabs a, |
| 2123 | .subnav a { | 2176 | .subnav a { |
crates/web/src/icons.rs +5 −0
| @@ -43,6 +43,7 @@ pub enum Icon { | |||
| 43 | GitFork, | 43 | GitFork, |
| 44 | Bookmark, | 44 | Bookmark, |
| 45 | Users, | 45 | Users, |
| 46 | Bell, | ||
| 46 | Github, | 47 | Github, |
| 47 | Mastodon, | 48 | Mastodon, |
| 48 | } | 49 | } |
| @@ -136,6 +137,10 @@ impl Icon { | |||
| 136 | Icon::Users => { | 137 | Icon::Users => { |
| 137 | r#"<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>"# | 138 | r#"<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>"# |
| 138 | } | 139 | } |
| 140 | // Lucide "bell": notifications. | ||
| 141 | Icon::Bell => { | ||
| 142 | r#"<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>"# | ||
| 143 | } | ||
| 139 | Icon::Github => { | 144 | Icon::Github => { |
| 140 | r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"# | 145 | r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"# |
| 141 | } | 146 | } |
crates/web/src/issues.rs +42 −0
| @@ -742,12 +742,53 @@ pub async fn create( | |||
| 742 | state.store.add_issue_label(&issue.id, id).await?; | 742 | state.store.add_issue_label(&issue.id, id).await?; |
| 743 | } | 743 | } |
| 744 | } | 744 | } |
| 745 | notify_mentions(&state, &user.id, &repo, &issue.id, desc.trim()).await; | ||
| 745 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | 746 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| 746 | } | 747 | } |
| 747 | .await; | 748 | .await; |
| 748 | respond_redirect(result) | 749 | respond_redirect(result) |
| 749 | } | 750 | } |
| 750 | 751 | ||
| 752 | /// Create a "mention" notification for every `@user` in `body` who can read the | ||
| 753 | /// repo. Best-effort: failures are ignored. | ||
| 754 | pub(crate) async fn notify_mentions( | ||
| 755 | state: &AppState, | ||
| 756 | actor_id: &str, | ||
| 757 | repo: &model::Repo, | ||
| 758 | issue_id: &str, | ||
| 759 | body: &str, | ||
| 760 | ) { | ||
| 761 | for name in markdown::mentions(body) { | ||
| 762 | let Ok(Some(user)) = state.store.user_by_username(&name).await else { | ||
| 763 | continue; | ||
| 764 | }; | ||
| 765 | let collab = state | ||
| 766 | .store | ||
| 767 | .collaborator_permission(&repo.id, &user.id) | ||
| 768 | .await | ||
| 769 | .ok() | ||
| 770 | .flatten() | ||
| 771 | .and_then(|p| auth::Permission::parse(&p)); | ||
| 772 | let viewer = auth::Viewer { | ||
| 773 | id: user.id.clone(), | ||
| 774 | is_admin: user.is_admin, | ||
| 775 | }; | ||
| 776 | let access = auth::access(Some(&viewer), repo, collab, state.allow_anonymous()); | ||
| 777 | if access >= auth::Access::Read { | ||
| 778 | let _ = state | ||
| 779 | .store | ||
| 780 | .create_notification(store::NewNotification { | ||
| 781 | user_id: user.id, | ||
| 782 | actor_id: actor_id.to_string(), | ||
| 783 | kind: "mention".to_string(), | ||
| 784 | repo_id: Some(repo.id.clone()), | ||
| 785 | issue_id: Some(issue_id.to_string()), | ||
| 786 | }) | ||
| 787 | .await; | ||
| 788 | } | ||
| 789 | } | ||
| 790 | } | ||
| 791 | |||
| 751 | /// Edit-title form. | 792 | /// Edit-title form. |
| 752 | #[derive(Debug, Deserialize)] | 793 | #[derive(Debug, Deserialize)] |
| 753 | pub struct EditTitleForm { | 794 | pub struct EditTitleForm { |
| @@ -912,6 +953,7 @@ pub async fn comment( | |||
| 912 | let body = form.body.trim(); | 953 | let body = form.body.trim(); |
| 913 | if !body.is_empty() { | 954 | if !body.is_empty() { |
| 914 | state.store.add_comment(&issue.id, &user.id, body).await?; | 955 | state.store.add_comment(&issue.id, &user.id, body).await?; |
| 956 | notify_mentions(&state, &user.id, &repo, &issue.id, body).await; | ||
| 915 | } | 957 | } |
| 916 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | 958 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| 917 | } | 959 | } |
crates/web/src/layout.rs +2 −0
| @@ -78,6 +78,8 @@ fn topbar(chrome: &Chrome) -> Markup { | |||
| 78 | (create_menu(chrome)) | 78 | (create_menu(chrome)) |
| 79 | a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" } | 79 | a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" } |
| 80 | aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) } | 80 | aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) } |
| 81 | a class="icon-btn" href="/notifications" | ||
| 82 | aria-label="Notifications" title="Notifications" { (icon(Icon::Bell)) } | ||
| 81 | } | 83 | } |
| 82 | form class="topbar-search" method="get" action="/search" role="search" { | 84 | form class="topbar-search" method="get" action="/search" role="search" { |
| 83 | input type="search" name="q" placeholder="Search…" aria-label="Search"; | 85 | input type="search" name="q" placeholder="Search…" aria-label="Search"; |
crates/web/src/lib.rs +4 −0
| @@ -24,6 +24,7 @@ mod layout; | |||
| 24 | mod lfs; | 24 | mod lfs; |
| 25 | mod license; | 25 | mod license; |
| 26 | mod markdown; | 26 | mod markdown; |
| 27 | mod notifications; | ||
| 27 | mod pages; | 28 | mod pages; |
| 28 | mod pulls; | 29 | mod pulls; |
| 29 | mod repo; | 30 | mod repo; |
| @@ -321,6 +322,9 @@ pub fn build_router(state: AppState) -> Router { | |||
| 321 | ) | 322 | ) |
| 322 | .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) | 323 | .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) |
| 323 | .route("/user-follow/{username}", get(repo::follow_toggle)) | 324 | .route("/user-follow/{username}", get(repo::follow_toggle)) |
| 325 | .route("/notifications", get(notifications::list)) | ||
| 326 | .route("/notifications/read-all", post(notifications::read_all)) | ||
| 327 | .route("/notifications/{id}", get(notifications::open)) | ||
| 324 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. | 328 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. |
| 325 | .route("/issue-new/{repo_id}", post(issues::create)) | 329 | .route("/issue-new/{repo_id}", post(issues::create)) |
| 326 | .route("/issue/{id}/comment", post(issues::comment)) | 330 | .route("/issue/{id}/comment", post(issues::comment)) |
crates/web/src/markdown.rs +79 −2
| @@ -60,6 +60,7 @@ fn linkify_refs(html: &str, base: &str) -> String { | |||
| 60 | let mut i = 0; | 60 | let mut i = 0; |
| 61 | let mut in_tag = false; | 61 | let mut in_tag = false; |
| 62 | let mut code_depth: usize = 0; | 62 | let mut code_depth: usize = 0; |
| 63 | let mut a_depth: usize = 0; // inside an existing <a> (or email autolink) | ||
| 63 | while i < bytes.len() { | 64 | while i < bytes.len() { |
| 64 | let c = bytes[i]; | 65 | let c = bytes[i]; |
| 65 | if in_tag { | 66 | if in_tag { |
| @@ -76,14 +77,20 @@ fn linkify_refs(html: &str, base: &str) -> String { | |||
| 76 | code_depth += 1; | 77 | code_depth += 1; |
| 77 | } else if rest.starts_with("</code") || rest.starts_with("</pre") { | 78 | } else if rest.starts_with("</code") || rest.starts_with("</pre") { |
| 78 | code_depth = code_depth.saturating_sub(1); | 79 | code_depth = code_depth.saturating_sub(1); |
| 80 | } else if rest.starts_with("<a") && !rest.starts_with("<abbr") { | ||
| 81 | a_depth += 1; | ||
| 82 | } else if rest.starts_with("</a") { | ||
| 83 | a_depth = a_depth.saturating_sub(1); | ||
| 79 | } | 84 | } |
| 80 | in_tag = true; | 85 | in_tag = true; |
| 81 | out.push(b'<'); | 86 | out.push(b'<'); |
| 82 | i += 1; | 87 | i += 1; |
| 83 | continue; | 88 | continue; |
| 84 | } | 89 | } |
| 85 | // A `#` that follows a non-alphanumeric boundary and is followed by digits. | 90 | let plain = code_depth == 0 && a_depth == 0; |
| 86 | if code_depth == 0 && c == b'#' && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric()) { | 91 | let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); |
| 92 | // `#123` → issue/PR link. | ||
| 93 | if plain && boundary && c == b'#' { | ||
| 87 | let mut j = i + 1; | 94 | let mut j = i + 1; |
| 88 | while j < bytes.len() && bytes[j].is_ascii_digit() { | 95 | while j < bytes.len() && bytes[j].is_ascii_digit() { |
| 89 | j += 1; | 96 | j += 1; |
| @@ -98,12 +105,60 @@ fn linkify_refs(html: &str, base: &str) -> String { | |||
| 98 | continue; | 105 | continue; |
| 99 | } | 106 | } |
| 100 | } | 107 | } |
| 108 | // `@name` → user profile link (boundary avoids matching email local parts). | ||
| 109 | if plain && boundary && c == b'@' { | ||
| 110 | let mut j = i + 1; | ||
| 111 | while j < bytes.len() && is_username_byte(bytes[j]) { | ||
| 112 | j += 1; | ||
| 113 | } | ||
| 114 | if j > i + 1 { | ||
| 115 | let name = &html[i + 1..j]; | ||
| 116 | out.extend_from_slice( | ||
| 117 | format!("<a class=\"user-ref\" href=\"/{name}\">@{name}</a>").as_bytes(), | ||
| 118 | ); | ||
| 119 | i = j; | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | } | ||
| 101 | out.push(c); | 123 | out.push(c); |
| 102 | i += 1; | 124 | i += 1; |
| 103 | } | 125 | } |
| 104 | String::from_utf8(out).unwrap_or_else(|_| html.to_string()) | 126 | String::from_utf8(out).unwrap_or_else(|_| html.to_string()) |
| 105 | } | 127 | } |
| 106 | 128 | ||
| 129 | /// Bytes allowed in a username (matches `model::validate_name`'s character set). | ||
| 130 | fn is_username_byte(b: u8) -> bool { | ||
| 131 | b.is_ascii_alphanumeric() || b == b'-' || b == b'_' | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Extract `@username` mentions from raw markdown text, deduplicated. Used to | ||
| 135 | /// create mention notifications (over-matching inside code is acceptable). | ||
| 136 | #[must_use] | ||
| 137 | pub fn mentions(text: &str) -> Vec<String> { | ||
| 138 | let bytes = text.as_bytes(); | ||
| 139 | let mut out: Vec<String> = Vec::new(); | ||
| 140 | let mut i = 0; | ||
| 141 | while i < bytes.len() { | ||
| 142 | let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); | ||
| 143 | if boundary && bytes[i] == b'@' { | ||
| 144 | let mut j = i + 1; | ||
| 145 | while j < bytes.len() && is_username_byte(bytes[j]) { | ||
| 146 | j += 1; | ||
| 147 | } | ||
| 148 | if j > i + 1 { | ||
| 149 | let name = text[i + 1..j].to_string(); | ||
| 150 | if !out.contains(&name) { | ||
| 151 | out.push(name); | ||
| 152 | } | ||
| 153 | i = j; | ||
| 154 | continue; | ||
| 155 | } | ||
| 156 | } | ||
| 157 | i += 1; | ||
| 158 | } | ||
| 159 | out | ||
| 160 | } | ||
| 161 | |||
| 107 | /// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">` | 162 | /// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">` |
| 108 | /// runs (and only those classes) inside code blocks. | 163 | /// runs (and only those classes) inside code blocks. |
| 109 | fn sanitizer() -> ammonia::Builder<'static> { | 164 | fn sanitizer() -> ammonia::Builder<'static> { |
| @@ -228,6 +283,28 @@ mod tests { | |||
| 228 | } | 283 | } |
| 229 | 284 | ||
| 230 | #[test] | 285 | #[test] |
| 286 | fn linkifies_mentions_but_not_emails() { | ||
| 287 | let html = render_with_refs("hi @alice, mail me@example.com", "/o/r").into_string(); | ||
| 288 | assert!( | ||
| 289 | html.contains(r#"<a class="user-ref" href="/alice">@alice</a>"#), | ||
| 290 | "@alice links: {html}" | ||
| 291 | ); | ||
| 292 | // The email's `@example` is not a mention (local part is alphanumeric). | ||
| 293 | assert!( | ||
| 294 | !html.contains(r#"href="/example""#), | ||
| 295 | "email untouched: {html}" | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | |||
| 299 | #[test] | ||
| 300 | fn extracts_mentions() { | ||
| 301 | assert_eq!(mentions("hey @bob and @carol-x!"), vec!["bob", "carol-x"]); | ||
| 302 | assert_eq!(mentions("foo@bar.com only"), Vec::<String>::new()); | ||
| 303 | // Deduplicated. | ||
| 304 | assert_eq!(mentions("@a @a @b"), vec!["a", "b"]); | ||
| 305 | } | ||
| 306 | |||
| 307 | #[test] | ||
| 231 | fn highlights_fenced_code() { | 308 | fn highlights_fenced_code() { |
| 232 | let html = render("```rust\nfn main() {}\n```").into_string(); | 309 | let html = render("```rust\nfn main() {}\n```").into_string(); |
| 233 | // A keyword span survives sanitizing with its highlight class. | 310 | // A keyword span survives sanitizing with its highlight class. |
crates/web/src/notifications.rs +182 −0
| @@ -0,0 +1,182 @@ | |||
| 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 | |||
| 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 | /// A resolved notification ready to render. | ||
| 24 | struct 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. | ||
| 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 | /// `GET /notifications/{id}` — mark one read and go to its target. | ||
| 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 | /// The CSRF-only mark-all form. | ||
| 123 | #[derive(Debug, Deserialize)] | ||
| 124 | pub struct BareForm { | ||
| 125 | #[serde(rename = "_csrf")] | ||
| 126 | csrf: String, | ||
| 127 | } | ||
| 128 | |||
| 129 | /// `POST /notifications/read-all` — mark every notification read. | ||
| 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 | /// Resolve a notification's issue to its `(url, "owner/repo#n title")` label. | ||
| 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 | /// The verb shown for a notification kind. | ||
| 169 | fn 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. | ||
| 177 | fn notif_icon(kind: &str) -> Icon { | ||
| 178 | match kind { | ||
| 179 | "assign" => Icon::User, | ||
| 180 | _ => Icon::Issue, | ||
| 181 | } | ||
| 182 | } | ||
crates/web/src/pulls.rs +1 −0
| @@ -522,6 +522,7 @@ pub async fn create( | |||
| 522 | .store | 522 | .store |
| 523 | .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head) | 523 | .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head) |
| 524 | .await?; | 524 | .await?; |
| 525 | crate::issues::notify_mentions(&state, &user.id, &repo, &issue.id, form.body.trim()).await; | ||
| 525 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | 526 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| 526 | } | 527 | } |
| 527 | .await; | 528 | .await; |