OXYGENHUB

Notifications by thread: participating versus watching, filters by kind and repository, read, unread and done per thread, and opening a row marks it read eb0c44a9ad37

closes #10
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 13:12 UTC
Signature
✔ signed by Andy McCall
Hash
eb0c44a9ad3782c027b106258eef6d5c1dafd074ce2bfad37f4af0e8f6ed8e2d
Depends on
18e381ea88c2 34beb1a7e376 42a0ba4bac4e 43e8ca33bc5d 93be9ce4653e b64e2537d702 d23464c0ac60
Depended on by
f2d2776b2a14 ecac6e8d9b41 4f8404093d30 8a48dd830178 3b1bc27fe805 97b18869acf5 ea14c4ec5c3f 5d90e0e045e2 bff9106edcba e1ec9fbf538e 78001759604c a160b98fb922
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
35 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/hub/migrations/0013_notification_threads.sql +15 −0
1 +
-- Notifications grouped by thread, filtered by repository, split into
2 +
-- participating and watching, and set aside when done (hub spec §10).
3 +
4 +
ALTER TABLE notifications ADD COLUMN repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE;
5 +
-- The page the notification is about, without a fragment: one row per thread in the inbox.
6 +
ALTER TABLE notifications ADD COLUMN thread TEXT NOT NULL DEFAULT '';
7 +
ALTER TABLE notifications ADD COLUMN reason TEXT NOT NULL DEFAULT 'participating' CHECK (reason IN ('participating', 'watching'));
8 +
ALTER TABLE notifications ADD COLUMN done INTEGER NOT NULL DEFAULT 0;
9 +
10 +
UPDATE notifications SET thread = CASE WHEN instr(url, '#') > 0 THEN substr(url, 1, instr(url, '#') - 1) ELSE url END;
11 +
UPDATE notifications SET repo_id = (
12 +
    SELECT r.id FROM repositories r
13 +
    WHERE substr(notifications.url, 1, length('/' || r.owner || '/' || r.name || '/')) = '/' || r.owner || '/' || r.name || '/'
14 +
);
15 +
CREATE INDEX notifications_by_thread ON notifications (account_id, done, thread, at_ms DESC);
modified crates/hub/src/db/notify.rs +152 −3
…
13 unchanged lines
14 14
    pub url: String,
15 15
    pub actor: String,
16 16
    pub read: bool,
17 +
    pub repo_id: Option<i64>,
18 +
    pub thread: String,
19 +
    pub reason: String,
20 +
    pub done: bool,
21 +
}
22 +
23 +
/// Why someone is told: they take part in the thread, or only watch the repository.
24 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25 +
pub enum Reason {
26 +
    Participating,
27 +
    Watching,
28 +
}
29 +
30 +
impl Reason {
31 +
    pub fn as_str(self) -> &'static str {
32 +
        match self {
33 +
            Reason::Participating => "participating",
34 +
            Reason::Watching => "watching",
35 +
        }
36 +
    }
17 37
}
18 38
19 -
pub async fn add(pool: &Pool, account_id: i64, kind: &str, title: &str, url: &str, actor: &str) -> sqlx::Result<()> {
20 -
    sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor) VALUES (?, ?, ?, ?, ?, ?)")
39 +
/// The thread a notification belongs to: its page, without a fragment.
40 +
pub fn thread_of(url: &str) -> &str {
41 +
    url.split('#').next().unwrap_or(url)
42 +
}
43 +
44 +
/// A notification to record.
45 +
pub struct New<'a> {
46 +
    pub account_id: i64,
47 +
    pub repo_id: Option<i64>,
48 +
    pub reason: Reason,
49 +
    pub kind: &'a str,
50 +
    pub title: &'a str,
51 +
    pub url: &'a str,
52 +
    pub actor: &'a str,
