From d69674b3ab7f0aaf6164e7864e291acf0e2fb4c2 Mon Sep 17 00:00:00 2001 From: swiftdoc Date: Fri, 29 May 2026 01:12:37 -0400 Subject: [PATCH] Mark notification thread as read when opened Opening a notification did not clear it because the unread badge is driven by the server's unread count and the open paths never called markAsRead. This marks the thread read on open in three navigation paths: NotificationsOverviewView (tapping a row), MergedNotificationsOverviewView (multi-instance list), and AppDelegate.didReceive (system notification default action). Rows update in place so navigation isn't interrupted; the now-read row leaves the Unread filter on the next refresh. Swipe-to-read and swipe-to-dismiss are unchanged. Fixes #32. Generated with Claude Opus 4.8 (1M) --- Forji/Forji/App/AppDelegate.swift | 2 ++ .../Views/MergedNotificationsOverviewView.swift | 15 +++++++++++++-- Forji/Forji/Views/NotificationsOverviewView.swift | 15 +++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Forji/Forji/App/AppDelegate.swift b/Forji/Forji/App/AppDelegate.swift index f17b9c1..dc96d90 100644 --- a/Forji/Forji/App/AppDelegate.swift +++ b/Forji/Forji/App/AppDelegate.swift @@ -59,6 +59,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { if response.actionIdentifier == "MARK_READ" { await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username) } else { + // Opening the notification clears it, the same as tapping it inside the app. + await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username) await MainActor.run { NavigationState.shared.navigateToNotifications(serverURL: serverURL, username: username) } diff --git a/Forji/Forji/Views/MergedNotificationsOverviewView.swift b/Forji/Forji/Views/MergedNotificationsOverviewView.swift index 6ae7a68..99b2e51 100644 --- a/Forji/Forji/Views/MergedNotificationsOverviewView.swift +++ b/Forji/Forji/Views/MergedNotificationsOverviewView.swift @@ -113,6 +113,9 @@ struct MergedNotificationsOverviewView: View { NavigationLink { destination } label: { MergedNotificationRow(notification: notification, instanceName: tagged.instanceName) } + .simultaneousGesture(TapGesture().onEnded { + Task { await markReadOnOpen(tagged) } + }) } else { MergedNotificationRow(notification: notification, instanceName: tagged.instanceName) } @@ -193,14 +196,22 @@ struct MergedNotificationsOverviewView: View { await setNotificationRead(tagged) } - private func setNotificationRead(_ tagged: TaggedItem) async { + /// Marks a notification read when the user opens it, mirroring Mail and News. + /// The row is updated in place rather than removed so navigation into the + /// detail view is not interrupted while the list is mutating. + private func markReadOnOpen(_ tagged: TaggedItem) async { + guard tagged.item.unread else { return } + await setNotificationRead(tagged, removeFromUnread: false) + } + + private func setNotificationRead(_ tagged: TaggedItem, removeFromUnread: Bool = true) async { guard let client = tagged.authService.client else { return } let service = NotificationService(client: client) let notification = tagged.item do { try await service.markAsRead(id: notification.id) withAnimation { - if statusFilter == "unread" { + if removeFromUnread, statusFilter == "unread" { pagination.items.removeAll { $0.id == tagged.id } } else if let index = pagination.items.firstIndex(where: { $0.id == tagged.id }) { let updated = NotificationThread( diff --git a/Forji/Forji/Views/NotificationsOverviewView.swift b/Forji/Forji/Views/NotificationsOverviewView.swift index 6c475ee..128041a 100644 --- a/Forji/Forji/Views/NotificationsOverviewView.swift +++ b/Forji/Forji/Views/NotificationsOverviewView.swift @@ -117,6 +117,9 @@ struct NotificationsOverviewView: View { NavigationLink { destination } label: { NotificationRow(notification: notification) } + .simultaneousGesture(TapGesture().onEnded { + Task { await markReadOnOpen(notification) } + }) } else { NotificationRow(notification: notification) } @@ -182,12 +185,20 @@ struct NotificationsOverviewView: View { await setNotificationRead(notification) } - private func setNotificationRead(_ notification: NotificationThread) async { + /// Marks a notification read when the user opens it, mirroring Mail and News. + /// The row is updated in place rather than removed so navigation into the + /// detail view is not interrupted while the list is mutating. + private func markReadOnOpen(_ notification: NotificationThread) async { + guard notification.unread else { return } + await setNotificationRead(notification, removeFromUnread: false) + } + + private func setNotificationRead(_ notification: NotificationThread, removeFromUnread: Bool = true) async { guard let notificationService else { return } do { try await notificationService.markAsRead(id: notification.id) withAnimation { - if statusFilter == "unread" { + if removeFromUnread, statusFilter == "unread" { pagination.items.removeAll { $0.id == notification.id } } else if let index = pagination.items.firstIndex(where: { $0.id == notification.id }) { pagination.items[index] = NotificationThread(