feat: route zap push notification tap to notification screen with scroll and highlight

When a zap push notification is tapped, the app now navigates to the
Notifications screen and scrolls to the specific zap event, highlighting
it briefly to draw the user's attention.

- Change Route.Notification from object to data class with optional scrollToEventId
- Include zap/reaction event ID in notification URI (scrollTo param)
- Parse scrollTo from URI in uriToRoute() and pass through navigation
- Scroll to matching card in the feed and animate a highlight that fades

https://claude.ai/code/session_01Dk9vVKQKDtLLgvJPUCfKSs
This commit is contained in:
Claude
2026-04-01 04:16:18 +00:00
parent cd11503b1e
commit c7176805b2
9 changed files with 94 additions and 13 deletions
@@ -60,6 +60,7 @@ import kotlin.coroutines.cancellation.CancellationException
private const val TAG = "EventNotificationConsumer" private const val TAG = "EventNotificationConsumer"
private const val ACCOUNT_QUERY_PARAM = "?account=" private const val ACCOUNT_QUERY_PARAM = "?account="
private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
class EventNotificationConsumer( class EventNotificationConsumer(
private val applicationContext: Context, private val applicationContext: Context,
@@ -455,7 +456,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" + "notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey account.signer.pubKey
.hexToByteArray() .hexToByteArray()
.toNpub() .toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" } Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
@@ -490,7 +492,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" + "notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey account.signer.pubKey
.hexToByteArray() .hexToByteArray()
.toNpub() .toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $title $noteUri" } Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
@@ -568,7 +571,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" + "notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey account.signer.pubKey
.hexToByteArray() .hexToByteArray()
.toNpub() .toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
notificationManager() notificationManager()
.sendReactionNotification( .sendReactionNotification(
@@ -600,7 +604,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" + "notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey account.signer.pubKey
.hexToByteArray() .hexToByteArray()
.toNpub() .toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
notificationManager() notificationManager()
.sendChessNotification( .sendChessNotification(
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -129,7 +130,8 @@ fun uriToRoute(
account: Account, account: Account,
): Route? { ): Route? {
if (isNotificationRoute(uri)) { if (isNotificationRoute(uri)) {
return Route.Notification val scrollTo = runCatching { java.net.URI(uri.removePrefix("nostr:")).findParameterValue("scrollTo") }.getOrNull()
return Route.Notification(scrollToEventId = scrollTo)
} }
if (isHashtagRoute(uri)) { if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase()) return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
@@ -178,7 +178,7 @@ fun BuildNavigation(
composable<Route.Message> { MessagesScreen(accountViewModel, nav) } composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) } composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) } composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) } composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) } composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) } composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) } composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
@@ -44,5 +44,5 @@ val bottomNavigationItems =
BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
BottomBarRoute(Route.Notification, R.drawable.ic_notifications, R.string.route_notifications), BottomBarRoute(Route.Notification(), R.drawable.ic_notifications, R.string.route_notifications),
) )
@@ -39,7 +39,9 @@ sealed class Route {
@Serializable object Discover : Route() @Serializable object Discover : Route()
@Serializable object Notification : Route() @Serializable data class Notification(
val scrollToEventId: String? = null,
) : Route()
@Serializable object Polls : Route() @Serializable object Polls : Route()
@@ -366,7 +366,7 @@ class AccountViewModel(
mapOf( mapOf(
Route.Home to homeHasNewItemsFlow, Route.Home to homeHasNewItemsFlow,
Route.Message to messagesHasNewItemsFlow, Route.Message to messagesHasNewItemsFlow,
Route.Notification to notificationHasNewItemsFlow, Route.Notification() to notificationHasNewItemsFlow,
) )
fun isWriteable(): Boolean = account.isWriteable() fun isWriteable(): Boolean = account.isWriteable()
@@ -104,3 +104,37 @@ class MessageSetCard(
override fun id() = note.idHex override fun id() = note.idHex
} }
/**
* Checks if this card contains a specific event ID.
* Used for scrolling to a notification from a push notification intent.
*/
fun Card.containsEventId(eventId: String): Boolean =
when (this) {
is NoteCard -> {
note.idHex == eventId
}
is BadgeCard -> {
note.idHex == eventId
}
is MessageSetCard -> {
note.idHex == eventId
}
is ZapUserSetCard -> {
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId }
}
is MultiSetCard -> {
note.idHex == eventId ||
zapEvents.any { it.response.idHex == eventId || it.request.idHex == eventId } ||
likeEvents.any { it.idHex == eventId } ||
boostEvents.any { it.idHex == eventId }
}
else -> {
false
}
}
@@ -20,8 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -38,8 +40,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -66,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.coroutines.delay
@Composable @Composable
fun RenderCardFeed( fun RenderCardFeed(
@@ -75,6 +82,7 @@ fun RenderCardFeed(
listState: LazyListState, listState: LazyListState,
nav: INav, nav: INav,
routeForLastRead: String, routeForLastRead: String,
scrollToEventId: String? = null,
) { ) {
val feedState by feedContent.feedContent.collectAsStateWithLifecycle() val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
@@ -101,6 +109,7 @@ fun RenderCardFeed(
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
scrollToEventId = scrollToEventId,
) )
} }
@@ -133,10 +142,29 @@ private fun FeedLoaded(
routeForLastRead: String, routeForLastRead: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
scrollToEventId: String? = null,
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by polls.flow.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle()
// Track which card is highlighted (will auto-clear after animation)
var highlightedCardId by remember { mutableStateOf<String?>(null) }
// Scroll to the card containing the target event ID
if (scrollToEventId != null) {
LaunchedEffect(scrollToEventId, items) {
val position = items.list.indexOfFirst { it.containsEventId(scrollToEventId) }
if (position >= 0) {
// +1 offset for the donation card header item
val scrollIndex = position + 1 + openPolls.size
listState.animateScrollToItem(scrollIndex)
highlightedCardId = items.list[position].id()
delay(2000)
highlightedCardId = null
}
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding, contentPadding = FeedPadding,
@@ -182,7 +210,14 @@ private fun FeedLoaded(
key = { _, item -> item.id() }, key = { _, item -> item.id() },
contentType = { _, item -> item.javaClass.simpleName }, contentType = { _, item -> item.javaClass.simpleName },
) { _, item -> ) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) { val isHighlighted = highlightedCardId == item.id()
val highlightColor by animateColorAsState(
targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
animationSpec = tween(durationMillis = if (isHighlighted) 300 else 1000),
label = "highlightAnimation",
)
Row(Modifier.fillMaxWidth().background(highlightColor).animateItem()) {
logTime( logTime(
debugMessage = { "CardFeedView $item" }, debugMessage = { "CardFeedView $item" },
) { ) {
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
fun NotificationScreen( fun NotificationScreen(
scrollToEventId: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -50,6 +51,7 @@ fun NotificationScreen(
notifSummaryState = accountViewModel.feedStates.notificationSummary, notifSummaryState = accountViewModel.feedStates.notificationSummary,
notifPolls = accountViewModel.feedStates.notificationsOpenPolls, notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
sharedPrefs = accountViewModel.settings.uiSettingsFlow, sharedPrefs = accountViewModel.settings.uiSettingsFlow,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -61,6 +63,7 @@ fun NotificationScreen(
notifSummaryState: NotificationSummaryState, notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState, notifPolls: OpenPollsState,
sharedPrefs: UiSettingsFlow, sharedPrefs: UiSettingsFlow,
scrollToEventId: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -79,8 +82,8 @@ fun NotificationScreen(
} }
}, },
bottomBar = { bottomBar = {
AppBottomBar(Route.Notification, accountViewModel) { route -> AppBottomBar(Route.Notification(), accountViewModel) { route ->
if (route == Route.Notification) { if (route is Route.Notification) {
notifFeedContentState.invalidateDataAndSendToTop(true) notifFeedContentState.invalidateDataAndSendToTop(true)
} else { } else {
nav.newStack(route) nav.newStack(route)
@@ -98,7 +101,7 @@ fun NotificationScreen(
WatchScrollToTop(notifFeedContentState, listState) WatchScrollToTop(notifFeedContentState, listState)
RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification") RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification", scrollToEventId)
} }
} }
} }