53 +
}
54 +
55 +
pub async fn add(pool: &Pool, n: New<'_>) -> sqlx::Result<()> {
56 +
    let New { account_id, repo_id, reason, kind, title, url, actor } = n;
57 +
    // A new event brings a thread that was set aside back to the inbox.
58 +
    sqlx::query("UPDATE notifications SET done = 0 WHERE account_id = ? AND thread = ? AND done = 1")
59 +
        .bind(account_id)
60 +
        .bind(thread_of(url))
61 +
        .execute(pool)
62 +
        .await?;
63 +
    sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor, repo_id, thread, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
21 64
        .bind(account_id)
22 65
        .bind(now_ms())
23 66
        .bind(kind)
24 67
        .bind(title)
25 68
        .bind(url)
26 69
        .bind(actor)
70 +
        .bind(repo_id)
71 +
        .bind(thread_of(url))
72 +
        .bind(reason.as_str())
27 73
        .execute(pool)
28 74
        .await?;
29 75
    Ok(())
30 76
}
31 77
78 +
/// One inbox row: a thread, shown by its newest notification.
79 +
#[derive(Clone, Debug, FromRow)]
80 +
pub struct Thread {
81 +
    /// The newest notification's id.
82 +
    pub id: i64,
83 +
    pub at_ms: i64,
84 +
    pub kind: String,
85 +
    pub title: String,
86 +
    pub url: String,
87 +
    pub actor: String,
88 +
    pub thread: String,
89 +
    pub repo_id: Option<i64>,
90 +
    pub repo: Option<String>,
91 +
    pub count: i64,
92 +
    pub unread: i64,
93 +
    pub participating: bool,
94 +
    pub done: bool,
95 +
}
96 +
97 +
/// What the inbox shows.
98 +
#[derive(Clone, Debug, Default)]
99 +
pub struct Filter {
100 +
    pub done: bool,
101 +
    pub unread_only: bool,
102 +
    /// `participating` or `watching`.
103 +
    pub reason: Option<String>,
104 +
    /// A kind or its family (`issue` matches `issue.closed`).
105 +
    pub kind: Option<String>,
106 +
    pub repo_id: Option<i64>,
107 +
}
108 +
109 +
pub async fn threads(pool: &Pool, account_id: i64, f: &Filter, limit: i64) -> sqlx::Result<Vec<Thread>> {
110 +
    // SQLite takes bare columns from the row holding MAX(at_ms): the newest.
111 +
    let mut sql = String::from(
112 +
        "SELECT n.id, MAX(n.at_ms) AS at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, \
113 +
         (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = n.repo_id) AS repo, \
114 +
         COUNT(*) AS count, SUM(n.read = 0) AS unread, SUM(n.reason = 'participating') > 0 AS participating, n.done \
115 +
         FROM notifications n WHERE n.account_id = ? AND n.done = ?",
116 +
    );
117 +
    if f.kind.is_some() {
118 +
        sql.push_str(" AND (n.kind = ? OR n.kind LIKE ? || '.%')");
119 +
    }
120 +
    if f.repo_id.is_some() {
121 +
        sql.push_str(" AND n.repo_id = ?");
122 +
    }
123 +
    sql.push_str(" GROUP BY n.thread HAVING 1");
124 +
    if f.unread_only {
125 +
        sql.push_str(" AND SUM(n.read = 0) > 0");
126 +
    }
127 +
    match f.reason.as_deref() {
128 +
        Some("participating") => sql.push_str(" AND SUM(n.reason = 'participating') > 0"),
129 +
        Some("watching") => sql.push_str(" AND SUM(n.reason = 'participating') = 0"),
130 +
        _ => {}
131 +
    }
132 +
    sql.push_str(" ORDER BY at_ms DESC LIMIT ?");
133 +
    let mut q = sqlx::query_as::<_, Thread>(&sql).bind(account_id).bind(f.done);
134 +
    if let Some(k) = &f.kind {
135 +
        q = q.bind(k).bind(k);
136 +
    }
137 +
    if let Some(r) = f.repo_id {
138 +
        q = q.bind(r);
139 +
    }
140 +
    q.bind(limit).fetch_all(pool).await
141 +
}
142 +
143 +
/// Mark a thread read or unread, done or back in the inbox.
144 +
pub async fn set_thread(pool: &Pool, account_id: i64, thread: &str, read: Option<bool>, done: Option<bool>) -> sqlx::Result<()> {
145 +
    if let Some(r) = read {
146 +
        if r {
147 +
            sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND thread = ?")
148 +
                .bind(account_id)
149 +
                .bind(thread)
150 +
                .execute(pool)
151 +
                .await?;
152 +
        } else {
153 +
            // Unread: the newest notification of the thread only.
154 +
            sqlx::query("UPDATE notifications SET read = 0 WHERE id = (SELECT id FROM notifications WHERE account_id = ? AND thread = ? ORDER BY at_ms DESC, id DESC LIMIT 1)")
155 +
                .bind(account_id)
156 +
                .bind(thread)
157 +
                .execute(pool)
158 +
                .await?;
159 +
        }
160 +
    }
161 +
    if let Some(d) = done {
162 +
        let read_too = if d { ", read = 1" } else { "" };
163 +
        sqlx::query(&format!("UPDATE notifications SET done = ?{read_too} WHERE account_id = ? AND thread = ?"))
164 +
            .bind(d)
165 +
            .bind(account_id)
166 +
            .bind(thread)
167 +
            .execute(pool)
168 +
            .await?;
169 +
    }
170 +
    Ok(())
171 +
}
172 +
173 +
/// The notification with this id, if it is the account's.
174 +
pub async fn get(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<Notification>> {
175 +
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? AND id = ?").bind(account_id).bind(id).fetch_optional(pool).await
176 +
}
177 +
32 178
pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result<Vec<Notification>> {
33 179
    sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? ORDER BY read, at_ms DESC, id DESC LIMIT ?")
34 180
        .bind(account_id)
…
3 unchanged lines
38 184
}
39 185
40 186
pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> {
41 -
    sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = ? AND read = 0").bind(account_id).fetch_one(pool).await
187 +
    sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = ? AND read = 0 AND done = 0")
188 +
        .bind(account_id)
189 +
        .fetch_one(pool)
190 +
        .await
42 191
}
43 192
44 193
pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> {
…
113 unchanged lines
modified crates/hub/src/notify/mod.rs +19 −10
…
33 unchanged lines
34 34
/// repository, mentioned accounts and direct targets, never the actor.
35 35
pub async fn deliver(app: &Shared, ev: Event<'_>) {
36 36
    let pool = &app.db;
37 -
    let mut targets: BTreeSet<i64> = BTreeSet::new();
37 +
    // Participants: the thread's subscribers, the mentioned, direct targets.
38 +
    // Watchers of the repository who are none of those are only watching.
39 +
    let mut participants: BTreeSet<i64> = BTreeSet::new();
38 40
    if let Some((kind, id)) = ev.thread {
39 41
        if let Ok(subs) = ndb::subscribers(pool, kind, id).await {
40 -
            targets.extend(subs);
42 +
            participants.extend(subs);
41 43
        }
42 44
        // Acting on a thread subscribes the actor to it.
43 45
        ndb::subscribe(pool, ev.actor.id, kind, id).await.ok();
44 46
    }
45 -
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
46 -
        targets.extend(w);
47 -
    }
48 47
    for name in mentions(ev.mention_text) {
49 48
        if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await {
50 -
            targets.insert(a.id);
49 +
            participants.insert(a.id);
51 50
        }
52 51
    }
53 -
    targets.extend(ev.direct.iter().copied());
54 -
    targets.remove(&ev.actor.id);
55 -
    for id in targets {
56 -
        ndb::add(pool, id, ev.kind, &ev.title, &ev.url, &ev.actor.username).await.ok();
52 +
    participants.extend(ev.direct.iter().copied());
53 +
    let mut targets: Vec<(i64, ndb::Reason)> = participants.iter().map(|id| (*id, ndb::Reason::Participating)).collect();
54 +
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
55 +
        targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching)));
