diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 970f5e80d..567b4ac44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index ebf820d76..9b4f19c9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fc83584fb..ed83311a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -178,7 +178,7 @@ fun BuildNavigation( composable { MessagesScreen(accountViewModel, nav) } composable { VideoScreen(accountViewModel, nav) } composable { DiscoverScreen(accountViewModel, nav) } - composable { NotificationScreen(accountViewModel, nav) } + composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt index 037e7b57e..134400932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt @@ -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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f2a0c7424..b6dcdbcc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 029e535c2..1c01f7a6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt index 6d923eaf4..a4fab1dab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt @@ -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 + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 316e97775..ff434728f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -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(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" }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index 12238097b..41d1f4183 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -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) } } }