Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +55 −2
| @@ -2114,10 +2114,63 @@ pre.code { | |||
| 2114 | 2114 | color: var(--fb-accent); | |
| 2115 | 2115 | border-color: var(--fb-accent); | |
| 2116 | 2116 | } | |
| 2117 | - | /* An inline #n issue/PR cross-reference link. */ | |
| 2118 | - | .issue-ref { | |
| 2117 | + | /* Inline #n / @user references in rendered markdown. */ | |
| 2118 | + | .issue-ref, | |
| 2119 | + | .user-ref { | |
| 2119 | 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 | 2174 | .repo-tabs a, | |
| 2122 | 2175 | .profile-tabs a, | |
| 2123 | 2176 | .subnav a { | |
crates/web/src/icons.rs +5 −0
| @@ -43,6 +43,7 @@ pub enum Icon { | |||
| 43 | 43 | GitFork, | |
| 44 | 44 | Bookmark, | |
| 45 | 45 | Users, | |
| 46 | + | Bell, | |
| 46 | 47 | Github, | |
| 47 | 48 | Mastodon, | |
| 48 | 49 | } | |
| @@ -136,6 +137,10 @@ impl Icon { | |||
| 136 | 137 | Icon::Users => { | |
| 137 | 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 | 144 | Icon::Github => { | |
| 140 | 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 | 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 | 746 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | |
| 746 | 747 | } | |
| 747 | 748 | .await; | |
| 748 | 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 | 792 | /// Edit-title form. | |
| 752 | 793 | #[derive(Debug, Deserialize)] | |
| 753 | 794 | pub struct EditTitleForm { | |
| @@ -912,6 +953,7 @@ pub async fn comment( | |||
| 912 | 953 | let body = form.body.trim(); | |
| 913 | 954 | if !body.is_empty() { | |
| 914 | 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 | 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 | 78 | (create_menu(chrome)) | |
| 79 | 79 | a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" } | |
| 80 | 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 | 84 | form class="topbar-search" method="get" action="/search" role="search" { | |
| 83 | 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 | 24 | mod lfs; | |
| 25 | 25 | mod license; | |
| 26 | 26 | mod markdown; | |
| 27 | + | mod notifications; | |
| 27 | 28 | mod pages; | |
| 28 | 29 | mod pulls; | |
| 29 | 30 | mod repo; | |
| @@ -321,6 +322,9 @@ pub fn build_router(state: AppState) -> Router { | |||
| 321 | 322 | ) | |
| 322 | 323 | .route("/repo-bookmark/{id}", get(repo::bookmark_toggle)) | |
| 323 | 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 | 328 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. | |
| 325 | 329 | .route("/issue-new/{repo_id}", post(issues::create)) | |
| 326 | 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 | 60 | let mut i = 0; | |
| 61 | 61 | let mut in_tag = false; | |
| 62 | 62 | let mut code_depth: usize = 0; | |
| 63 | + | let mut a_depth: usize = 0; // inside an existing <a> (or email autolink) | |
| 63 | 64 | while i < bytes.len() { | |
| 64 | 65 | let c = bytes[i]; | |
| 65 | 66 | if in_tag { | |
| @@ -76,14 +77,20 @@ fn linkify_refs(html: &str, base: &str) -> String { | |||
| 76 | 77 | code_depth += 1; | |
| 77 | 78 | } else if rest.starts_with("</code") || rest.starts_with("</pre") { | |
| 78 | 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 | 85 | in_tag = true; | |
| 81 | 86 | out.push(b'<'); | |
| 82 | 87 | i += 1; | |
| 83 | 88 | continue; | |
| 84 | 89 | } | |
| 85 | - | // A `#` that follows a non-alphanumeric boundary and is followed by digits. | |
| 86 | - | if code_depth == 0 && c == b'#' && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric()) { | |
| 90 | + | let plain = code_depth == 0 && a_depth == 0; | |
| 91 | + | let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); | |
| 92 | + | // `#123` → issue/PR link. | |
| 93 | + | if plain && boundary && c == b'#' { | |
| 87 | 94 | let mut j = i + 1; | |
| 88 | 95 | while j < bytes.len() && bytes[j].is_ascii_digit() { | |
| 89 | 96 | j += 1; | |
| @@ -98,12 +105,60 @@ fn linkify_refs(html: &str, base: &str) -> String { | |||
| 98 | 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 | 123 | out.push(c); | |
| 102 | 124 | i += 1; | |
| 103 | 125 | } | |
| 104 | 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 | 162 | /// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">` | |
| 108 | 163 | /// runs (and only those classes) inside code blocks. | |
| 109 | 164 | fn sanitizer() -> ammonia::Builder<'static> { | |
| @@ -228,6 +283,28 @@ mod tests { | |||
| 228 | 283 | } | |
| 229 | 284 | ||
| 230 | 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 | 308 | fn highlights_fenced_code() { | |
| 232 | 309 | let html = render("```rust\nfn main() {}\n```").into_string(); | |
| 233 | 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 | 522 | .store | |
| 523 | 523 | .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head) | |
| 524 | 524 | .await?; | |
| 525 | + | crate::issues::notify_mentions(&state, &user.id, &repo, &issue.id, form.body.trim()).await; | |
| 525 | 526 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | |
| 526 | 527 | } | |
| 527 | 528 | .await; | |