diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index a213727a50..e14b2ad2ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssembler @@ -167,6 +168,9 @@ class RelaySubscriptionsCoordinator( val chess = ChessFilterAssembler(client) val polls = PollsFilterAssembler(client) + + // Votes for the poll whose results screen is open. + val pollResponses = PollResponsesFilterAssembler(client) val pictures = PicturesFilterAssembler(client) val workouts = WorkoutsFilterAssembler(client) val gitRepositories = GitRepositoriesFilterAssembler(client) @@ -229,6 +233,7 @@ class RelaySubscriptionsCoordinator( video, discovery, polls, + pollResponses, pictures, workouts, gitRepositories, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index a6abaf7ddd..50d10b29cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -617,40 +617,53 @@ fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier { } /** Faces drawn before the rest collapse into a "+N" chip. */ -private const val GALLERY_FACES = 4 +private const val GALLERY_FACES = PollResponsesCache.GALLERY_FACES @Composable fun UserGallery( tally: TallyResults, galleryUser: @Composable RowScope.(user: User) -> Unit, -) { - if (tally.size > 0) { - val shown = tally.topUsers(GALLERY_FACES) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((-10).dp), - ) { - shown.forEach { - key(it.pubkeyHex) { - galleryUser(it) - } - } +) = UserGallery(tally.topUsers(GALLERY_FACES), tally.size, galleryUser) - if (tally.size > shown.size) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .size(Size25dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), - ) { - Text( - text = "+" + showCount(tally.size - shown.size), - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurface, - ) - } +/** + * The faces behind a count: a few overlapping avatars, then "+N" for everyone who didn't fit. + * + * Takes the shown users and the true total rather than a tally, so the poll card and the poll + * results screen draw the same widget from their different sources instead of each owning a copy + * that can drift in size, spacing or cap. + */ +@Composable +fun UserGallery( + shown: List, + total: Int, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + if (total <= 0) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + shown.forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (total > shown.size) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(total - shown.size), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt index 1cda73b89c..75acbccda8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -92,7 +92,9 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.note.types.UserGallery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size25dp @@ -145,12 +147,13 @@ private fun PollResults( ) { val account = accountViewModel.account - // Subscribes for this poll while the screen is on top *and* observes what arrives. Without the - // subscription the only votes ever shown are the ones the one-shot backfill happened to catch — - // a vote cast while you are reading would never appear, because the feed card that used to - // carry it is disposed behind us. It also loads the kind-1068 event on a deep link. + // The poll's own event and engagement, exactly as any note on any screen gets them. val noteState by observeNote(note, accountViewModel) + // ...plus this screen's own claim: every vote, from the relays the poll nominates. Lifecycle-aware + // and deduplicated like every other current-screen data source, so leaving the screen closes it. + PollResponsesFilterAssemblerSubscription(note.idHex, accountViewModel) + // Opening this screen is the opt-in, exactly like the card's "View results" link. Keyed on the // note state rather than the id: arriving by deep link, the poll event lands *after* the first // composition, and an id-keyed effect would never run again to record it. @@ -168,7 +171,7 @@ private fun PollResults( isHidden = { account.isHidden(it) }, follows = account.allFollows.flow.map { it.authors }, hiddenChanges = account.hiddenUsers.flow, - loader = RelayPollResponseLoader(account.client, account.cache, note), + loader = RelayPollResponseLoader(account.client, note), ) } @@ -488,29 +491,8 @@ private fun AvatarStack( ) { if (option.topVoters.isEmpty()) return - Row(horizontalArrangement = Arrangement.spacedBy((-8).dp), verticalAlignment = Alignment.CenterVertically) { - option.topVoters.forEach { user -> - key(user.pubkeyHex) { - UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav) - } - } - val rest = option.voters - option.topVoters.size - if (rest > 0) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .size(Size25dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), - ) { - Text( - text = "+$rest", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } + UserGallery(option.topVoters, option.voters) { user -> + UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav) } } @@ -629,7 +611,7 @@ private fun ResultsFooter(state: PollResultsUiState) { } } - if (state.totalVoters == 0 && !state.isBackfilling) { + if (state.totalVoters == 0 && !state.isCheckingCompleteness) { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { Text( text = stringRes(R.string.poll_results_no_votes), @@ -662,7 +644,7 @@ private fun ResultsFooter(state: PollResultsUiState) { */ @Composable private fun Completeness(state: PollResultsUiState) { - if (state.isBackfilling) { + if (state.isCheckingCompleteness) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator( modifier = Modifier.size(12.dp), @@ -671,7 +653,7 @@ private fun Completeness(state: PollResultsUiState) { ) Spacer(Modifier.width(8.dp)) Text( - text = stringRes(R.string.poll_results_loading_votes), + text = stringRes(R.string.poll_results_checking_completeness), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt index 96d2a545e3..b1bf7a931e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt @@ -23,63 +23,36 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollLoadReport import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResponseLoader -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip45Count.mergeCountResults import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import java.util.concurrent.ConcurrentHashMap /** - * Drains a poll's responses past the live subscription's cap, then asks the relays how many there - * should have been. + * Asks the relays how many votes a poll should have, so the screen can say when it is showing fewer. * - * Two independent problems, one round trip each: + * Fetching the votes is **not** this class's job — that is + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssembler], + * the same lifecycle-aware, deduplicated, EOSE-tracked subscription every other current-screen data + * source uses. What is left here is the one question a subscription cannot answer: how many events + * exist that we did not receive. * - * 1. **Truncation.** Kind 1018 rides in the shared engagement filter with a small `limit` and no - * paging, and that limit is spread across every note batched into it — so a busy poll arrives - * partially and nothing says so. [fetchAllPagesFromPool] walks `until` backwards per relay - * until each is exhausted. - * - * 2. **Knowing what "complete" is.** See [mergeCountResults] — a COUNT fan-out cannot be summed, - * because relays mirror each other and the same vote would be counted once per relay. + * See [mergeCountResults] for why a COUNT fan-out is never summed — relays mirror each other, so + * adding their counts would multiply the poll. */ class RelayPollResponseLoader( private val client: INostrClient, - private val cache: LocalCache, private val pollNote: Note, ) : PollResponseLoader { companion object { - /** Per-relay idle window for both the drain and the COUNT. */ + /** Per-relay idle window for the COUNT. */ const val TIMEOUT_MS = 20_000L - - /** - * How long a completed drain stands in for the next one. - * - * The ViewModel — and therefore this loader — is rebuilt on every visit, so bouncing in and - * out of a poll would otherwise re-walk every relay's whole history each time. The live - * subscription keeps the tally current in between, which is what makes skipping safe. - */ - const val REDRAIN_AFTER_SECONDS = 5 * 60L - - // Process-wide, because the point is to outlive the screen. Small and self-limiting: one - // entry per poll actually opened. - private val recentDrains = ConcurrentHashMap>() - - fun forgetDrains() = recentDrains.clear() } override suspend fun load(poll: PollEvent): PollLoadReport { - recentDrains[poll.id]?.let { (drainedAt, report) -> - if (TimeUtils.now() - drainedAt < REDRAIN_AFTER_SECONDS) return report - } - val relays = responseRelays(poll) if (relays.isEmpty()) return PollLoadReport(null, approximate = false, relaysAsked = 0, relaysAnswered = 0) @@ -89,36 +62,22 @@ class RelayPollResponseLoader( tags = mapOf("e" to listOf(poll.id)), ) - client.fetchAllPagesFromPool( - filters = relays.associateWith { listOf(filter) }, - idleTimeoutMs = TIMEOUT_MS, - ) { event, relay -> - // Provenance is best-effort: a client that doesn't expose relay handles still consumes - // the vote, it just doesn't learn where it came from. - cache.justConsume(event, runCatching { client.getOrCreateRelay(relay) }.getOrNull(), false) - } - val results = client.count(relays.associateWith { listOf(filter) }, idleTimeoutMs = TIMEOUT_MS) - // Combination rules (never a sum) live in quartz — see mergeCountResults. val merged = mergeCountResults(results.values) - val report = - PollLoadReport( - reported = merged?.count, - // An estimate is approximate; so is a figure from only some of the relays we asked. - approximate = (merged?.approximate ?: false) || results.size < relays.size, - relaysAsked = relays.size, - relaysAnswered = results.size, - ) - - recentDrains[poll.id] = TimeUtils.now() to report - return report + return PollLoadReport( + reported = merged?.count, + // An estimate is approximate; so is a figure from only some of the relays we asked. + approximate = (merged?.approximate ?: false) || results.size < relays.size, + relaysAsked = relays.size, + relaysAnswered = results.size, + ) } /** * Where a poll's votes live: the relays the poll itself nominates (NIP-88 tells respondents to * publish there, and [com.vitorpamplona.amethyst.model.EventBroadcaster] obeys it), plus the - * relays we would look at for any other engagement. + * relays we would look at for any other engagement. Same set the subscription asks. */ private fun responseRelays(poll: PollEvent): Set = (poll.relays() + pollNote.relayUrlsForReactions()).toSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssembler.kt new file mode 100644 index 0000000000..1d857d4e43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssembler.kt @@ -0,0 +1,81 @@ +/* + * 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.results.datasources + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.subassembies.filterPollResponses +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** One open results screen. Two screens on the same poll share the subscription underneath. */ +@Stable +class PollResponsesQueryState( + val pollId: HexKey, + override val account: Account, +) : AccountScopedQuery + +/** + * The votes behind the poll results screen. + * + * The screen already gets the poll's own engagement through the shared event watcher; this adds the + * one thing that watcher cannot give it — a page of votes big enough to be worth calling "every + * voter", drawn from the poll's declared relays. Structured like every other current-screen data + * source ([com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler] + * is the closest sibling) so it is lifecycle-aware, deduplicated across screens, and EOSE-tracked + * without any of that being written twice. + */ +@Stable +class PollResponsesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = listOf(PollResponsesSubAssembler(client, ::allKeys)) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class PollResponsesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: PollResponsesQueryState, + since: SincePerRelayMap?, + ): List? { + // The poll event carries the relays to ask; on a deep link it may still be in flight, in + // which case there is nothing to ask yet and the filter re-assembles when it lands. + val poll = LocalCache.getNoteIfExists(key.pollId) ?: return null + + return filterPollResponses(poll, since) + } + + override fun id(key: PollResponsesQueryState) = key.pollId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..905e75d654 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/PollResponsesFilterAssemblerSubscription.kt @@ -0,0 +1,54 @@ +/* + * 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.results.datasources + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** Subscribes to a poll's votes for as long as its results screen is composed. */ +@Composable +fun PollResponsesFilterAssemblerSubscription( + pollId: HexKey, + accountViewModel: AccountViewModel, +) = PollResponsesFilterAssemblerSubscription( + pollId, + accountViewModel.account, + accountViewModel.dataSources().pollResponses, +) + +@Composable +fun PollResponsesFilterAssemblerSubscription( + pollId: HexKey, + account: Account, + filterAssembler: PollResponsesFilterAssembler, +) { + // different screens get different states, even when tracking the same poll. + val state = + remember(pollId, account) { + PollResponsesQueryState(pollId, account) + } + + LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/subassembies/FilterPollResponses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/subassembies/FilterPollResponses.kt new file mode 100644 index 0000000000..819778015c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/datasources/subassembies/FilterPollResponses.kt @@ -0,0 +1,70 @@ +/* + * 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.results.datasources.subassembies + +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent + +/** + * How many votes one relay may hand back for the open poll. + * + * The engagement filter that feeds the poll *card* asks for 100 events covering every reaction kind + * at once, which a well-voted poll blows through — and the card has no way to know it did. The + * results screen is the surface that claims to show every voter, so it asks for its own, far larger + * page of nothing but kind-1018. + */ +private const val VOTE_LIMIT = 1000 + +/** + * Every vote cast on [poll], from the relays a vote could plausibly be on. + * + * NIP-88 tells respondents to publish to the relays the poll itself nominates, and + * [com.vitorpamplona.amethyst.model.EventBroadcaster] obeys that on the way out — those relays are + * usually neither the author's inbox nor where we happened to see the poll, so they have to be + * asked explicitly or the tally silently under-counts. + */ +fun filterPollResponses( + poll: Note, + since: SincePerRelayMap?, +): List? { + val pollId = poll.event?.id ?: return null + val relays = ((poll.event as? PollEvent)?.relays().orEmpty() + poll.relayUrlsForReactions()).toSet() + if (relays.isEmpty()) return null + + return relays.map { + RelayBasedFilter( + relay = it, + filter = + ExplainedFilter( + purpose = SubPurpose.THREAD, + kinds = listOf(PollResponseEvent.KIND), + tags = mapOf("e" to listOf(pollId)), + limit = VOTE_LIMIT, + since = since?.get(it)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a2e8095b5e..963b5d3aca 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4300,7 +4300,7 @@ Your vote · %1$d selections No votes yet - Loading every vote… + Checking how many votes exist… %1$d of %2$d responses loaded from %3$d relays. %1$d of about %2$d responses loaded from %3$d relays. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt index f1e24e245c..73145915d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -40,6 +40,14 @@ import kotlinx.coroutines.flow.update @Stable class PollResponsesCache : UserDependencies { companion object { + /** + * Faces an avatar gallery shows before the rest collapse into "+N". + * + * Lives with the tally because both readers of a tally — the feed card and the results + * screen — draw the same gallery, and two copies of this number drift. + */ + const val GALLERY_FACES = 4 + val DefaultFeedOrder: Comparator = compareByDescending { it.createdAt }.thenBy { it.id } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt index 65a2a3cab5..3c6e040d58 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent /** - * What the relays said about a poll's responses, after the backfill drained them into the cache. + * What the relays said about a poll's responses, against what the subscription actually delivered. * * [reported] is deliberately **not** a sum across relays. The same vote is usually stored on * several relays, so adding their counts would multiply the poll. NIP-45's optional HyperLogLog @@ -50,11 +50,11 @@ class PollLoadReport( ) /** - * Drains every response for a poll into the cache and reports how many the relays believe exist. + * Reports how many responses the relays believe a poll has. * - * The live subscription is capped and never pages, so a poll with more responses than the cap is - * silently truncated — exactly the polls a results screen is opened for. Implementations page past - * the cap once, on demand, rather than widening the always-on subscription. + * Loading the votes belongs to the screen's subscription, not here; this exists only so the screen + * can tell the difference between "these are all the votes" and "these are the votes we got". A + * COUNT is a one-shot question with no streaming form, which is why it is not a subscription. */ fun interface PollResponseLoader { suspend fun load(poll: PollEvent): PollLoadReport diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt index 18e9fbaf99..481a743e33 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache.ResponseTally import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -91,7 +92,8 @@ class PollResultsUiState( val reportedResponses: Int? = null, val reportIsApproximate: Boolean = false, val relaysAnswered: Int = 0, - val isBackfilling: Boolean = false, + /** True while we are still asking the relays how many votes exist. */ + val isCheckingCompleteness: Boolean = false, ) { /** True only when we can actually show that responses are missing. */ val isIncomplete get() = reportedResponses != null && reportedResponses > loadedResponses @@ -102,8 +104,8 @@ class PollResultsUiState( * * Reads the live tally off the poll [Note] — no second cache — and turns it into rows the UI can * render without doing set arithmetic in composition. Every rebuild after the first runs on - * [computeContext] (Default): a backfill emits one tally per consumed vote, and re-sorting thousands of - * voters that many times on the UI thread would drop frames for the whole drain. The only + * [computeContext] (Default): a busy poll emits one tally per vote consumed, and re-sorting thousands + * of voters that many times on the UI thread would drop frames for the whole drain. The only * interactive state is [selectedOption], which scopes the voter list without touching the summary. * * Muting is applied here rather than in the tally: a muted voter still counts toward the totals @@ -123,29 +125,30 @@ class PollResultsViewModel( private val loadContext: CoroutineContext = Dispatchers.IO, ) : ViewModel() { companion object { - /** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */ - const val AVATAR_STACK = 4 + /** Faces shown per option before collapsing into a "+N" chip. */ + const val AVATAR_STACK = PollResponsesCache.GALLERY_FACES } private val _selectedOption = MutableStateFlow(null) val selectedOption = _selectedOption.asStateFlow() - private val backfill = MutableStateFlow(BackfillState()) + private val completeness = MutableStateFlow(CompletenessState()) - private class BackfillState( + private class CompletenessState( val running: Boolean = false, val report: PollLoadReport? = null, ) init { - // Page past the subscription cap once, on open. Without this a poll with more responses - // than the live filter's limit shows a confidently wrong tally. + // The votes themselves arrive on the screen's subscription. This asks the one thing a + // subscription cannot answer — how many exist that we were not sent — so the page can say + // when it is showing fewer than the relays hold. if (loader != null) { viewModelScope.launch(loadContext) { val poll = awaitPollEvent() - backfill.value = BackfillState(running = true) + completeness.value = CompletenessState(running = true) val report = runCatching { loader.load(poll) }.getOrNull() - backfill.value = BackfillState(running = false, report = report) + completeness.value = CompletenessState(running = false, report = report) } } } @@ -166,7 +169,7 @@ class PollResultsViewModel( follows, _selectedOption, hiddenChanges, - backfill, + completeness, ) { tally, followSet, selected, _, load -> build(tally, followSet, selected, load) }.flowOn(computeContext) @@ -175,9 +178,9 @@ class PollResultsViewModel( started = SharingStarted.WhileSubscribed(5_000), // Built eagerly so the first frame already has the tally instead of flashing an // empty poll. Only this one pass is on the caller's thread; every recomputation - // after it — including the flood of them during a backfill, one per consumed vote — + // after it — including the flood of them as votes stream in, one per event — // runs on Default via the flowOn above. - initialValue = build(pollNote.pollState().responses.value, emptySet(), null, BackfillState()), + initialValue = build(pollNote.pollState().responses.value, emptySet(), null, CompletenessState()), ) fun selectOption(code: String?) { @@ -188,7 +191,7 @@ class PollResultsViewModel( tally: ResponseTally, follows: Set, selected: String?, - load: BackfillState, + load: CompletenessState, ): PollResultsUiState { val event = pollNote.event as? PollEvent @@ -272,7 +275,7 @@ class PollResultsViewModel( reportedResponses = load.report?.reported?.coerceAtLeast(tally.allResponses.size), reportIsApproximate = load.report?.approximate ?: false, relaysAnswered = load.report?.relaysAnswered ?: 0, - isBackfilling = load.running, + isCheckingCompleteness = load.running, ) }