feat(notifications): opt-in Following / Everyone tab split (#197)

Adds an opt-in toggle under Settings → Notifications that splits the
notifications screen into two pinned-mode tabs — Following (kind:3
follow list only) and Everyone (global). The bottom-bar unread dot
glows only for Following so it behaves like the DM-style indicator
the bounty asks for. Default is off; existing single-feed behavior
and the FeedFilterSpinner are unchanged when the toggle is off.
This commit is contained in:
davotoula
2026-05-12 08:32:25 +02:00
parent 4364bc1023
commit 972bce75c5
10 changed files with 345 additions and 49 deletions
@@ -141,6 +141,7 @@ private object PrefKeys {
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val TOR_SETTINGS = "tor_settings" const val TOR_SETTINGS = "tor_settings"
const val USE_PROXY = "use_proxy" const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port" const val PROXY_PORT = "proxy_port"
@@ -419,6 +420,7 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
// migrating from previous design // migrating from previous design
remove(PrefKeys.USE_PROXY) remove(PrefKeys.USE_PROXY)
@@ -527,6 +529,7 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
@@ -655,6 +658,7 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog, hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog, hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
backupUserMetadata = latestUserMetadata.await(), backupUserMetadata = latestUserMetadata.await(),
backupContactList = latestContactList.await(), backupContactList = latestContactList.await(),
backupNIP65RelayList = latestNip65RelayList.await(), backupNIP65RelayList = latestNip65RelayList.await(),
@@ -180,6 +180,7 @@ class AccountSettings(
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false,
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false), val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
val splitNotificationsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
var backupUserMetadata: MetadataEvent? = null, var backupUserMetadata: MetadataEvent? = null,
var backupContactList: ContactListEvent? = null, var backupContactList: ContactListEvent? = null,
var backupDMRelayList: ChatMessageRelayListEvent? = null, var backupDMRelayList: ChatMessageRelayListEvent? = null,
@@ -236,6 +237,13 @@ class AccountSettings(
return newValue return newValue
} }
fun toggleSplitNotificationsEnabled(): Boolean {
val newValue = !splitNotificationsEnabled.value
splitNotificationsEnabled.tryEmit(newValue)
saveAccountSettings()
return newValue
}
// --- // ---
// Zaps and Reactions // Zaps and Reactions
// --- // ---
@@ -38,6 +38,8 @@ private data class ScrollState(
object ScrollStateKeys { object ScrollStateKeys {
const val NOTIFICATION_SCREEN = "NotificationsFeed" const val NOTIFICATION_SCREEN = "NotificationsFeed"
const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed"
const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed"
const val VIDEO_SCREEN = "VideoFeed" const val VIDEO_SCREEN = "VideoFeed"
const val HOME_FOLLOWS = "HomeFollowsFeed" const val HOME_FOLLOWS = "HomeFollowsFeed"
const val HOME_REPLIES = "HomeFollowsRepliesFeed" const val HOME_REPLIES = "HomeFollowsRepliesFeed"
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
@@ -108,6 +109,14 @@ 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 notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope)
val notificationsOpenPolls = OpenPollsState(account, scope) val notificationsOpenPolls = OpenPollsState(account, scope)
val notificationSummary = NotificationSummaryState(account) val notificationSummary = NotificationSummaryState(account)
@@ -183,6 +192,8 @@ class AccountFeedContentStates(
articlesFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes)
notifications.updateFeedWith(newNotes) notifications.updateFeedWith(newNotes)
notificationsFollowing.updateFeedWith(newNotes)
notificationsEveryone.updateFeedWith(newNotes)
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.updateFeedWith(newNotes) drafts.updateFeedWith(newNotes)
@@ -231,6 +242,8 @@ class AccountFeedContentStates(
articlesFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes) notifications.deleteFromFeed(newNotes)
notificationsFollowing.deleteFromFeed(newNotes)
notificationsEveryone.deleteFromFeed(newNotes)
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.deleteFromFeed(newNotes) drafts.deleteFromFeed(newNotes)
@@ -240,6 +253,8 @@ class AccountFeedContentStates(
fun destroy() { fun destroy() {
notifications.destroy() notifications.destroy()
notificationsFollowing.destroy()
notificationsEveryone.destroy()
notificationSummary.destroy() notificationSummary.destroy()
feedListOptions.destroy() feedListOptions.destroy()
@@ -319,29 +319,44 @@ class AccountViewModel(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val notificationHasNewItems = val notificationHasNewItems =
combineTransform( // Issue #197: when split-notifications is ON, the bottom-bar dot must glow only
account.loadLastReadFlow("Notification"), // for items in the Following tab. When OFF, fall back to the user-selected single
feedStates.notifications.feedContent // feed. Switch sources on the toggle flow so changing the setting takes effect
.flatMapLatest { // without a restart.
if (it is CardFeedState.Loaded) { account.settings.splitNotificationsEnabled
it.feed .flatMapLatest { isSplit ->
val source =
if (isSplit) feedStates.notificationsFollowing else feedStates.notifications
combineTransform(
account.loadLastReadFlow("Notification"),
source.feedContent
.flatMapLatest {
if (it is CardFeedState.Loaded) {
it.feed
} else {
MutableStateFlow(null)
}
}.map { it?.list?.firstOrNull()?.createdAt() },
) { lastRead, newestItemCreatedAt ->
emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead)
}
}.onStart {
val source =
if (account.settings.splitNotificationsEnabled.value) {
feedStates.notificationsFollowing
} else { } else {
MutableStateFlow(null) feedStates.notifications
} }
}.map { it?.list?.firstOrNull()?.createdAt() }, val lastRead = account.loadLastReadFlow("Notification").value
) { lastRead, newestItemCreatedAt -> val cards = source.feedContent.value
emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) if (cards is CardFeedState.Loaded) {
}.onStart { val newestItemCreatedAt =
val lastRead = account.loadLastReadFlow("Notification").value cards.feed.value.list
val cards = feedStates.notifications.feedContent.value .firstOrNull()
if (cards is CardFeedState.Loaded) { ?.createdAt()
val newestItemCreatedAt = emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead)
cards.feed.value.list }
.firstOrNull()
?.createdAt()
emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead)
} }
}
val notificationHasNewItemsFlow = val notificationHasNewItemsFlow =
notificationHasNewItems notificationHasNewItems
@@ -20,11 +20,22 @@
*/ */
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.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -36,6 +47,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@Composable @Composable
fun NotificationScreen( fun NotificationScreen(
@@ -45,6 +59,8 @@ fun NotificationScreen(
) { ) {
NotificationScreen( NotificationScreen(
notifFeedContentState = accountViewModel.feedStates.notifications, notifFeedContentState = accountViewModel.feedStates.notifications,
notifFollowingState = accountViewModel.feedStates.notificationsFollowing,
notifEveryoneState = accountViewModel.feedStates.notificationsEveryone,
notifSummaryState = accountViewModel.feedStates.notificationSummary, notifSummaryState = accountViewModel.feedStates.notificationSummary,
notifPolls = accountViewModel.feedStates.notificationsOpenPolls, notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
sharedPrefs = accountViewModel.settings.uiSettingsFlow, sharedPrefs = accountViewModel.settings.uiSettingsFlow,
@@ -57,6 +73,8 @@ fun NotificationScreen(
@Composable @Composable
fun NotificationScreen( fun NotificationScreen(
notifFeedContentState: CardFeedContentState, notifFeedContentState: CardFeedContentState,
notifFollowingState: CardFeedContentState,
notifEveryoneState: CardFeedContentState,
notifSummaryState: NotificationSummaryState, notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState, notifPolls: OpenPollsState,
sharedPrefs: UiSettingsFlow, sharedPrefs: UiSettingsFlow,
@@ -66,16 +84,52 @@ fun NotificationScreen(
) { ) {
SelectNotificationProvider(sharedPrefs) SelectNotificationProvider(sharedPrefs)
WatchAccountForNotifications(notifFeedContentState, accountViewModel) val split by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
if (split) {
WatchAccountForNotifications(notifFollowingState, accountViewModel)
WatchAccountForNotifications(notifEveryoneState, accountViewModel)
SplitNotificationsScaffold(
notifFollowingState = notifFollowingState,
notifEveryoneState = notifEveryoneState,
notifSummaryState = notifSummaryState,
notifPolls = notifPolls,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
WatchAccountForNotifications(notifFeedContentState, accountViewModel)
SingleNotificationsScaffold(
notifFeedContentState = notifFeedContentState,
notifSummaryState = notifSummaryState,
notifPolls = notifPolls,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun SingleNotificationsScaffold(
notifFeedContentState: CardFeedContentState,
notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState,
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { topBar = {
Column { // DisappearingScaffold layers content under a translating topBar; without an
NotificationTopBar(accountViewModel, nav) // opaque background, feed items scroll visibly through the SummaryBar gap
SummaryBar( // between NotificationTopBar and the divider.
state = notifSummaryState, Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
) NotificationTopBar(accountViewModel, nav, showSpinner = true)
SummaryBar(state = notifSummaryState)
} }
}, },
bottomBar = { bottomBar = {
@@ -89,25 +143,179 @@ fun NotificationScreen(
}, },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) { ) {
RefresheableBox(notifFeedContentState, true) { SingleNotificationsBody(
val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) notifFeedContentState = notifFeedContentState,
notifPolls = notifPolls,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
WatchScrollToTop(notifFeedContentState, listState) @Composable
private fun SplitNotificationsScaffold(
notifFollowingState: CardFeedContentState,
notifEveryoneState: CardFeedContentState,
notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState,
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pagerState = rememberPagerState(pageCount = { 2 })
val coroutineScope = rememberCoroutineScope()
RenderCardFeed( DisappearingScaffold(
feedContent = notifFeedContentState, isInvertedLayout = false,
pollContent = notifPolls, topBar = {
accountViewModel = accountViewModel, // Mirror HomeScreen: the TabRow lives in the topBar Column so it inherits
listState = listState, // the scaffold's status-bar inset handling and scrolls together with the
nav = nav, // title + summary instead of floating into the status bar area.
routeForLastRead = "Notification", // Opaque background hides feed items that scroll under the topBar through
scrollToEventId = scrollToEventId, // the SummaryBar gap (which has no container of its own).
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
) NotificationTopBar(accountViewModel, nav, showSpinner = false)
SummaryBar(state = notifSummaryState)
SecondaryTabRow(
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
Tab(
selected = pagerState.currentPage == 0,
text = { Text(stringRes(R.string.notification_tab_following)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(stringRes(R.string.notification_tab_everyone)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
}
}
},
bottomBar = {
AppBottomBar(Route.Notification(), nav, accountViewModel) { route ->
if (route is Route.Notification) {
val active =
if (pagerState.currentPage == 0) notifFollowingState else notifEveryoneState
active.invalidateDataAndSendToTop(true)
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
SplitNotificationsBody(
pagerState = pagerState,
notifFollowingState = notifFollowingState,
notifEveryoneState = notifEveryoneState,
notifPolls = notifPolls,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun SingleNotificationsBody(
notifFeedContentState: CardFeedContentState,
notifPolls: OpenPollsState,
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
RefresheableBox(notifFeedContentState, true) {
val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN)
WatchScrollToTop(notifFeedContentState, listState)
RenderCardFeed(
feedContent = notifFeedContentState,
pollContent = notifPolls,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "Notification",
scrollToEventId = scrollToEventId,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
)
}
}
@Composable
private fun SplitNotificationsBody(
pagerState: PagerState,
notifFollowingState: CardFeedContentState,
notifEveryoneState: CardFeedContentState,
notifPolls: OpenPollsState,
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> {
NotificationPagerPage(
state = notifFollowingState,
pollContent = notifPolls,
scrollStateKey = ScrollStateKeys.NOTIFICATION_FOLLOWING,
scrollToEventId = scrollToEventId,
accountViewModel = accountViewModel,
nav = nav,
)
}
1 -> {
NotificationPagerPage(
state = notifEveryoneState,
pollContent = notifPolls,
scrollStateKey = ScrollStateKeys.NOTIFICATION_EVERYONE,
// Only the Following tab honors the deep-link scroll target so users
// aren't bounced when they swipe across to Everyone.
scrollToEventId = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
} }
} }
} }
@Composable
private fun NotificationPagerPage(
state: CardFeedContentState,
pollContent: OpenPollsState,
scrollStateKey: String,
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
RefresheableBox(state, true) {
val listState = rememberForeverLazyListState(scrollStateKey)
WatchScrollToTop(state, listState)
RenderCardFeed(
feedContent = state,
pollContent = pollContent,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
// Both tabs share the "Notification" last-read marker (Section H #7): the
// 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,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
)
}
}
@Composable @Composable
fun WatchAccountForNotifications( fun WatchAccountForNotifications(
notifFeedContentState: CardFeedContentState, notifFeedContentState: CardFeedContentState,
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -37,17 +38,24 @@ import com.vitorpamplona.amethyst.ui.stringRes
fun NotificationTopBar( fun NotificationTopBar(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
showSpinner: Boolean = true,
) { ) {
UserDrawerSearchTopBar(accountViewModel, nav) { UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultNotificationFollowList if (showSpinner) {
.collectAsStateWithLifecycle() val list by accountViewModel.account.settings.defaultNotificationFollowList
.collectAsStateWithLifecycle()
TopNavFilterBar( TopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions, followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list, listName = list,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList, onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList,
) )
} 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))
}
} }
} }
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
@@ -73,10 +74,21 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class NotificationFeedFilter( class NotificationFeedFilter(
val account: Account, val account: Account,
val modeOverride: TopFilter? = null,
) : AdditiveFeedFilter<Note>() { ) : AdditiveFeedFilter<Note>() {
// When [modeOverride] is set (split-notifications tabs), build a dedicated
// IFeedTopNavFilter flow so this filter is independent of the user-controlled
// [Account.liveNotificationFollowLists]. The Following tab pins AllFollows; the
// Everyone tab pins Global. When the override is null we fall back to the
// shared, spinner-driven flow used by the single-feed layout.
private val overrideFollowLists: StateFlow<IFeedTopNavFilter>? =
modeOverride?.let { account.topNavFilterFlow(MutableStateFlow(it)) }
companion object { companion object {
val ADDRESSABLE_KINDS = val ADDRESSABLE_KINDS =
listOf( listOf(
@@ -125,7 +137,7 @@ class NotificationFeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
fun followList(): TopFilter = account.settings.defaultNotificationFollowList.value fun followList(): TopFilter = modeOverride ?: account.settings.defaultNotificationFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList fun TopFilter.isMuteList() = this is TopFilter.MuteList
@@ -137,7 +149,7 @@ class NotificationFeedFilter(
fun buildFilterParams(account: Account): FilterByListParams = fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create( FilterByListParams.create(
followLists = account.liveNotificationFollowLists.value, followLists = overrideFollowLists?.value ?: account.liveNotificationFollowLists.value,
hiddenUsers = account.hiddenUsers.flow.value, hiddenUsers = account.hiddenUsers.flow.value,
) )
@@ -141,6 +141,7 @@ fun SettingsScreen(
PushNotificationSettingsRow(sharedPrefs) PushNotificationSettingsRow(sharedPrefs)
if (accountViewModel != null) { if (accountViewModel != null) {
AlwaysOnNotificationServiceChoice(accountViewModel) AlwaysOnNotificationServiceChoice(accountViewModel)
SplitNotificationsChoice(accountViewModel)
} }
} }
} }
@@ -556,6 +557,24 @@ fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) {
} }
} }
@Composable
fun SplitNotificationsChoice(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.splitNotificationsEnabled
.collectAsStateWithLifecycle()
SettingsRow(
R.string.split_notifications_setting_title,
R.string.split_notifications_setting_description,
) {
Switch(
checked = enabled,
onCheckedChange = {
accountViewModel.account.settings.toggleSplitNotificationsEnabled()
},
)
}
}
@Composable @Composable
fun BatteryOptimizationBanner() { fun BatteryOptimizationBanner() {
val context = LocalContext.current val context = LocalContext.current
+5
View File
@@ -1185,6 +1185,11 @@
<string name="always_on_notif_setting_title">Always-on notification service</string> <string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message.</string> <string name="always_on_notif_setting_description">Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message.</string>
<string name="split_notifications_setting_title">Split notifications by Follows</string>
<string name="split_notifications_setting_description">Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow.</string>
<string name="notification_tab_following">Following</string>
<string name="notification_tab_everyone">Everyone</string>
<string name="battery_optimization_title">Battery optimization active</string> <string name="battery_optimization_title">Battery optimization active</string>
<string name="battery_optimization_description">Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications.</string> <string name="battery_optimization_description">Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications.</string>
<string name="battery_optimization_fix_now">Fix now</string> <string name="battery_optimization_fix_now">Fix now</string>