eleven / messenger Public

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>
Paul Campbell cdc4cf5db02955695bd0ddea5b245ce38287a5f6 parent bbef5be
5 files changed, +40 −1
  • changelog/2026-07-06-notifications-clear-on-read.md +3 −0
  • ios/LChat/PushManager.swift +15 −0
  • ios/LChat/ThreadStore.swift +1 −0
  • ios/LChat/Views/ChatView.swift +7 −1
  • web/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