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 {
21142114 color: var(--fb-accent);
21152115 border-color: var(--fb-accent);
21162116 }
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 {
21192120 font-weight: 600;
21202121 }
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+}
21212174 .repo-tabs a,
21222175 .profile-tabs a,
21232176 .subnav a {
crates/web/src/icons.rs +5 −0
@@ -43,6 +43,7 @@ pub enum Icon {
4343 GitFork,
4444 Bookmark,
4545 Users,
46+ Bell,
4647 Github,
4748 Mastodon,
4849 }
@@ -136,6 +137,10 @@ impl Icon {
136137 Icon::Users => {
137138 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"/>"#
138139 }
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+ }
139144 Icon::Github => {
140145 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"/>"#
141146 }
crates/web/src/issues.rs +42 −0
@@ -742,12 +742,53 @@ pub async fn create(
742742 state.store.add_issue_label(&issue.id, id).await?;
743743 }
744744 }
745+ notify_mentions(&state, &user.id, &repo, &issue.id, desc.trim()).await;
745746 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
746747 }
747748 .await;
748749 respond_redirect(result)
749750 }
750751
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+
751792 /// Edit-title form.
752793 #[derive(Debug, Deserialize)]
753794 pub struct EditTitleForm {
@@ -912,6 +953,7 @@ pub async fn comment(
912953 let body = form.body.trim();
913954 if !body.is_empty() {
914955 state.store.add_comment(&issue.id, &user.id, body).await?;
956+ notify_mentions(&state, &user.id, &repo, &issue.id, body).await;
915957 }
916958 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
917959 }
crates/web/src/layout.rs +2 −0
@@ -78,6 +78,8 @@ fn topbar(chrome: &Chrome) -> Markup {
7878 (create_menu(chrome))
7979 a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" }
8080 aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) }
81+ a class="icon-btn" href="/notifications"
82+ aria-label="Notifications" title="Notifications" { (icon(Icon::Bell)) }
8183 }
8284 form class="topbar-search" method="get" action="/search" role="search" {
8385 input type="search" name="q" placeholder="Search…" aria-label="Search";
crates/web/src/lib.rs +4 −0
@@ -24,6 +24,7 @@ mod layout;
2424 mod lfs;
2525 mod license;
2626 mod markdown;
27+mod notifications;
2728 mod pages;
2829 mod pulls;
2930 mod repo;
@@ -321,6 +322,9 @@ pub fn build_router(state: AppState) -> Router {
321322 )
322323 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
323324 .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))
324328 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
325329 .route("/issue-new/{repo_id}", post(issues::create))
326330 .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 {
6060 let mut i = 0;
6161 let mut in_tag = false;
6262 let mut code_depth: usize = 0;
63+ let mut a_depth: usize = 0; // inside an existing <a> (or email autolink)
6364 while i < bytes.len() {
6465 let c = bytes[i];
6566 if in_tag {
@@ -76,14 +77,20 @@ fn linkify_refs(html: &str, base: &str) -> String {
7677 code_depth += 1;
7778 } else if rest.starts_with("</code") || rest.starts_with("</pre") {
7879 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);
7984 }
8085 in_tag = true;
8186 out.push(b'<');
8287 i += 1;
8388 continue;
8489 }
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'#' {
8794 let mut j = i + 1;
8895 while j < bytes.len() && bytes[j].is_ascii_digit() {
8996 j += 1;
@@ -98,12 +105,60 @@ fn linkify_refs(html: &str, base: &str) -> String {
98105 continue;
99106 }
100107 }
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+ }
101123 out.push(c);
102124 i += 1;
103125 }
104126 String::from_utf8(out).unwrap_or_else(|_| html.to_string())
105127 }
106128
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+
107162 /// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">`
108163 /// runs (and only those classes) inside code blocks.
109164 fn sanitizer() -> ammonia::Builder<'static> {
@@ -228,6 +283,28 @@ mod tests {
228283 }
229284
230285 #[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]
231308 fn highlights_fenced_code() {
232309 let html = render("```rust\nfn main() {}\n```").into_string();
233310 // 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(
522522 .store
523523 .create_pull(&repo.id, &user.id, title, form.body.trim(), base, head)
524524 .await?;
525+ crate::issues::notify_mentions(&state, &user.id, &repo, &issue.id, form.body.trim()).await;
525526 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
526527 }
527528 .await;