Moves open polls view to a state flow in thew viewmodel

This commit is contained in:
Vitor Pamplona
2026-03-21 16:20:15 -04:00
parent 90cced23d2
commit 459f69ff2c
5 changed files with 104 additions and 100 deletions
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -68,6 +69,7 @@ class AccountFeedContentStates(
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache) val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationsOpenPolls = OpenPollsState(account, scope)
val notificationSummary = NotificationSummaryState(account) val notificationSummary = NotificationSummaryState(account)
val feedListOptions = TopNavFilterState(account, scope) val feedListOptions = TopNavFilterState(account, scope)
@@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -52,9 +51,6 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.BadgeCompose
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
@@ -71,43 +67,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
@Composable
fun RefreshableCardView(
feedContent: CardFeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
routeForLastRead: String,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
RefresheableBox(feedContent, enablePullRefresh) {
SaveableCardFeedState(feedContent, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
}
@Composable
private fun SaveableCardFeedState(
feedContent: CardFeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
routeForLastRead: String,
scrollStateKey: String? = null,
) {
val listState =
if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
} else {
rememberLazyListState()
}
WatchScrollToTop(feedContent, listState)
RenderCardFeed(feedContent, accountViewModel, listState, nav, routeForLastRead)
}
@Composable @Composable
fun RenderCardFeed( fun RenderCardFeed(
feedContent: CardFeedContentState, feedContent: CardFeedContentState,
pollContent: OpenPollsState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
listState: LazyListState, listState: LazyListState,
nav: INav, nav: INav,
@@ -133,6 +96,7 @@ fun RenderCardFeed(
is CardFeedState.Loaded -> { is CardFeedState.Loaded -> {
FeedLoaded( FeedLoaded(
loaded = state, loaded = state,
polls = pollContent,
listState = listState, listState = listState,
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -164,13 +128,14 @@ fun NotificationFeedEmpty(onRefresh: () -> Unit) {
@Composable @Composable
private fun FeedLoaded( private fun FeedLoaded(
loaded: CardFeedState.Loaded, loaded: CardFeedState.Loaded,
polls: OpenPollsState,
listState: LazyListState, listState: LazyListState,
routeForLastRead: String, routeForLastRead: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by openPollsState(accountViewModel) val openPolls by polls.flow.collectAsStateWithLifecycle()
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -30,7 +30,10 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.ScrollStateKeys 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.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -45,6 +48,7 @@ fun NotificationScreen(
NotificationScreen( NotificationScreen(
notifFeedContentState = accountViewModel.feedStates.notifications, notifFeedContentState = accountViewModel.feedStates.notifications,
notifSummaryState = accountViewModel.feedStates.notificationSummary, notifSummaryState = accountViewModel.feedStates.notificationSummary,
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
sharedPrefs = accountViewModel.settings.uiSettingsFlow, sharedPrefs = accountViewModel.settings.uiSettingsFlow,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
@@ -55,6 +59,7 @@ fun NotificationScreen(
fun NotificationScreen( fun NotificationScreen(
notifFeedContentState: CardFeedContentState, notifFeedContentState: CardFeedContentState,
notifSummaryState: NotificationSummaryState, notifSummaryState: NotificationSummaryState,
notifPolls: OpenPollsState,
sharedPrefs: UiSettingsFlow, sharedPrefs: UiSettingsFlow,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
@@ -88,13 +93,13 @@ fun NotificationScreen(
modifier = Modifier.padding(it).consumeWindowInsets(it), modifier = Modifier.padding(it).consumeWindowInsets(it),
) { ) {
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
RefreshableCardView( RefresheableBox(notifFeedContentState, true) {
feedContent = notifFeedContentState, val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN)
accountViewModel = accountViewModel,
nav = nav, WatchScrollToTop(notifFeedContentState, listState)
routeForLastRead = "Notification",
scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification")
) }
} }
} }
} }
@@ -24,65 +24,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@Composable
fun openPollsState(accountViewModel: AccountViewModel) =
remember(accountViewModel) {
val userPubkeyHex = accountViewModel.account.userProfile().pubkeyHex
val filter =
Filter(
kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND),
authors = listOf(userPubkeyHex),
)
accountViewModel.account.cache
.observeNotes(filter)
.map { notes -> filterOpenPolls(notes) }
.flowOn(Dispatchers.IO)
}.collectAsStateWithLifecycle(initialValue = emptyList())
private fun filterOpenPolls(notes: List<Note>): List<Note> {
val now = TimeUtils.now()
val oneDayInSeconds = TimeUtils.ONE_DAY
return notes
.filter { note ->
val event = note.event ?: return@filter false
when (event) {
is PollEvent -> {
val endsAt = event.endsAt()
if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now
}
is ZapPollEvent -> {
val closedAt = event.closedAt()
if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now
}
else -> {
false
}
}
}.sortedByDescending { it.createdAt() }
}
@Composable @Composable
fun OpenPollsSectionHeader() { fun OpenPollsSectionHeader() {
@@ -0,0 +1,86 @@
/*
* 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.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache.notes
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@Stable
class OpenPollsState(
account: Account,
scope: CoroutineScope,
) {
val filter =
Filter(
kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND),
authors = listOf(account.pubKey),
)
val flow: StateFlow<List<Note>> =
account.cache
.observeNotes(filter)
.map { notes -> filterOpenPolls(notes) }
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
private fun filterOpenPolls(notes: List<Note>): List<Note> {
val now = TimeUtils.now()
val oneDayInSeconds = TimeUtils.ONE_DAY
return notes
.filter { note ->
val event = note.event ?: return@filter false
when (event) {
is PollEvent -> {
val endsAt = event.endsAt()
if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now
}
is ZapPollEvent -> {
val closedAt = event.closedAt()
if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now
}
else -> {
false
}
}
}.sortedByDescending { it.createdAt() }
}
}