diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 28fe219aa..d91a5bf8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -141,6 +141,7 @@ private object PrefKeys { 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 ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" + const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled" const val TOR_SETTINGS = "tor_settings" const val USE_PROXY = "use_proxy" const val PROXY_PORT = "proxy_port" @@ -419,6 +420,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) + putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -527,6 +529,7 @@ object LocalPreferences { val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) 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 dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -655,6 +658,7 @@ object LocalPreferences { hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), + splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), backupUserMetadata = latestUserMetadata.await(), backupContactList = latestContactList.await(), backupNIP65RelayList = latestNip65RelayList.await(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 8a61a24f3..6bb81485c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -180,6 +180,7 @@ class AccountSettings( var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, val alwaysOnNotificationService: MutableStateFlow = MutableStateFlow(false), + val splitNotificationsEnabled: MutableStateFlow = MutableStateFlow(false), var backupUserMetadata: MetadataEvent? = null, var backupContactList: ContactListEvent? = null, var backupDMRelayList: ChatMessageRelayListEvent? = null, @@ -236,6 +237,13 @@ class AccountSettings( return newValue } + fun toggleSplitNotificationsEnabled(): Boolean { + val newValue = !splitNotificationsEnabled.value + splitNotificationsEnabled.tryEmit(newValue) + saveAccountSettings() + return newValue + } + // --- // Zaps and Reactions // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index c5d079463..d92ffc0de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -38,6 +38,8 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" + const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" + const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" const val HOME_FOLLOWS = "HomeFollowsFeed" const val HOME_REPLIES = "HomeFollowsRepliesFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index d40b7af0b..92c6f965c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState @@ -108,6 +109,14 @@ class AccountFeedContentStates( val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) 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 notificationSummary = NotificationSummaryState(account) @@ -183,6 +192,8 @@ class AccountFeedContentStates( articlesFeed.updateFeedWith(newNotes) notifications.updateFeedWith(newNotes) + notificationsFollowing.updateFeedWith(newNotes) + notificationsEveryone.updateFeedWith(newNotes) notificationSummary.invalidateInsertData(newNotes) drafts.updateFeedWith(newNotes) @@ -231,6 +242,8 @@ class AccountFeedContentStates( articlesFeed.deleteFromFeed(newNotes) notifications.deleteFromFeed(newNotes) + notificationsFollowing.deleteFromFeed(newNotes) + notificationsEveryone.deleteFromFeed(newNotes) notificationSummary.invalidateInsertData(newNotes) drafts.deleteFromFeed(newNotes) @@ -240,6 +253,8 @@ class AccountFeedContentStates( fun destroy() { notifications.destroy() + notificationsFollowing.destroy() + notificationsEveryone.destroy() notificationSummary.destroy() feedListOptions.destroy() 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 26e4f10c5..3b459aac8 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 @@ -319,29 +319,44 @@ class AccountViewModel( @OptIn(ExperimentalCoroutinesApi::class) val notificationHasNewItems = - combineTransform( - account.loadLastReadFlow("Notification"), - feedStates.notifications.feedContent - .flatMapLatest { - if (it is CardFeedState.Loaded) { - it.feed + // Issue #197: when split-notifications is ON, the bottom-bar dot must glow only + // 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 + .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 { - MutableStateFlow(null) + feedStates.notifications } - }.map { it?.list?.firstOrNull()?.createdAt() }, - ) { lastRead, newestItemCreatedAt -> - emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) - }.onStart { - val lastRead = account.loadLastReadFlow("Notification").value - val cards = feedStates.notifications.feedContent.value - if (cards is CardFeedState.Loaded) { - val newestItemCreatedAt = - cards.feed.value.list - .firstOrNull() - ?.createdAt() - emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) + val lastRead = account.loadLastReadFlow("Notification").value + val cards = source.feedContent.value + if (cards is CardFeedState.Loaded) { + val newestItemCreatedAt = + cards.feed.value.list + .firstOrNull() + ?.createdAt() + emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) + } } - } val notificationHasNewItemsFlow = notificationHasNewItems 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 f8b6a7046..34dc8e4a9 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 @@ -20,11 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications +import androidx.compose.foundation.background 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider 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.routes.Route 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 fun NotificationScreen( @@ -45,6 +59,8 @@ fun NotificationScreen( ) { NotificationScreen( notifFeedContentState = accountViewModel.feedStates.notifications, + notifFollowingState = accountViewModel.feedStates.notificationsFollowing, + notifEveryoneState = accountViewModel.feedStates.notificationsEveryone, notifSummaryState = accountViewModel.feedStates.notificationSummary, notifPolls = accountViewModel.feedStates.notificationsOpenPolls, sharedPrefs = accountViewModel.settings.uiSettingsFlow, @@ -57,6 +73,8 @@ fun NotificationScreen( @Composable fun NotificationScreen( notifFeedContentState: CardFeedContentState, + notifFollowingState: CardFeedContentState, + notifEveryoneState: CardFeedContentState, notifSummaryState: NotificationSummaryState, notifPolls: OpenPollsState, sharedPrefs: UiSettingsFlow, @@ -66,16 +84,52 @@ fun NotificationScreen( ) { 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( isInvertedLayout = false, topBar = { - Column { - NotificationTopBar(accountViewModel, nav) - SummaryBar( - state = notifSummaryState, - ) + // DisappearingScaffold layers content under a translating topBar; without an + // 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) + SummaryBar(state = notifSummaryState) } }, bottomBar = { @@ -89,25 +143,179 @@ fun NotificationScreen( }, accountViewModel = accountViewModel, ) { - RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + SingleNotificationsBody( + 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( - feedContent = notifFeedContentState, - pollContent = notifPolls, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "Notification", - scrollToEventId = scrollToEventId, - headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, - ) + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + // Mirror HomeScreen: the TabRow lives in the topBar Column so it inherits + // 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) + 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 fun WatchAccountForNotifications( notifFeedContentState: CardFeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 2e377f37c..14af718e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -37,17 +38,24 @@ import com.vitorpamplona.amethyst.ui.stringRes fun NotificationTopBar( accountViewModel: AccountViewModel, nav: INav, + showSpinner: Boolean = true, ) { UserDrawerSearchTopBar(accountViewModel, nav) { - val list by accountViewModel.account.settings.defaultNotificationFollowList - .collectAsStateWithLifecycle() + if (showSpinner) { + val list by accountViewModel.account.settings.defaultNotificationFollowList + .collectAsStateWithLifecycle() - TopNavFilterBar( - followListsModel = accountViewModel.feedStates.feedListOptions, - listName = list, - accountViewModel = accountViewModel, - onChange = accountViewModel.account.settings::changeDefaultNotificationFollowList, - ) + TopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + 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)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 8f93ad00b..81afe71ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter 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.DefaultFeedOrder 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.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow class NotificationFeedFilter( val account: Account, + val modeOverride: TopFilter? = null, ) : AdditiveFeedFilter() { + // 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? = + modeOverride?.let { account.topNavFilterFlow(MutableStateFlow(it)) } + companion object { val ADDRESSABLE_KINDS = listOf( @@ -125,7 +137,7 @@ class NotificationFeedFilter( 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 @@ -137,7 +149,7 @@ class NotificationFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - followLists = account.liveNotificationFollowLists.value, + followLists = overrideFollowLists?.value ?: account.liveNotificationFollowLists.value, hiddenUsers = account.hiddenUsers.flow.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 6dc4f3381..cedb5c1c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -141,6 +141,7 @@ fun SettingsScreen( PushNotificationSettingsRow(sharedPrefs) if (accountViewModel != null) { 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 fun BatteryOptimizationBanner() { val context = LocalContext.current diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fd5e32013..02be0dc5e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1185,6 +1185,11 @@ Always-on notification service 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. + Split notifications by Follows + Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow. + Following + Everyone + Battery optimization active Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications. Fix now