fabrica

hanna/fabrica

feat(web): notifications and @mentions

dd5ce44 · hanna committed on 2026-07-26

- @username in issue/PR descriptions and comments links to the profile
  (markdown linkifier, skipping emails/code/existing links) and creates a
  "mention" notification for each mentioned user who can read the repo
  (on issue open, comment, and PR open).
- A notifications inbox (/notifications, bell icon in the navbar): lists
  events newest-first, highlights unread, opens the target issue/PR while
  marking the item read, and a Mark-all-read action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +370 −4UnifiedSplit
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}
750751
752/// Create a "mention" notification for every `@user` in `body` who can read the
753/// repo. Best-effort: failures are ignored.
754pub(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)]
753pub struct EditTitleForm {794pub 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;
24mod lfs;24mod lfs;
25mod license;25mod license;
26mod markdown;26mod markdown;
27mod notifications;
27mod pages;28mod pages;
28mod pulls;29mod pulls;
29mod repo;30mod 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}
106128
129/// Bytes allowed in a username (matches `model::validate_name`'s character set).
130fn 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]
137pub 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.
109fn sanitizer() -> ammonia::Builder<'static> {164fn sanitizer() -> ammonia::Builder<'static> {
@@ -228,6 +283,28 @@ mod tests {
228 }283 }
229284
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
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}
crates/web/src/pulls.rs +1 −0
@@ -522,6 +522,7 @@ pub async fn create(
522 .store522 .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;