ios+web: clear delivered notifications once a chat is read on this device (#260)
Step 1 of #245: iOS sweeps a chat's banners from Notification Center when you open it (and when the app foregrounds into an already-open chat); the web app closes lingering notifications whenever it becomes visible. Same- device only — cross-device clearing is #259, gated on Apple's notification- filtering entitlement.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+40
−1
changelog/2026-07-06-notifications-clear-on-read.md+3 −0ios/LChat/PushManager.swift+15 −0ios/LChat/ThreadStore.swift+1 −0ios/LChat/Views/ChatView.swift+7 −1web/static/shell.js+14 −0
diff --git a/changelog/2026-07-06-notifications-clear-on-read.md b/changelog/2026-07-06-notifications-clear-on-read.md| new file mode 100644 |
| index 0000000..f08d5cc |
| --- /dev/null |
| +++ b/changelog/2026-07-06-notifications-clear-on-read.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Notifications now tidy up after themselves: on iPhone, opening a chat clears |
| +its banners from Notification Center, and on the web, coming back to the app |
| +dismisses any lingering message notification. |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 02352ac..5285e93 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -93,6 +93,21 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // for the conversation you're reading is just noise. |
| static var activeThreadID: String? |
| + /// Sweep a read chat's banners out of Notification Center — iOS never does |
| + /// this itself, so without it a message read in the app still sits in the |
| + /// shade. Matched on the payload's thread id, which every push carries; |
| + /// threadIdentifier is only set when the NSE could decrypt, so the |
| + /// placeholder "New message" banners would slip through that match. |
| + static func clearDelivered(thread: String) { |
| + let center = UNUserNotificationCenter.current() |
| + center.getDeliveredNotifications { notes in |
| + let ids = notes |
| + .filter { ($0.request.content.userInfo["thread"] as? String) == thread } |
| + .map { $0.request.identifier } |
| + if !ids.isEmpty { center.removeDeliveredNotifications(withIdentifiers: ids) } |
| + } |
| + } |
| + |
| func userNotificationCenter(_ center: UNUserNotificationCenter, |
| willPresent notification: UNNotification, |
| withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index ca95678..ef0eae2 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -236,6 +236,7 @@ final class ThreadStore: ObservableObject { |
| func markRead(_ thread: Thread) { |
| ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt) |
| if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false } |
| + PushManager.clearDelivered(thread: thread.id) |
| } |
| // Built-in mini-app display titles, for app-threads created before the title was |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index e9a6ad1..09ef6a7 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -189,7 +189,13 @@ struct ChatView: View { |
| // first consumes the force-scroll marker (a notification tap wants the |
| // newest message even if the feed had been scrolled up into history). |
| .onChange(of: scenePhase) { _, phase in |
| - if phase == .active { bridge.didAttach(force: consumeNotificationScroll()) } |
| + if phase == .active { |
| + bridge.didAttach(force: consumeNotificationScroll()) |
| + // Also the moment this chat's banners become read: notifications |
| + // delivered while the phone was locked (willPresent never ran) |
| + // are on-screen now, so sweep them from Notification Center. |
| + PushManager.clearDelivered(thread: thread.id) |
| + } |
| } |
| #if targetEnvironment(macCatalyst) |
| .background(MacWindowTitle(title: thread.title)) |
diff --git a/web/static/shell.js b/web/static/shell.js| index b8c1a25..78bb4cf 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -971,6 +971,7 @@ function startHeartbeat() { |
| beat(); |
| setInterval(beat, 30000); |
| document.addEventListener("visibilitychange", beat); |
| + clearNotifications(); |
| // Returning to the app (tab/window visible again) → check for a newer build, |
| // and catch up on any messages missed while hidden. This is what fixes a tapped |
| // notification landing on a stale thread: the socket may still look OPEN (the OS |
| @@ -981,6 +982,7 @@ function startHeartbeat() { |
| checkForUpdate(); |
| resyncActive(); |
| focusChatComposer(); |
| + clearNotifications(); |
| } |
| }); |
| // ...and when the window itself regains focus (clicking back into the app from |
| @@ -990,6 +992,18 @@ function startHeartbeat() { |
| window.addEventListener("focus", focusChatComposer); |
| } |
| +// Being in the app means you've seen what the banners were shouting about: close |
| +// any lchat notifications still sitting in the shade. Same sweep a notification |
| +// tap does in sw.js — the shared "lchat-message" tag means there's at most one, |
| +// covering whichever thread pushed last, so closing all loses nothing. |
| +function clearNotifications() { |
| + if (!("serviceWorker" in navigator)) return; |
| + navigator.serviceWorker.getRegistration().then(async (reg) => { |
| + if (!reg) return; |
| + for (const n of await reg.getNotifications()) n.close(); |
| + }).catch(() => {}); |
| +} |
| + |
| // Drop the caret into the live chat composer (the id-less chat.js <textarea> |
| // inside .chatapp-root) when the app regains focus. Desktop/Mac only: on touch, |
| // refocusing would pop the on-screen keyboard up on every foreground. Scoped by |