diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 47af964ce..835af46c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 9d5837c2d..4e92f770a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt index b765a25dd..0b512d29c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt @@ -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) -> 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, + 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt new file mode 100644 index 000000000..aa67010f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/ClosedPollsFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePollsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt new file mode 100644 index 000000000..96d40b1ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/dal/OpenPollsFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePollsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d78a6a2e9..261c5f8a1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -416,6 +416,8 @@ Bookmarks migrated successfully Drafts Polls + Open + Closed Pictures Shorts Videos