56 +
    }
57 +
    targets.retain(|(id, _)| *id != ev.actor.id);
58 +
    let repo_id = (ev.repo_id > 0).then_some(ev.repo_id);
59 +
    for (id, reason) in targets {
60 +
        ndb::add(
61 +
            pool,
62 +
            ndb::New { account_id: id, repo_id, reason, kind: ev.kind, title: &ev.title, url: &ev.url, actor: &ev.actor.username },
63 +
        )
64 +
        .await
65 +
        .ok();
57 66
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
58 67
            // Only to addresses the account has proven it reads (hub spec §5.1.1).
59 68
            if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) {
…
24 unchanged lines
modified crates/hub/src/web/inbox.rs +140 −8
…
14 unchanged lines
15 15
#[template(path = "notifications.html")]
16 16
pub struct InboxPage {
17 17
    shell: Shell,
18 -
    rows: Vec<ndb::Notification>,
18 +
    threads: Vec<ndb::Thread>,
19 19
    unread: i64,
20 20
    email_on: bool,
21 21
    notice: Option<String>,
22 +
    q: InboxQuery,
23 +
    /// Kind families and repositories among the threads in the inbox, with counts.
24 +
    kinds: Vec<(String, usize)>,
25 +
    repos: Vec<(String, usize)>,
26 +
}
27 +
28 +
/// `?view=unread|done&reason=participating|watching&kind=issue&repo=owner/name`.
29 +
#[derive(Clone, Debug, Default, Deserialize)]
30 +
pub struct InboxQuery {
31 +
    #[serde(default)]
32 +
    pub view: String,
33 +
    #[serde(default)]
34 +
    pub reason: String,
35 +
    #[serde(default)]
36 +
    pub kind: String,
37 +
    #[serde(default)]
38 +
    pub repo: String,
39 +
}
40 +
41 +
impl InboxQuery {
42 +
    /// This query with one key changed, as a URL (empty values drop out).
43 +
    pub fn with(&self, key: &str, value: &str) -> String {
44 +
        let mut q = self.clone();
45 +
        match key {
46 +
            "view" => q.view = value.into(),
47 +
            "reason" => q.reason = value.into(),
48 +
            "kind" => q.kind = value.into(),
49 +
            "repo" => q.repo = value.into(),
50 +
            _ => {}
51 +
        }
52 +
        let parts: Vec<String> = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)]
53 +
            .iter()
54 +
            .filter(|(_, v)| !v.is_empty())
55 +
            .map(|(k, v)| format!("{k}={}", super::settings::urlencode(v)))
56 +
            .collect();
57 +
        if parts.is_empty() {
58 +
            "/notifications".into()
59 +
        } else {
60 +
            format!("/notifications?{}", parts.join("&"))
61 +
        }
62 +
    }
