Commit c08f54fe1f
c08f54fe1f49d97a26278675957e3d30788c1841
parent: 02a9801c46
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-21 01:18 UTC
Clear the app badge when the inbox is read
A push carries aps.badge and iOS applies it on arrival, so the badge
is right the moment a notification lands and wrong from then on:
nothing tells iOS the count dropped when the inbox is read in the app.
markAllRead sets zero, which is exact. The dashboard sets the server's
count on every load, which corrects everything else. Marking a single
row read writes nothing — the inbox list is paged, so the rows in hand
are not the unread total, and the modern badge API has no getter to
decrement from.
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift
+7
| @@ -124,6 +124,13 @@ final class NotificationsViewModel { |
| 124 | |
124 | |
| 125 | func markAllRead() async { |
125 | func markAllRead() async { |
| 126 | await perform(["notifications", "read", "--all"]) |
126 | await perform(["notifications", "read", "--all"]) |
| |
127 | // Nothing else tells iOS the count dropped: a push sets the badge |
| |
128 | // on arrival and no one lowers it, so it would sit at whatever the |
| |
129 | // last one said. Zero is exact here. Marking a single row read has |
| |
130 | // no reliable count to work from — the inbox list is paged, so the |
| |
131 | // rows in hand are not the unread total — and is left to the |
| |
132 | // dashboard's resync. |
| |
133 | await setAppBadge(0) |
| 127 | } |
134 | } |
| 128 | |
135 | |
| 129 | func loadPushSettings() async { |
136 | func loadPushSettings() async { |
gitbay/Dashboard/DashboardViewModel.swift
+6 −1
| @@ -19,7 +19,12 @@ final class DashboardViewModel { |
| 19 | |
19 | |
| 20 | func load() async { |
20 | func load() async { |
| 21 | do { |
21 | do { |
| 22 | state = .loaded(try await client.read(["dashboard"], as: DashboardData.self)) |
22 | let data = try await client.read(["dashboard"], as: DashboardData.self) |
| |
23 | state = .loaded(data) |
| |
24 | // The authoritative resync. A push sets the badge on arrival |
| |
25 | // and nothing lowers it afterwards, so this is what corrects |
| |
26 | // it — on every app open, against the account you are in. |
| |
27 | await setAppBadge(data.unread) |
| 23 | } catch { |
28 | } catch { |
| 24 | state = .from(error) |
29 | state = .from(error) |
| 25 | } |
30 | } |
gitbay/Push/AppBadge.swift
added
+19
| @@ -0,0 +1,19 @@ |
| |
1 | import UserNotifications |
| |
2 | |
| |
3 | /// Sets the app icon badge to an unread count. |
| |
4 | /// |
| |
5 | /// A push carries `aps.badge` and iOS applies it on arrival, so the badge |
| |
6 | /// is right the moment a notification lands. It is not right afterwards: |
| |
7 | /// nothing tells iOS the count dropped when the inbox is read in the app, |
| |
8 | /// and the number sits there until the next push. These calls close that. |
| |
9 | /// |
| |
10 | /// The value is always the active account's unread count. The badge is |
| |
11 | /// per-device and unread is per-account, so with two accounts signed in |
| |
12 | /// the last writer wins — a push for one account, then a dashboard load |
| |
13 | /// on the other, disagree. Exact for a single account. |
| |
14 | /// |
| |
15 | /// Failures are ignored. A badge that did not take is not worth telling |
| |
16 | /// anyone about, and the next writer corrects it. |
| |
17 | func setAppBadge(_ count: Int) async { |
| |
18 | try? await UNUserNotificationCenter.current().setBadgeCount(max(0, count)) |
| |
19 | } |