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; } |