diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 600939abe7..1f5eb63239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.notifications +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -43,6 +44,7 @@ import coil3.request.allowHardware import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -791,4 +793,35 @@ object NotificationUtils { fun NotificationManager.cancelNotifications() { cancelAll() } + + /** + * Dismisses the tray notification posted for [eventId] — used to auto-clear a + * notification once the user reads the underlying event in-app. + * + * Per-event notifications are keyed by `id.hashCode()` (see [sendNotification] + * and [sendDMNotificationStyled]), so hashing the same event id targets exactly + * the notification posted for it. Cancelling an id that isn't currently shown is + * a harmless no-op. After removing the child, any group summary left without + * children is cancelled too so the tray doesn't keep an empty summary around. + */ + fun NotificationManager.dismissNotificationForEvent(eventId: HexKey) { + val notId = eventId.hashCode() + + // Most events the user reads never had a tray notification (regular feed + // items), so bail out before touching anything when nothing is posted for it. + if (activeNotifications.none { it.id == notId }) return + + cancel(notId) + cancelChildlessGroupSummaries() + } + + private fun NotificationManager.cancelChildlessGroupSummaries() { + val active = activeNotifications + for (summary in active) { + if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue + val group = summary.notification.group ?: continue + val hasChildren = active.any { it.id != summary.id && it.notification.group == group } + if (!hasChildren) cancel(summary.id) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b81981e28d..a0c1ce164f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -240,6 +240,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -528,6 +529,7 @@ fun calculateBackgroundColor( routeForLastRead: String? = null, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, + dismissNotificationId: HexKey? = null, ): MutableState { val defaultBackgroundColor = MaterialTheme.colorScheme.background val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor @@ -540,7 +542,7 @@ fun calculateBackgroundColor( val isNew = remember(createdAt, routeForLastRead) { - routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt) + routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt, dismissNotificationId) } val bgColor = @@ -589,6 +591,7 @@ private fun CheckNewAndRenderNote( routeForLastRead, parentBackgroundColor, accountViewModel, + dismissNotificationId = baseNote.idHex, ) InnerNoteWithReactions( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2666f0a1b6..48f95e1461 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.os.Handler import android.os.Looper @@ -30,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -69,6 +71,7 @@ import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao @@ -1609,6 +1612,7 @@ class AccountViewModel( fun loadAndMarkAsRead( routeForLastRead: String, createdAt: Long?, + dismissNotificationId: HexKey? = null, ): Boolean { if (createdAt == null) return false @@ -1619,12 +1623,21 @@ class AccountViewModel( if (onIsNew) { viewModelScope.launch(Dispatchers.IO) { account.markAsRead(routeForLastRead, createdAt) + // The user is now looking at this event in-app, so clear any tray + // notification that was posted for it while the app was backgrounded. + dismissNotificationId?.let { dismissTrayNotificationFor(it) } } } return onIsNew } + private fun dismissTrayNotificationFor(eventId: HexKey) { + ContextCompat + .getSystemService(Amethyst.instance.appContext, NotificationManager::class.java) + ?.dismissNotificationForEvent(eventId) + } + fun markAllChatNotesAsRead(notes: List) { viewModelScope.launch(Dispatchers.IO) { for (note in notes) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 000fbc6ca8..30ca93806a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -187,7 +187,7 @@ fun NormalChatNote( if (routeForLastRead != null) { LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt()) + accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt(), dismissNotificationId = note.idHex) } }