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)
This commit is contained in:
swiftdoc 2026-05-29 01:12:37 -04:00 committed by Claude
parent 992c628abd
commit d69674b3ab
3 changed files with 28 additions and 4 deletions

View file

@ -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)
}

View file

@ -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<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(_ tagged: TaggedItem<NotificationThread>) async {
guard tagged.item.unread else { return }
await setNotificationRead(tagged, removeFromUnread: false)
}
private func setNotificationRead(_ tagged: TaggedItem<NotificationThread>, 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(

View file

@ -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(