63 +
64 +
    /// This query as it is, for forms that come back to it.
65 +
    pub fn here(&self) -> String {
66 +
        self.with("", "")
67 +
    }
22 68
}
23 69
24 -
async fn inbox_page(app: &Shared, shell: Shell, me: &accounts::Account, notice: Option<String>) -> Result<Page<InboxPage>, Error> {
25 -
    let rows = ndb::inbox(&app.db, me.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
26 -
    let unread = ndb::unread(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
70 +
/// `issue.closed` → `issue`.
71 +
fn family(kind: &str) -> &str {
72 +
    kind.split('.').next().unwrap_or(kind)
73 +
}
74 +
75 +
async fn inbox_page(
76 +
    app: &Shared,
77 +
    shell: Shell,
78 +
    me: &accounts::Account,
79 +
    q: InboxQuery,
80 +
    notice: Option<String>,
81 +
) -> Result<Page<InboxPage>, Error> {
82 +
    let e = |e| Error::from_db(shell.clone(), e);
83 +
    let repo_id = match q.repo.split_once('/') {
84 +
        Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)),
85 +
        None => None,
86 +
    };
87 +
    let filter = ndb::Filter {
88 +
        done: q.view == "done",
89 +
        unread_only: q.view == "unread",
90 +
        reason: (!q.reason.is_empty()).then(|| q.reason.clone()),
91 +
        kind: (!q.kind.is_empty()).then(|| q.kind.clone()),
92 +
        repo_id,
93 +
    };
94 +
    let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?;
95 +
    // Facets over the whole inbox, so the choices do not vanish as you pick.
96 +
    let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?;
97 +
    let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
98 +
    let mut repos: std::collections::BTreeMap<String, usize> = Default::default();
99 +
    for t in &all {
100 +
        *kinds.entry(family(&t.kind).to_string()).or_default() += 1;
101 +
        if let Some(r) = &t.repo {
102 +
            *repos.entry(r.clone()).or_default() += 1;
103 +
        }
104 +
    }
105 +
    let unread = ndb::unread(&app.db, me.id).await.map_err(e)?;
27 106
    let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten();
28 -
    Ok(Page(InboxPage { shell, rows, unread, email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), notice }))
107 +
    Ok(Page(InboxPage {
108 +
        shell,
109 +
        threads,
110 +
        unread,
111 +
        email_on: fresh.map(|a| a.email_notifications).unwrap_or(false),
112 +
        notice,
113 +
        q,
114 +
        kinds: kinds.into_iter().collect(),
115 +
        repos: repos.into_iter().collect(),
116 +
    }))
29 117
}
30 118
31 -
pub async fn inbox(State(app): State<Shared>, viewer: Viewer) -> Result<Page<InboxPage>, Error> {
119 +
pub async fn inbox(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InboxQuery>) -> Result<Page<InboxPage>, Error> {
32 120
    let shell = Shell::new(&app, "/notifications", &viewer);
33 121
    let me = require(&viewer, &shell)?;
34 -
    inbox_page(&app, shell, &me.account, None).await
122 +
    inbox_page(&app, shell, &me.account, q, None).await
35 123
}
36 124
37 125
#[derive(Deserialize)]
…
9 unchanged lines
47 135
    let me = require(&viewer, &shell)?;
48 136
    csrf(&viewer, &f.csrf_token, &shell)?;
49 137
    ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
50 -
    inbox_page(&app, shell, &me.account, None).await
138 +
    inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await
139 +
}
140 +
141 +
#[derive(Deserialize)]
142 +
pub struct ThreadForm {
143 +
    #[serde(rename = "_csrf")]
144 +
    csrf_token: String,
145 +
    thread: String,
146 +
    /// `read`, `unread`, `done` or `undone`.
147 +
    action: String,
148 +
    /// Where to go back to: the inbox with its filters.
149 +
    #[serde(default)]
150 +
    back: String,
51 151
}
52 152
153 +
/// `POST /notifications/thread`.
154 +
pub async fn thread(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ThreadForm>) -> Result<Response, Error> {
155 +
    let shell = Shell::new(&app, "/notifications", &viewer);
156 +
    let me = require(&viewer, &shell)?;
157 +
    csrf(&viewer, &f.csrf_token, &shell)?;
158 +
    let (read, done) = match f.action.as_str() {
159 +
        "read" => (Some(true), None),
160 +
        "unread" => (Some(false), None),
161 +
        "done" => (None, Some(true)),
162 +
        "undone" => (None, Some(false)),
163 +
        _ => return Err(Error::bad(shell, "unknown action")),
164 +
    };
165 +
    ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?;
166 +
    let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() };
