mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #2855 from davotoula/feat/split-notifications-197
Split notifications: Following vs Everyone
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Tests for [NotificationFeedFilter]'s `modeOverride` constructor parameter — the wiring
|
||||
* that drives the split-notifications Following / Everyone tabs (Issue #197).
|
||||
*
|
||||
* Asserts the three contracts the feature relies on:
|
||||
* 1. `feedKey` is mode-discriminated so each pinned tab caches independently.
|
||||
* 2. `followList()` honors `modeOverride` when set; falls back to the spinner setting otherwise.
|
||||
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for
|
||||
* `TopFilter.Global` (so `isGlobal()` is true, allowing non-follower notifications through),
|
||||
* and a non-Global filter for `TopFilter.AllFollows` (forcing the follow-membership gate).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationFeedFilterModeOverrideTest {
|
||||
companion object {
|
||||
private val keyPair = KeyPair()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val client =
|
||||
NostrClient(
|
||||
OkHttpWebSocket.Builder {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
},
|
||||
scope,
|
||||
)
|
||||
|
||||
private val account =
|
||||
Account(
|
||||
settings = AccountSettings(keyPair = keyPair),
|
||||
signer = NostrSignerInternal(keyPair),
|
||||
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
|
||||
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
|
||||
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
|
||||
cache = LocalCache,
|
||||
client = client,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedKeyDiffersByModeOverride() {
|
||||
val spinner = NotificationFeedFilter(account)
|
||||
val following = NotificationFeedFilter(account, TopFilter.AllFollows)
|
||||
val everyone = NotificationFeedFilter(account, TopFilter.Global)
|
||||
|
||||
assertNotEquals(
|
||||
"Following tab's feedKey must differ from Everyone tab's so each caches independently",
|
||||
following.feedKey(),
|
||||
everyone.feedKey(),
|
||||
)
|
||||
assertTrue(
|
||||
"Everyone feedKey should encode the Global filter code",
|
||||
everyone.feedKey().endsWith(TopFilter.Global.code),
|
||||
)
|
||||
assertTrue(
|
||||
"Following feedKey should encode the AllFollows filter code",
|
||||
following.feedKey().endsWith(TopFilter.AllFollows.code),
|
||||
)
|
||||
|
||||
// When override is null, feedKey reflects the spinner-selected default.
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.Global
|
||||
assertEquals(everyone.feedKey(), spinner.feedKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followListHonorsModeOverride() {
|
||||
val following = NotificationFeedFilter(account, TopFilter.AllFollows)
|
||||
val everyone = NotificationFeedFilter(account, TopFilter.Global)
|
||||
|
||||
assertEquals(TopFilter.AllFollows, following.followList())
|
||||
assertEquals(TopFilter.Global, everyone.followList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followListFallsBackToSpinnerWhenOverrideNull() {
|
||||
val spinner = NotificationFeedFilter(account)
|
||||
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.Global
|
||||
assertEquals(TopFilter.Global, spinner.followList())
|
||||
|
||||
account.settings.defaultNotificationFollowList.value = TopFilter.AllFollows
|
||||
assertEquals(TopFilter.AllFollows, spinner.followList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFilterParamsForGlobalOverrideReportsGlobal() {
|
||||
val everyone = NotificationFeedFilter(account, TopFilter.Global)
|
||||
|
||||
val params = everyone.buildFilterParams(account)
|
||||
|
||||
// isGlobal() short-circuits the follow-membership gate in acceptableEvent,
|
||||
// which is how the Everyone tab admits notifications from non-followed authors.
|
||||
assertTrue(
|
||||
"Everyone tab's FilterByListParams must report isGlobal so non-followers pass the gate",
|
||||
params.isGlobal(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFilterParamsForAllFollowsOverrideIsNotGlobal() {
|
||||
val following = NotificationFeedFilter(account, TopFilter.AllFollows)
|
||||
|
||||
val params = following.buildFilterParams(account)
|
||||
|
||||
// The Following tab must NOT be Global so acceptableEvent falls through to
|
||||
// isAuthorInFollows() — that's the gate that drops non-follower notifications.
|
||||
assertFalse(
|
||||
"Following tab's FilterByListParams must not be Global; it must apply the follows gate",
|
||||
params.isGlobal(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -180,6 +180,7 @@ class AccountSettings(
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val splitNotificationsEnabled: MutableStateFlow<Boolean> = 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
|
||||
// ---
|
||||
|
||||
@@ -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"
|
||||
@@ -81,6 +83,7 @@ object PagerStateKeys {
|
||||
const val HOME_SCREEN = "PagerHome"
|
||||
const val DISCOVER_SCREEN = "PagerDiscover"
|
||||
const val POLLS_SCREEN = "PagerPolls"
|
||||
const val NOTIFICATION_SCREEN = "PagerNotification"
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+14
@@ -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,9 @@ class AccountFeedContentStates(
|
||||
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
|
||||
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 +187,10 @@ class AccountFeedContentStates(
|
||||
articlesFeed.updateFeedWith(newNotes)
|
||||
|
||||
notifications.updateFeedWith(newNotes)
|
||||
if (account.settings.splitNotificationsEnabled.value) {
|
||||
notificationsFollowing.updateFeedWith(newNotes)
|
||||
notificationsEveryone.updateFeedWith(newNotes)
|
||||
}
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
drafts.updateFeedWith(newNotes)
|
||||
@@ -231,6 +239,10 @@ class AccountFeedContentStates(
|
||||
articlesFeed.deleteFromFeed(newNotes)
|
||||
|
||||
notifications.deleteFromFeed(newNotes)
|
||||
if (account.settings.splitNotificationsEnabled.value) {
|
||||
notificationsFollowing.deleteFromFeed(newNotes)
|
||||
notificationsEveryone.deleteFromFeed(newNotes)
|
||||
}
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
drafts.deleteFromFeed(newNotes)
|
||||
@@ -240,6 +252,8 @@ class AccountFeedContentStates(
|
||||
|
||||
fun destroy() {
|
||||
notifications.destroy()
|
||||
notificationsFollowing.destroy()
|
||||
notificationsEveryone.destroy()
|
||||
notificationSummary.destroy()
|
||||
|
||||
feedListOptions.destroy()
|
||||
|
||||
+33
-20
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
@@ -319,29 +320,41 @@ class AccountViewModel(
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val notificationHasNewItems =
|
||||
combineTransform(
|
||||
account.loadLastReadFlow("Notification"),
|
||||
feedStates.notifications.feedContent
|
||||
.flatMapLatest {
|
||||
if (it is CardFeedState.Loaded) {
|
||||
it.feed
|
||||
// When split-notifications is on, the badge tracks only the Following feed.
|
||||
account.settings.splitNotificationsEnabled
|
||||
.flatMapLatest { isSplit ->
|
||||
val source =
|
||||
if (isSplit) feedStates.notificationsFollowing else feedStates.notifications
|
||||
combineTransform(
|
||||
account.loadLastReadFlow(NOTIFICATION_LAST_READ_KEY),
|
||||
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_LAST_READ_KEY).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
|
||||
|
||||
+216
-18
@@ -21,21 +21,36 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
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.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.PagerStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.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
|
||||
|
||||
const val NOTIFICATION_LAST_READ_KEY = "Notification"
|
||||
|
||||
@Composable
|
||||
fun NotificationScreen(
|
||||
@@ -45,6 +60,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 +74,8 @@ fun NotificationScreen(
|
||||
@Composable
|
||||
fun NotificationScreen(
|
||||
notifFeedContentState: CardFeedContentState,
|
||||
notifFollowingState: CardFeedContentState,
|
||||
notifEveryoneState: CardFeedContentState,
|
||||
notifSummaryState: NotificationSummaryState,
|
||||
notifPolls: OpenPollsState,
|
||||
sharedPrefs: UiSettingsFlow,
|
||||
@@ -66,16 +85,49 @@ 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,
|
||||
)
|
||||
NotificationTopBar(accountViewModel, nav, showSpinner = true)
|
||||
SummaryBar(state = notifSummaryState)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
@@ -89,25 +141,171 @@ 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 = rememberForeverPagerState(key = PagerStateKeys.NOTIFICATION_SCREEN) { 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 = {
|
||||
Column {
|
||||
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_LAST_READ_KEY,
|
||||
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,
|
||||
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
|
||||
scrollToEventId = scrollToEventId,
|
||||
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForNotifications(
|
||||
notifFeedContentState: CardFeedContentState,
|
||||
|
||||
+19
-13
@@ -25,7 +25,9 @@ import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,21 +48,25 @@ import com.vitorpamplona.amethyst.ui.theme.chartStyle
|
||||
fun SummaryBar(state: NotificationSummaryState) {
|
||||
var showChart by remember { mutableStateOf(false) }
|
||||
|
||||
UserReactionsRow(state) { showChart = !showChart }
|
||||
// Opaque background: DisappearingScaffold layers feed content under the topBar
|
||||
// on scroll, and SummaryBar otherwise has no container of its own.
|
||||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
UserReactionsRow(state) { showChart = !showChart }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showChart,
|
||||
enter = slideInVertically() + expandVertically(),
|
||||
exit = slideOutVertically() + shrinkVertically(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 0.dp, horizontal = 20.dp)
|
||||
.clickable(onClick = { showChart = !showChart }),
|
||||
AnimatedVisibility(
|
||||
visible = showChart,
|
||||
enter = slideInVertically() + expandVertically(),
|
||||
exit = slideOutVertically() + shrinkVertically(),
|
||||
) {
|
||||
ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) {
|
||||
ObserveAndShowChart(state)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 0.dp, horizontal = 20.dp)
|
||||
.clickable(onClick = { showChart = !showChart }),
|
||||
) {
|
||||
ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) {
|
||||
ObserveAndShowChart(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-8
@@ -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,22 @@ 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 {
|
||||
Text(text = stringRes(R.string.route_notifications))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -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,20 @@ 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<Note>() {
|
||||
// Pin to modeOverride for split-tab mode; otherwise follow the spinner.
|
||||
// Lazy so the eagerly-collected topNavFilter pipeline is only built when
|
||||
// the split UI actually opens this filter.
|
||||
private val overrideFollowLists: StateFlow<IFeedTopNavFilter>? by lazy {
|
||||
modeOverride?.let { account.topNavFilterFlow(MutableStateFlow(it)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ADDRESSABLE_KINDS =
|
||||
listOf(
|
||||
@@ -125,7 +136,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 +148,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,
|
||||
)
|
||||
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
@@ -1185,6 +1185,11 @@
|
||||
<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="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_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>
|
||||
|
||||
Reference in New Issue
Block a user