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:
@@ -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"
|
||||||
|
|||||||
+15
@@ -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()
|
||||||
|
|||||||
+35
-20
@@ -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
|
||||||
|
|||||||
+227
-19
@@ -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,
|
||||||
|
|||||||
+16
-8
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+19
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user