167 +
    Ok(Redirect::to(&back).into_response())
168 +
}
169 +
170 +
/// `GET /notifications/open/{id}`: mark the thread read and go to it.
171 +
pub async fn open(State(app): State<Shared>, viewer: Viewer, axum::extract::Path(id): axum::extract::Path<i64>) -> Result<Response, Error> {
172 +
    let shell = Shell::new(&app, "/notifications", &viewer);
173 +
    let me = require(&viewer, &shell)?;
174 +
    let n = ndb::get(&app.db, me.account.id, id)
175 +
        .await
176 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
177 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
178 +
    ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?;
179 +
    // Only local pages: a notification's URL is a path on this hub.
180 +
    let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() };
181 +
    Ok(Redirect::to(&to).into_response())
182 +
}
183 +
53 184
#[derive(Deserialize)]
54 185
pub struct PrefsForm {
55 186
    #[serde(rename = "_csrf")]
…
18 unchanged lines
74 205
        &app,
75 206
        shell,
76 207
        &me.account,
208 +
        InboxQuery::default(),
77 209
        Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }),
78 210
    )
79 211
    .await
…
91 unchanged lines
modified crates/hub/src/web/issues.rs +11 −5
…
929 unchanged lines
930 930
                    idb::set_state(&app.db, i.id, "closed", "completed").await.ok();
931 931
                    idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok();
932 932
                    for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() {
933 +
                        let title = format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]);
934 +
                        let url = format!("/{}/issue/{}", row.slug(), i.number);
933 935
                        ndb::add(
934 936
                            &app.db,
935 -
                            id,
936 -
                            "issue.closed",
937 -
                            &format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]),
938 -
                            &format!("/{}/issue/{}", row.slug(), i.number),
939 -
                            "",
937 +
                            ndb::New {
938 +
                                account_id: id,
939 +
                                repo_id: Some(row.id),
940 +
                                reason: ndb::Reason::Participating,
941 +
                                kind: "issue.closed",
942 +
                                title: &title,
943 +
                                url: &url,
944 +
                                actor: "",
945 +
                            },
940 946
                        )
941 947
                        .await
942 948
                        .ok();
…
6 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
88 unchanged lines
89 89
        .route("/{owner}/{name}/changes/{r}", get(history::history))
90 90
        .route("/{owner}/{name}/change/{hash}", get(history::change))
91 91
        .route("/help", get(help::help))
92 +
        .route("/notifications/thread", post(inbox::thread))
93 +
        .route("/notifications/open/{id}", get(inbox::open))
92 94
        .route("/login/reset", get(recovery::request_form).post(recovery::request))
93 95
        .route("/login/reset/{secret}", get(recovery::new_password_form).post(recovery::set_new_password))
94 96
        .route("/verify/{secret}", get(recovery::verify))
