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 @@
1import 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.
17func setAppBadge(_ count: Int) async {
18 try? await UNUserNotificationCenter.current().setBadgeCount(max(0, count))
19}