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:
+9
-4
@@ -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) }
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+34
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+36
-1
@@ -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" },
|
||||||
) {
|
) {
|
||||||
|
|||||||
+6
-3
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user