…
470 unchanged lines
modified crates/hub/tests/api.rs +28 −2
…
333 unchanged lines
334 334
    let bob_user = mint(bob.clone(), "scopes=user%3Aread").await;
335 335
    let bob_id = oxygenhub::db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
336 336
    oxygenhub::db::notify::mark_read(&app.db, bob_id, None).await.unwrap();
337 -
    oxygenhub::db::notify::add(&app.db, bob_id, "mention", "andy/demo#1: Bug", "/andy/demo/issue/1", "andy").await.unwrap();
338 -
    oxygenhub::db::notify::add(&app.db, bob_id, "review", "andy/demo#2: Feat", "/andy/demo/proposal/2", "andy").await.unwrap();
337 +
    oxygenhub::db::notify::add(
338 +
        &app.db,
339 +
        oxygenhub::db::notify::New {
340 +
            account_id: bob_id,
341 +
            repo_id: None,
342 +
            reason: oxygenhub::db::notify::Reason::Participating,
343 +
            kind: "mention",
344 +
            title: "andy/demo#1: Bug",
345 +
            url: "/andy/demo/issue/1",
346 +
            actor: "andy",
347 +
        },
348 +
    )
349 +
    .await
350 +
    .unwrap();
351 +
    oxygenhub::db::notify::add(
352 +
        &app.db,
353 +
        oxygenhub::db::notify::New {
354 +
            account_id: bob_id,
355 +
            repo_id: None,
356 +
            reason: oxygenhub::db::notify::Reason::Participating,
357 +
            kind: "review",
358 +
            title: "andy/demo#2: Feat",
359 +
            url: "/andy/demo/proposal/2",
360 +
            actor: "andy",
361 +
        },
362 +
    )
363 +
    .await
364 +
    .unwrap();
339 365
    let (st, v) = api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await;
340 366
    assert_eq!(st, StatusCode::OK, "{v}");
341 367
    assert_eq!(v["unread"], 2);
…
11 unchanged lines
modified crates/hub/tests/issues.rs +50 −0
…
232 unchanged lines
233 233
    // A comment on the issue referencing #3 links to the proposal.
234 234
    let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await;
235 235
    assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body);
236 +
    // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3.
237 +
    let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body;
238 +
    assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}");
239 +
    let watch = get(app, "/notifications?reason=watching", Some(&bob)).await.body;
240 +
    assert!(watch.contains("andy/demo#3: Feat") && !watch.contains("Crash on start"), "{watch}");
241 +
    let props = get(app, "/notifications?kind=proposal", Some(&bob)).await.body;
242 +
    assert!(props.contains("andy/demo#3: Feat") && !props.contains("andy/demo#1"), "kind filter");
