Merge pull request #2066 from vitorpamplona/claude/zap-notification-routing-MBmaw

Add scroll-to-event functionality for push notifications
This commit is contained in:
Vitor Pamplona
2026-04-01 16:38:02 -04:00
committed by GitHub
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)
} }
} }
} }