Merge pull request #3310 from vitorpamplona/claude/android-tray-dismiss-vbdhve

Auto-dismiss notifications when events are read in-app
This commit is contained in:
Vitor Pamplona
2026-06-20 15:05:09 -04:00
committed by GitHub
4 changed files with 51 additions and 2 deletions
@@ -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)
}
}
}
@@ -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<Color>? = null,
accountViewModel: AccountViewModel,
dismissNotificationId: HexKey? = null,
): MutableState<Color> {
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(
@@ -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<Note>) {
viewModelScope.launch(Dispatchers.IO) {
for (note in notes) {
@@ -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)
}
}