243 +
    assert!(get(app, "/notifications?repo=andy/demo", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
244 +
    assert!(get(app, "/notifications?repo=nobody/none", Some(&bob)).await.body.contains("Nothing matches these filters"));
245 +
    // One row per thread, however many events it had.
246 +
    let inbox = get(app, "/notifications", Some(&bob)).await.body;
247 +
    assert_eq!(inbox.matches("data-row-thread=\"/andy/demo/issue/1\"").count(), 1, "{inbox}");
248 +
    assert!(inbox.contains(" updates"), "the count of a busy thread");
249 +
    // Opening a thread marks it read and goes there.
250 +
    let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
251 +
    let newest: i64 = sqlx::query_scalar(
252 +
        "SELECT id FROM notifications WHERE account_id = ? AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1",
253 +
    )
254 +
    .bind(bob_id)
255 +
    .fetch_one(&app.db)
256 +
    .await
257 +
    .unwrap();
258 +
    let before = db::notify::unread(&app.db, bob_id).await.unwrap();
259 +
    let r = get(app, &format!("/notifications/open/{newest}"), Some(&bob)).await;
260 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"));
261 +
    assert!(db::notify::unread(&app.db, bob_id).await.unwrap() < before);
262 +
    assert_eq!(get(app, &format!("/notifications/open/{newest}"), Some(&andy)).await.status, StatusCode::NOT_FOUND, "only your own");
263 +
    // Done sets a thread aside until something new happens in it.
264 +
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
265 +
    let r = post(
266 +
        app,
267 +
        "/notifications/thread",
268 +
        Some(&bob),
269 +
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=done&back=%2Fnotifications"),
270 +
    )
271 +
    .await;
272 +
    assert_eq!(r.location.as_deref(), Some("/notifications"));
273 +
    assert!(!get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"));
274 +
    assert!(get(app, "/notifications?view=done", Some(&bob)).await.body.contains("Crash on start"));
275 +
    post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=one+more+thing")).await;
276 +
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("Crash on start"), "back with the new comment");
277 +
    let r = post(
278 +
        app,
279 +
        "/notifications/thread",
280 +
        Some(&bob),
281 +
        &format!("_csrf={csrf_b}&thread=%2Fandy%2Fdemo%2Fissue%2F1&action=read&back=https%3A%2F%2Fevil.example"),
282 +
    )
283 +
    .await;
284 +
    assert_eq!(r.location.as_deref(), Some("/notifications"), "only back to the inbox");
285 +
236 286
    // Bob (watcher) heard about the proposal; the inbox marks all read.
237 287
    assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat"));
238 288
    let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body);
…
420 unchanged lines
modified docs/spec.md +2 −0
…
379 unchanged lines
380 380
381 381
Events: proposal opened, review requested, review submitted, comment on a thread you are in, mention (`@name`), landing, check failed on your proposal, issue assigned, policy proposal awaiting your signature, push refused. Each becomes an inbox item (`/notifications`, with unread count in the header) and, when the account chooses, an email through `lettre` with a per-account SMTP-independent unsubscribe link. Watching a repository subscribes to all its events; participating in a thread subscribes to it.
382 382
383 +
The inbox shows one row per **thread** (the page a notification is about, such as an issue or a proposal), with its newest event, how many updates it had and how many are new. Each notification records why it was sent: **participating** (the account opened, commented on, was assigned, mentioned in or asked to review the thread) or **watching** (it only watches the repository); a thread counts as participating if any of its notifications does. The inbox filters by view (inbox, unread, done), by reason, by kind (`issue`, `proposal`, `release`, …) and by repository, with counts over the whole inbox. A thread can be marked read or unread, and **done**, which sets it aside (read) until something new happens in it. Opening a row marks its thread read and goes to the page.
384 +
383 385
## 11. Search
384 386
385 387
Global search box with scopes: repositories (name, description, topics), issues and proposals (FTS5 over title, body and comments), changes (message FTS5 over the default bookmark's state, indexed on push), accounts, and code. Code search in 1.0 is a per-repository substring and regular-expression search over the default state's text files, run with a time budget and a result cap, cached per state hash. A trigram index is a 1.x item.
…
180 unchanged lines
modified templates/notifications.html +36 −5
…
2 unchanged lines
3 3
{% block content %}
4 4
<div class="head"><h1>Notifications <small>{{ unread }} unread</small></h1></div>
5 5
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
6 +
<div class="sectioned inbox">
7 +
<nav class="section-nav inbox-nav" aria-label="Filters">
8 +
  <a href="{{ q.with("view", "") }}"{% if q.view.is_empty() %} class="on" aria-current="page"{% endif %}>Inbox</a>
9 +
  <a href="{{ q.with("view", "unread") }}"{% if q.view == "unread" %} class="on" aria-current="page"{% endif %}>Unread{% if unread > 0 %} <span class="count-chip">{{ unread }}</span>{% endif %}</a>
10 +
  <a href="{{ q.with("view", "done") }}"{% if q.view == "done" %} class="on" aria-current="page"{% endif %}>Done</a>
11 +
  <h3>Why</h3>
12 +
  <a href="{{ q.with("reason", "") }}"{% if q.reason.is_empty() %} class="on"{% endif %}>Everything</a>
13 +
  <a href="{{ q.with("reason", "participating") }}"{% if q.reason == "participating" %} class="on"{% endif %} title="You opened, commented, were assigned, mentioned or asked to review">Participating</a>
14 +
  <a href="{{ q.with("reason", "watching") }}"{% if q.reason == "watching" %} class="on"{% endif %} title="You only watch the repository">Watching</a>
15 +
  {% if !kinds.is_empty() %}<h3>Kind</h3>
16 +
  <a href="{{ q.with("kind", "") }}"{% if q.kind.is_empty() %} class="on"{% endif %}>All kinds</a>
17 +
  {% for (k, n) in kinds %}<a href="{{ q.with("kind", k) }}"{% if &q.kind == k %} class="on"{% endif %}>{{ k }} <span class="small mute">{{ n }}</span></a>{% endfor %}{% endif %}
18 +
  {% if !repos.is_empty() %}<h3>Repository</h3>
19 +
  <a href="{{ q.with("repo", "") }}"{% if q.repo.is_empty() %} class="on"{% endif %}>All repositories</a>
20 +
  {% for (r, n) in repos %}<a href="{{ q.with("repo", r) }}"{% if &q.repo == r %} class="on"{% endif %}>{{ r }} <span class="small mute">{{ n }}</span></a>{% endfor %}{% endif %}
21 +
</nav>
22 +
<div class="sections">
6 23
<div class="bar">
7 24
  <form method="post" action="/notifications/read"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">MARK ALL READ</button></form>
8 25
  <form method="post" action="/settings/notifications"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label class="small"><input type="checkbox" name="email" value="1"{% if email_on %} checked{% endif %}> email me too</label> <button type="submit" class="small">SAVE</button></form>
9 26
</div>
10 -
{% if rows.is_empty() %}<p class="empty">Nothing yet. Watch a repository or take part in a thread and its events land here.</p>{% else %}
11 -
<ul class="rows">
12 -
{% for n in rows %}
13 -
  <li{% if !n.read %} class="unread"{% endif %}><div><a class="name" href="{{ n.url }}">{% if !n.read %}<span class="sig sig-valid">●</span> {% endif %}{{ n.title }}</a><p class="small">{{ n.kind }} · {{ n.actor }} · <time title="{{ n.at_ms|when_i }}">{{ n.at_ms|ago_i }}</time></p></div>
14 -
  <div class="right">{% if !n.read %}<form class="tiny" method="post" action="/notifications/read"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="id" value="{{ n.id }}"><button>read</button></form>{% endif %}</div></li>
27 +
{% if threads.is_empty() %}
28 +
<p class="empty">{% if q.view == "done" %}Nothing set aside.{% else if q.view == "unread" %}All read.{% else if !q.reason.is_empty() || !q.kind.is_empty() || !q.repo.is_empty() %}Nothing matches these filters.{% else %}Nothing yet. Watch a repository or take part in a thread and its events land here.{% endif %}</p>
29 +
{% else %}
30 +
<ul class="rows threads">
31 +
{% for t in threads %}
32 +
  <li{% if t.unread > 0 %} class="unread"{% endif %} data-row-thread="{{ t.thread }}">
33 +
    <div>
34 +
      <a class="name" href="/notifications/open/{{ t.id }}">{% if t.unread > 0 %}<span class="sig sig-valid" title="unread">●</span> {% endif %}{{ t.title }}</a>
35 +
      <p class="small">{% if let Some(r) = t.repo %}<span class="mute">{{ r }}</span> · {% endif %}{{ t.kind }} · {{ t.actor }} · <time title="{{ t.at_ms|when_i }}">{{ t.at_ms|ago_i }}</time>{% if t.count > 1 %} · {{ t.count }} updates{% if t.unread > 0 && t.unread < t.count %}, {{ t.unread }} new{% endif %}{% endif %} · <span class="tag{% if !t.participating %} dim{% endif %}">{% if t.participating %}participating{% else %}watching{% endif %}</span></p>
36 +
    </div>
37 +
    <div class="right">
38 +
      <form class="tiny" method="post" action="/notifications/thread"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="thread" value="{{ t.thread }}"><input type="hidden" name="back" value="{{ q.here() }}">
39 +
        {% if t.unread > 0 %}<button name="action" value="read" title="Mark the thread read">read</button>{% else %}<button name="action" value="unread" title="Mark the thread unread">unread</button>{% endif %}
40 +
        {% if t.done %}<button name="action" value="undone" title="Back to the inbox">move to inbox</button>{% else %}<button name="action" value="done" title="Set the thread aside until something new happens">done</button>{% endif %}
41 +
      </form>
42 +
    </div>
43 +
  </li>
15 44
{% endfor %}
16 45
</ul>
17 46
{% endif %}
47 +
</div>
48 +
</div>
18 49
{% endblock %}
modified templates/style.css +4 −0
…
463 unchanged lines
464 464
465 465
button.linkish { background: none; border: 0; padding: 0; margin: 0; color: var(--acc); font: inherit; cursor: pointer; text-transform: none; letter-spacing: normal; }
466 466
button.linkish:hover { text-decoration: underline; }
467 +
468 +
/* Inbox filters (hub spec §10). */
469 +
.inbox-nav h3 { margin: 14px 0 4px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); }
470 +
.inbox .rows.threads .tag.dim { opacity: .7; }