feat: split polls feed into Open and Closed tabs

Adds tabbed navigation to the polls screen, separating polls into
"Open" (active/no end date) and "Closed" (ended) tabs. Uses the same
tab pattern as HomeScreen with HorizontalPager and SecondaryTabRow.

https://claude.ai/code/session_0168P3u8aMmn4HnSBDXR7yv7
This commit is contained in:
Claude
2026-04-04 03:27:04 +00:00
parent e2a7f14c9a
commit 7a3542c4b4
6 changed files with 317 additions and 16 deletions
@@ -56,6 +56,8 @@ object ScrollStateKeys {
const val DISCOVER_CHATS = "DiscoverChatsFeed"
const val POLLS_SCREEN = "PollsFeed"
const val POLLS_OPEN = "PollsOpenFeed"
const val POLLS_CLOSED = "PollsClosedFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val LONGS_SCREEN = "LongsFeed"
@@ -68,6 +70,7 @@ object ScrollStateKeys {
object PagerStateKeys {
const val HOME_SCREEN = "PagerHome"
const val DISCOVER_SCREEN = "PagerDiscover"
const val POLLS_SCREEN = "PagerPolls"
}
@Composable
@@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
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.pictures.dal.PictureFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.ClosedPollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.OpenPollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
@@ -74,6 +76,8 @@ class AccountFeedContentStates(
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache)
val openPollsFeed = FeedContentState(OpenPollsFeedFilter(account), scope, LocalCache)
val closedPollsFeed = FeedContentState(ClosedPollsFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
@@ -114,6 +118,8 @@ class AccountFeedContentStates(
discoverPublicChats.updateFeedWith(newNotes)
pollsFeed.updateFeedWith(newNotes)
openPollsFeed.updateFeedWith(newNotes)
closedPollsFeed.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
@@ -148,6 +154,8 @@ class AccountFeedContentStates(
discoverPublicChats.deleteFromFeed(newNotes)
pollsFeed.deleteFromFeed(newNotes)
openPollsFeed.deleteFromFeed(newNotes)
closedPollsFeed.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
@@ -21,24 +21,41 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
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.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
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.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
fun PollsScreen(
@@ -46,7 +63,8 @@ fun PollsScreen(
nav: INav,
) {
PollsScreen(
pollsFeedContentState = accountViewModel.feedStates.pollsFeed,
openPollsFeedContentState = accountViewModel.feedStates.openPollsFeed,
closedPollsFeedContentState = accountViewModel.feedStates.closedPollsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -54,23 +72,85 @@ fun PollsScreen(
@Composable
fun PollsScreen(
pollsFeedContentState: FeedContentState,
openPollsFeedContentState: FeedContentState,
closedPollsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(pollsFeedContentState)
WatchAccountForPollsScreen(pollsFeedContentState = pollsFeedContentState, accountViewModel = accountViewModel)
WatchLifecycleAndUpdateModel(openPollsFeedContentState)
WatchLifecycleAndUpdateModel(closedPollsFeedContentState)
WatchAccountForPollsScreen(openPollsFeedContentState, closedPollsFeedContentState, accountViewModel)
PollsFilterAssemblerSubscription(accountViewModel)
AssemblePollsTabs(openPollsFeedContentState, closedPollsFeedContentState) { pagerState, tabItems ->
PollsPages(pagerState, tabItems, accountViewModel, nav)
}
}
@Composable
private fun AssemblePollsTabs(
openPollsFeedContentState: FeedContentState,
closedPollsFeedContentState: FeedContentState,
inner: @Composable (PagerState, ImmutableList<PollsTabItem>) -> Unit,
) {
val pagerState = rememberForeverPagerState(key = PagerStateKeys.POLLS_SCREEN) { 2 }
val tabs by
remember(openPollsFeedContentState, closedPollsFeedContentState) {
mutableStateOf(
listOf(
PollsTabItem(
resource = R.string.open_polls,
feedState = openPollsFeedContentState,
routeForLastRead = "PollsOpenFeed",
scrollStateKey = ScrollStateKeys.POLLS_OPEN,
),
PollsTabItem(
resource = R.string.closed_polls,
feedState = closedPollsFeedContentState,
routeForLastRead = "PollsClosedFeed",
scrollStateKey = ScrollStateKeys.POLLS_CLOSED,
),
).toImmutableList(),
)
}
inner(pagerState, tabs)
}
@Composable
private fun PollsPages(
pagerState: PagerState,
tabs: ImmutableList<PollsTabItem>,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
PollsTopBar(accountViewModel, nav)
Column {
PollsTopBar(accountViewModel, nav)
SecondaryTabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
val coroutineScope = rememberCoroutineScope()
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
text = { Text(text = stringRes(tab.resource)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
)
}
}
}
},
bottomBar = {
AppBottomBar(Route.Polls, accountViewModel) { route ->
if (route == Route.Polls) {
pollsFeedContentState.sendToTop()
tabs[pagerState.currentPage].feedState.sendToTop()
} else {
nav.newStack(route)
}
@@ -80,16 +160,20 @@ fun PollsScreen(
NewPollButton(nav)
},
accountViewModel = accountViewModel,
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
RefresheableBox(pollsFeedContentState, true) {
SaveableFeedContentState(pollsFeedContentState, scrollStateKey = ScrollStateKeys.POLLS_SCREEN) { listState ->
) {
HorizontalPager(
contentPadding = it,
state = pagerState,
userScrollEnabled = true,
) { page ->
RefresheableBox(tabs[page].feedState, true) {
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderFeedContentState(
feedContentState = pollsFeedContentState,
feedContentState = tabs[page].feedState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "PollsFeed",
routeForLastRead = tabs[page].routeForLastRead,
)
}
}
@@ -99,7 +183,8 @@ fun PollsScreen(
@Composable
fun WatchAccountForPollsScreen(
pollsFeedContentState: FeedContentState,
openPollsFeedContentState: FeedContentState,
closedPollsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.livePollsFollowLists.collectAsStateWithLifecycle()
@@ -108,6 +193,15 @@ fun WatchAccountForPollsScreen(
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
pollsFeedContentState.checkKeysInvalidateDataAndSendToTop()
openPollsFeedContentState.checkKeysInvalidateDataAndSendToTop()
closedPollsFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@Immutable
class PollsTabItem(
val resource: Int,
val feedState: FeedContentState,
val routeForLastRead: String,
val scrollStateKey: String,
)
@@ -0,0 +1,97 @@
/*
* 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.polls.dal
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.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class ClosedPollsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + "-closed"
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultPollsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
private fun isClosed(note: Note): Boolean {
val noteEvent = note.event
return when (noteEvent) {
is PollEvent -> {
noteEvent.hasEnded()
}
is ZapPollEvent -> {
val closedAt = noteEvent.closedAt()
closedAt != null && closedAt < TimeUtils.now()
}
else -> {
false
}
}
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
(noteEvent is PollEvent || noteEvent is ZapPollEvent) && params.match(noteEvent, it.relays) && isClosed(it)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePollsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
(noteEvent is PollEvent || noteEvent is ZapPollEvent) && params.match(noteEvent, it.relays) && isClosed(it)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,97 @@
/*
* 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.polls.dal
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.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.TimeUtils
class OpenPollsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + "-open"
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultPollsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
private fun isOpen(note: Note): Boolean {
val noteEvent = note.event
return when (noteEvent) {
is PollEvent -> {
!noteEvent.hasEnded()
}
is ZapPollEvent -> {
val closedAt = noteEvent.closedAt()
closedAt == null || closedAt >= TimeUtils.now()
}
else -> {
false
}
}
}
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
(noteEvent is PollEvent || noteEvent is ZapPollEvent) && params.match(noteEvent, it.relays) && isOpen(it)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePollsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
(noteEvent is PollEvent || noteEvent is ZapPollEvent) && params.match(noteEvent, it.relays) && isOpen(it)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
+2
View File
@@ -416,6 +416,8 @@
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
<string name="drafts">Drafts</string>
<string name="polls">Polls</string>
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
<string name="pictures">Pictures</string>
<string name="shorts">Shorts</string>
<string name="longs">Videos</string>