mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
refactor(polls): load the results screen's votes the way every other screen loads events
The results page had its own private relay stack: a `PollResponseLoader` seam, a `RelayPollResponseLoader` that drove `fetchAllPagesFromPool` straight from the ViewModel, and a process-wide `recentDrains` map that suppressed re-drains for five minutes. Those accessories are the headless path — CLI, geode, tests — and using them here meant the screen paid for its own paging, its own caching and its own staleness rules while the rest of the app got all three for free. It also showed: the completeness line was the only thing on a live page that could not refresh, because that five-minute cache outlived the screen that drew it. Votes now arrive through `PollResponsesFilterAssembler`, a current-screen data source shaped like `ThreadFilterAssembler` — one sub-assembler, EOSE-tracked per poll, deduplicated across screens, subscribed for exactly as long as the screen is composed, and asking the poll's own declared relays for a page of kind-1018 large enough to be worth calling "every voter". No TTL to go stale, because there is nothing cached to go stale. What is left of the loader is the single question a subscription cannot ask — NIP-45 COUNT, "how many exist that we were not sent" — which is what the completeness footer needs and nothing else. `isBackfilling` becomes `isCheckingCompleteness` and its string says what it now does, rather than describing a drain that no longer happens. The avatar stack was a second copy of the card's `UserGallery`, down to its own face cap. `UserGallery` now takes (shown, total) with the tally overload delegating to it, both callers draw the same widget, and the cap is one constant on `PollResponsesCache` instead of `GALLERY_FACES` and `AVATAR_STACK` drifting apart. Verified on a tablet: the page loads its voters through the new subscription — showing 11 while the card behind it still had 10 — and a vote published with the page open still lands live (11 → 12, bars and chips redrawn, no navigation). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
44c993841e
commit
c937d42254
+5
@@ -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,
|
||||
|
||||
@@ -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<User>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-31
@@ -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,
|
||||
)
|
||||
|
||||
+17
-58
@@ -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<HexKey, Pair<Long, PollLoadReport>>()
|
||||
|
||||
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<NormalizedRelayUrl> = (poll.relays() + pollNote.relayUrlsForReactions()).toSet()
|
||||
}
|
||||
|
||||
+81
@@ -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<PollResponsesQueryState>() {
|
||||
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<PollResponsesQueryState>,
|
||||
) : PerUniqueIdEoseManager<PollResponsesQueryState, HexKey>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: PollResponsesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
// 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
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
+70
@@ -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<RelayBasedFilter>? {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4300,7 +4300,7 @@
|
||||
<string name="poll_results_your_pick">Your vote</string>
|
||||
<string name="poll_results_selections">· %1$d selections</string>
|
||||
<string name="poll_results_no_votes">No votes yet</string>
|
||||
<string name="poll_results_loading_votes">Loading every vote…</string>
|
||||
<string name="poll_results_checking_completeness">Checking how many votes exist…</string>
|
||||
<string name="poll_results_partial">%1$d of %2$d responses loaded from %3$d relays.</string>
|
||||
<string name="poll_results_partial_approx">%1$d of about %2$d responses loaded from %3$d relays.</string>
|
||||
<plurals name="poll_results_voters">
|
||||
|
||||
+8
@@ -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<PollResponseEvent> =
|
||||
compareByDescending<PollResponseEvent> { it.createdAt }.thenBy { it.id }
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+19
-16
@@ -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<String?>(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<HexKey>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user