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 ACCOUNT_QUERY_PARAM = "?account="
private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
class EventNotificationConsumer(
private val applicationContext: Context,
@@ -455,7 +456,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
@@ -490,7 +492,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
@@ -568,7 +571,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
notificationManager()
.sendReactionNotification(
@@ -600,7 +604,8 @@ class EventNotificationConsumer(
"notifications$ACCOUNT_QUERY_PARAM" +
account.signer.pubKey
.hexToByteArray()
.toNpub()
.toNpub() +
SCROLL_TO_QUERY_PARAM + event.id
notificationManager()
.sendChessNotification(
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
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.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -129,7 +130,8 @@ fun uriToRoute(
account: Account,
): Route? {
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)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
@@ -178,7 +178,7 @@ fun BuildNavigation(
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(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.Pictures> { PicturesScreen(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.Video, R.drawable.ic_video, R.string.route_video),
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 Notification : Route()
@Serializable data class Notification(
val scrollToEventId: String? = null,
) : Route()
@Serializable object Polls : Route()
@@ -366,7 +366,7 @@ class AccountViewModel(
mapOf(
Route.Home to homeHasNewItemsFlow,
Route.Message to messagesHasNewItemsFlow,
Route.Notification to notificationHasNewItemsFlow,
Route.Notification() to notificationHasNewItemsFlow,
)
fun isWriteable(): Boolean = account.isWriteable()
@@ -104,3 +104,37 @@ class MessageSetCard(
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
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -38,8 +40,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.Modifier
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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.coroutines.delay
@Composable
fun RenderCardFeed(
@@ -75,6 +82,7 @@ fun RenderCardFeed(
listState: LazyListState,
nav: INav,
routeForLastRead: String,
scrollToEventId: String? = null,
) {
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
@@ -101,6 +109,7 @@ fun RenderCardFeed(
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
scrollToEventId = scrollToEventId,
)
}
@@ -133,10 +142,29 @@ private fun FeedLoaded(
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: INav,
scrollToEventId: String? = null,
) {
val items by loaded.feed.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(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
@@ -182,7 +210,14 @@ private fun FeedLoaded(
key = { _, item -> item.id() },
contentType = { _, item -> item.javaClass.simpleName },
) { _, 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(
debugMessage = { "CardFeedView $item" },
) {
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(
scrollToEventId: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -50,6 +51,7 @@ fun NotificationScreen(
notifSummaryState = accountViewModel.feedStates.notificationSummary,
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
sharedPrefs = accountViewModel.settings.uiSettingsFlow,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -61,6 +63,7 @@ fun NotificationScreen(
notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState,
sharedPrefs: UiSettingsFlow,
scrollToEventId: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -79,8 +82,8 @@ fun NotificationScreen(
}
},
bottomBar = {
AppBottomBar(Route.Notification, accountViewModel) { route ->
if (route == Route.Notification) {
AppBottomBar(Route.Notification(), accountViewModel) { route ->
if (route is Route.Notification) {
notifFeedContentState.invalidateDataAndSendToTop(true)
} else {
nav.newStack(route)
@@ -98,7 +101,7 @@ fun NotificationScreen(
WatchScrollToTop(notifFeedContentState, listState)
RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification")
RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification", scrollToEventId)
}
}
}