Code review:

- Gate the new notificationsFollowing / notificationsEveryone fan-out in
  updateFeedsWith / deleteNotes on splitNotificationsEnabled.value so the
  default-off case stops doing two extra filter scans per event bundle.
- Lazy-init NotificationFeedFilter.overrideFollowLists so the
  SharingStarted.Eagerly topNavFilter pipeline only starts when the
  split UI actually opens the filter.
- Switch SplitNotificationsScaffold to rememberForeverPagerState so the
  active tab survives recomposition, matching HomeScreen/DiscoverScreen.
- Promote the duplicated "Notification" last-read key string to a
  NOTIFICATION_LAST_READ_KEY const.
- Move the opaque-background fix into SummaryBar itself so callers don't
  need a band-aid Modifier.background on their topBar Column.
- Trim narrating / WHAT comments; keep only the deep-link scroll WHY.
This commit is contained in:
davotoula
2026-05-12 08:44:51 +02:00
parent 972bce75c5
commit b91e84b416
7 changed files with 46 additions and 55 deletions
@@ -83,6 +83,7 @@ object PagerStateKeys {
const val HOME_SCREEN = "PagerHome" const val HOME_SCREEN = "PagerHome"
const val DISCOVER_SCREEN = "PagerDiscover" const val DISCOVER_SCREEN = "PagerDiscover"
const val POLLS_SCREEN = "PagerPolls" const val POLLS_SCREEN = "PagerPolls"
const val NOTIFICATION_SCREEN = "PagerNotification"
} }
@Composable @Composable
@@ -109,11 +109,6 @@ class AccountFeedContentStates(
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
// Split-notifications mode (Issue #197): when [AccountSettings.splitNotificationsEnabled]
// is on, the screen renders these two pinned-mode feeds in tabs instead of [notifications].
// Following = kind:3 follow list members only; Everyone = global. Both are always
// constructed so updateFeedsWith can fan out incoming events without rebuilding state.
val notificationsFollowing = CardFeedContentState(NotificationFeedFilter(account, TopFilter.AllFollows), scope) val notificationsFollowing = CardFeedContentState(NotificationFeedFilter(account, TopFilter.AllFollows), scope)
val notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope) val notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope)
@@ -192,8 +187,10 @@ class AccountFeedContentStates(
articlesFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes)
notifications.updateFeedWith(newNotes) notifications.updateFeedWith(newNotes)
notificationsFollowing.updateFeedWith(newNotes) if (account.settings.splitNotificationsEnabled.value) {
notificationsEveryone.updateFeedWith(newNotes) notificationsFollowing.updateFeedWith(newNotes)
notificationsEveryone.updateFeedWith(newNotes)
}
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.updateFeedWith(newNotes) drafts.updateFeedWith(newNotes)
@@ -242,8 +239,10 @@ class AccountFeedContentStates(
articlesFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes) notifications.deleteFromFeed(newNotes)
notificationsFollowing.deleteFromFeed(newNotes) if (account.settings.splitNotificationsEnabled.value) {
notificationsEveryone.deleteFromFeed(newNotes) notificationsFollowing.deleteFromFeed(newNotes)
notificationsEveryone.deleteFromFeed(newNotes)
}
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.deleteFromFeed(newNotes) drafts.deleteFromFeed(newNotes)
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@@ -319,16 +320,13 @@ class AccountViewModel(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val notificationHasNewItems = val notificationHasNewItems =
// Issue #197: when split-notifications is ON, the bottom-bar dot must glow only // When split-notifications is on, the badge tracks only the Following feed.
// for items in the Following tab. When OFF, fall back to the user-selected single
// feed. Switch sources on the toggle flow so changing the setting takes effect
// without a restart.
account.settings.splitNotificationsEnabled account.settings.splitNotificationsEnabled
.flatMapLatest { isSplit -> .flatMapLatest { isSplit ->
val source = val source =
if (isSplit) feedStates.notificationsFollowing else feedStates.notifications if (isSplit) feedStates.notificationsFollowing else feedStates.notifications
combineTransform( combineTransform(
account.loadLastReadFlow("Notification"), account.loadLastReadFlow(NOTIFICATION_LAST_READ_KEY),
source.feedContent source.feedContent
.flatMapLatest { .flatMapLatest {
if (it is CardFeedState.Loaded) { if (it is CardFeedState.Loaded) {
@@ -347,7 +345,7 @@ class AccountViewModel(
} else { } else {
feedStates.notifications feedStates.notifications
} }
val lastRead = account.loadLastReadFlow("Notification").value val lastRead = account.loadLastReadFlow(NOTIFICATION_LAST_READ_KEY).value
val cards = source.feedContent.value val cards = source.feedContent.value
if (cards is CardFeedState.Loaded) { if (cards is CardFeedState.Loaded) {
val newestItemCreatedAt = val newestItemCreatedAt =
@@ -20,11 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
@@ -33,15 +31,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -51,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
const val NOTIFICATION_LAST_READ_KEY = "Notification"
@Composable @Composable
fun NotificationScreen( fun NotificationScreen(
scrollToEventId: String? = null, scrollToEventId: String? = null,
@@ -124,10 +125,7 @@ private fun SingleNotificationsScaffold(
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
// DisappearingScaffold layers content under a translating topBar; without an Column {
// opaque background, feed items scroll visibly through the SummaryBar gap
// between NotificationTopBar and the divider.
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
NotificationTopBar(accountViewModel, nav, showSpinner = true) NotificationTopBar(accountViewModel, nav, showSpinner = true)
SummaryBar(state = notifSummaryState) SummaryBar(state = notifSummaryState)
} }
@@ -163,18 +161,13 @@ private fun SplitNotificationsScaffold(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val pagerState = rememberPagerState(pageCount = { 2 }) val pagerState = rememberForeverPagerState(key = PagerStateKeys.NOTIFICATION_SCREEN) { 2 }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
// Mirror HomeScreen: the TabRow lives in the topBar Column so it inherits Column {
// the scaffold's status-bar inset handling and scrolls together with the
// title + summary instead of floating into the status bar area.
// Opaque background hides feed items that scroll under the topBar through
// the SummaryBar gap (which has no container of its own).
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
NotificationTopBar(accountViewModel, nav, showSpinner = false) NotificationTopBar(accountViewModel, nav, showSpinner = false)
SummaryBar(state = notifSummaryState) SummaryBar(state = notifSummaryState)
SecondaryTabRow( SecondaryTabRow(
@@ -240,7 +233,7 @@ private fun SingleNotificationsBody(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
listState = listState, listState = listState,
nav = nav, nav = nav,
routeForLastRead = "Notification", routeForLastRead = NOTIFICATION_LAST_READ_KEY,
scrollToEventId = scrollToEventId, scrollToEventId = scrollToEventId,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
) )
@@ -306,10 +299,7 @@ private fun NotificationPagerPage(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
listState = listState, listState = listState,
nav = nav, nav = nav,
// Both tabs share the "Notification" last-read marker (Section H #7): the routeForLastRead = NOTIFICATION_LAST_READ_KEY,
// unread indicator is gated to the Following tab elsewhere, so a single
// marker keeps migration trivial and avoids a stale marker per tab.
routeForLastRead = "Notification",
scrollToEventId = scrollToEventId, scrollToEventId = scrollToEventId,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
) )
@@ -25,7 +25,9 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -46,21 +48,25 @@ import com.vitorpamplona.amethyst.ui.theme.chartStyle
fun SummaryBar(state: NotificationSummaryState) { fun SummaryBar(state: NotificationSummaryState) {
var showChart by remember { mutableStateOf(false) } var showChart by remember { mutableStateOf(false) }
UserReactionsRow(state) { showChart = !showChart } // Opaque background: DisappearingScaffold layers feed content under the topBar
// on scroll, and SummaryBar otherwise has no container of its own.
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
UserReactionsRow(state) { showChart = !showChart }
AnimatedVisibility( AnimatedVisibility(
visible = showChart, visible = showChart,
enter = slideInVertically() + expandVertically(), enter = slideInVertically() + expandVertically(),
exit = slideOutVertically() + shrinkVertically(), exit = slideOutVertically() + shrinkVertically(),
) {
Row(
modifier =
Modifier
.padding(vertical = 0.dp, horizontal = 20.dp)
.clickable(onClick = { showChart = !showChart }),
) { ) {
ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) { Row(
ObserveAndShowChart(state) modifier =
Modifier
.padding(vertical = 0.dp, horizontal = 20.dp)
.clickable(onClick = { showChart = !showChart }),
) {
ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) {
ObserveAndShowChart(state)
}
} }
} }
} }
@@ -52,8 +52,6 @@ fun NotificationTopBar(
onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList, onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList,
) )
} else { } else {
// Split-notifications (Issue #197): tabs replace the list-filter spinner, so
// render a plain title in the top bar's content slot.
Text(text = stringRes(R.string.route_notifications)) Text(text = stringRes(R.string.route_notifications))
} }
} }
@@ -81,13 +81,12 @@ class NotificationFeedFilter(
val account: Account, val account: Account,
val modeOverride: TopFilter? = null, val modeOverride: TopFilter? = null,
) : AdditiveFeedFilter<Note>() { ) : AdditiveFeedFilter<Note>() {
// When [modeOverride] is set (split-notifications tabs), build a dedicated // Pin to modeOverride for split-tab mode; otherwise follow the spinner.
// IFeedTopNavFilter flow so this filter is independent of the user-controlled // Lazy so the eagerly-collected topNavFilter pipeline is only built when
// [Account.liveNotificationFollowLists]. The Following tab pins AllFollows; the // the split UI actually opens this filter.
// Everyone tab pins Global. When the override is null we fall back to the private val overrideFollowLists: StateFlow<IFeedTopNavFilter>? by lazy {
// shared, spinner-driven flow used by the single-feed layout.
private val overrideFollowLists: StateFlow<IFeedTopNavFilter>? =
modeOverride?.let { account.topNavFilterFlow(MutableStateFlow(it)) } modeOverride?.let { account.topNavFilterFlow(MutableStateFlow(it)) }
}
companion object { companion object {
val ADDRESSABLE_KINDS = val ADDRESSABLE_KINDS =