mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(polls): poll results screen with counts and voters
Adds a dedicated NIP-88 poll results screen showing how many votes each option got and who voted for what, and fixes the tally bugs and the missing subscription it would otherwise have inherited. Tally is now poll-aware. PollTallyPolicy carries the kind-1068 rules — valid option codes, poll type, and the open window — into ResponseTally, which previously had no access to the poll it was counting. That fixes four things at once: single-choice polls now read only the first response tag instead of every one of them, unknown option codes no longer create phantom buckets that drag real percentages down, responses stamped outside the poll's window are excluded rather than winning on timestamp, and percentages divide by distinct voters instead of total selections. Responses routinely arrive before their poll, so the tally starts permissive and recomputes when updatePolicy lands; both caches set it from either arrival order. Percentages are now share-of-voters. Single choice is unchanged; on multiple choice a bar reads "7 in 10 people" and the bars can sum past 100%. Android now asks the poll's own relay tags for kind 1018. Votes are published there per NIP-88 and EventBroadcaster obeys that on the way out, but the engagement filter only queried the author's inbox relays and where the note was seen, so tallies were systematically short. Desktop had already patched this per-card. The screen itself reads that same tally off the poll Note — no new subscription, no second cache, so the feed card and the results page cannot disagree. Voter rows are UserLine unmodified, with the vote passed into the trailingContent slot it already exposes. Tapping an option scopes the list without moving the summary above it. Muted voters still count toward the totals but are not listed, and the footer accounts for every response excluded and why. Entry points: a vote count beside each percentage on the feed card, and a "N votes" link that opens the screen — so the avatar stack's "+N" is no longer a dead end. Desktop column, audience filter, sort, search and zap polls are not in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
This commit is contained in:
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter
|
||||
@@ -2946,6 +2947,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
if (new) {
|
||||
pollNote.pollState().addResponse(responseNote)
|
||||
// Responses and their poll race each other. If the poll is already here, hand the
|
||||
// tally its rules now; if it isn't, consume(PollEvent) does it on arrival.
|
||||
(pollNote.event as? PollEvent)?.let {
|
||||
pollNote.pollState().updatePolicy(PollTallyPolicy.from(it))
|
||||
}
|
||||
}
|
||||
return new
|
||||
}
|
||||
@@ -2953,6 +2959,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
|
||||
// Not gated on `new`: the tally may have been built from responses that arrived before this
|
||||
// poll did, and updatePolicy is idempotent for the usual re-delivery from another relay.
|
||||
getOrCreateNote(event.id).pollState().updatePolicy(PollTallyPolicy.from(event))
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: FileStorageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -3528,11 +3549,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
}
|
||||
}
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
}
|
||||
is PollEvent -> consume(event, relay, wasVerified)
|
||||
|
||||
is ThreadEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
|
||||
+9
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
@@ -89,6 +90,14 @@ fun filterRepliesAndReactionsToNotes(
|
||||
note.relayUrlsForReactions().forEach { relay ->
|
||||
add(relay, note.idHex)
|
||||
}
|
||||
|
||||
// NIP-88 tells respondents to publish their kind-1018 votes to the relays the poll
|
||||
// itself declares, and EventBroadcaster obeys that on the way out. Those relays are
|
||||
// usually not the author's inbox nor where we saw the poll, so without this the
|
||||
// tally silently under-counts — often down to just our own vote.
|
||||
(note.event as? PollEvent)?.relays()?.forEach { relay ->
|
||||
add(relay, note.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodca
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.PollResultsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
|
||||
@@ -616,6 +617,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.PollResults> { PollResultsScreen(it.noteId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Url> { UrlScreen(it, accountViewModel, nav) }
|
||||
|
||||
@@ -573,6 +573,10 @@ sealed class Route {
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class PollResults(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class Hashtag(
|
||||
val hashtag: String,
|
||||
) : Route()
|
||||
|
||||
@@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
@@ -199,12 +201,40 @@ fun InnerRenderPoll(
|
||||
)
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
PollResultsLink(note, nav)
|
||||
}
|
||||
|
||||
if (event.hasHashtags()) {
|
||||
DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The way out of the card. The avatar stack tops out at four faces and the "+N" chip counts people
|
||||
* the card has no room to show — this is the door to the rest of them.
|
||||
*/
|
||||
@Composable
|
||||
private fun PollResultsLink(
|
||||
note: Note,
|
||||
nav: INav,
|
||||
) {
|
||||
val tally by note.pollState().responses.collectAsStateWithLifecycle()
|
||||
val voters = tally.totalVoters()
|
||||
if (voters <= 0) return
|
||||
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.poll_results_vote_count_link, voters, voters),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { nav.nav(Route.PollResults(note.idHex)) }
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class PollCard(
|
||||
val options: List<PollItemCard>,
|
||||
@@ -536,6 +566,14 @@ private fun RenderClosedItem(
|
||||
UserGallery(tally, resultContent)
|
||||
}
|
||||
|
||||
// The percentage alone never said how many people that was.
|
||||
Text(
|
||||
text = tally.users.size.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${(tally.percent * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollOptionResult
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsViewModel
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollVoterRow
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val VoteColumnWidth = 116.dp
|
||||
|
||||
/**
|
||||
* The full results of a NIP-88 poll: how many votes each option got, and who voted for what.
|
||||
*
|
||||
* Everything here reads the tally already hanging off the poll [Note] — the vote counts on the feed
|
||||
* card and the numbers on this screen are the same object, so they can never disagree.
|
||||
*/
|
||||
@Composable
|
||||
fun PollResultsScreen(
|
||||
noteId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadNote(baseNoteHex = noteId, accountViewModel) { note ->
|
||||
if (note == null) {
|
||||
PollResultsScaffold(nav) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_results_loading),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PollResults(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollResults(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
|
||||
// Opening this screen is the opt-in, exactly like the card's "View results" link — so the feed
|
||||
// card stops hiding the tally behind a tap once you have been here.
|
||||
LaunchedEffect(note.idHex) {
|
||||
(note.event as? PollEvent)?.let { accountViewModel.markPollResultsViewed(it.id, it.endsAt()) }
|
||||
}
|
||||
|
||||
val viewModel: PollResultsViewModel =
|
||||
viewModel(
|
||||
key = "PollResults-${note.idHex}",
|
||||
factory =
|
||||
PollResultsViewModel.Factory(
|
||||
pollNote = note,
|
||||
forKey = account.pubKey,
|
||||
isHidden = { account.isHidden(it) },
|
||||
follows = account.allFollows.flow.map { it.authors },
|
||||
hiddenChanges = account.hiddenUsers.flow,
|
||||
),
|
||||
)
|
||||
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selected by viewModel.selectedOption.collectAsStateWithLifecycle()
|
||||
|
||||
PollResultsScaffold(nav) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item("header") {
|
||||
PollHeader(note, state, accountViewModel, nav)
|
||||
}
|
||||
|
||||
item("options") {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
state.options.forEach { option ->
|
||||
key(option.code) {
|
||||
OptionBar(
|
||||
option = option,
|
||||
isSelected = option.code == selected,
|
||||
isMyPick = option.code in state.myVote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick = { viewModel.selectOption(option.code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.options.size > 1) {
|
||||
item("chips") {
|
||||
OptionFilterRow(state, selected, onSelect = viewModel::selectOption)
|
||||
}
|
||||
}
|
||||
|
||||
item("voters-divider") {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
|
||||
items(state.voters, key = { it.user.pubkeyHex }) { voter ->
|
||||
VoterRow(voter, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
item("footer") {
|
||||
ResultsFooter(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollResultsScaffold(
|
||||
nav: INav,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(caption = stringRes(R.string.poll_results_title), nav = nav) },
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Header: who asked, what they asked, and the totals in one glance.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun PollHeader(
|
||||
note: Note,
|
||||
state: PollResultsUiState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? PollEvent
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
note.author?.let { author ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UserPicture(author, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
UsernameDisplay(author, accountViewModel = accountViewModel)
|
||||
TimeAgo(note.createdAt() ?: 0L, TimeAgoStyle.Dotted)
|
||||
}
|
||||
}
|
||||
|
||||
event?.content?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
PollStatusChip(state.endsAt)
|
||||
PollTypeChip(state)
|
||||
}
|
||||
|
||||
TotalsLine(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollStatusChip(endsAt: Long?) {
|
||||
val hasEnded = endsAt != null && endsAt < TimeUtils.now()
|
||||
val tint = if (hasEnded) MaterialTheme.colorScheme.grayText else MaterialTheme.colorScheme.allGoodColor
|
||||
|
||||
Chip(tint) {
|
||||
// A slow breath on the dot: the tally really is live, and this is the only thing on the
|
||||
// screen that says so without adding a control nobody asked for.
|
||||
val alpha =
|
||||
if (hasEnded) {
|
||||
1f
|
||||
} else {
|
||||
val transition = rememberInfiniteTransition(label = "pollLive")
|
||||
transition
|
||||
.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.25f,
|
||||
animationSpec = infiniteRepeatable(tween(1400), RepeatMode.Reverse),
|
||||
label = "pollLiveDot",
|
||||
).value
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.alpha(alpha)
|
||||
.clip(CircleShape)
|
||||
.background(tint),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text =
|
||||
if (endsAt == null) {
|
||||
stringRes(R.string.poll_results_open)
|
||||
} else if (hasEnded) {
|
||||
stringRes(R.string.poll_results_ended, timeAgoNoDot(endsAt, LocalContext.current))
|
||||
} else {
|
||||
// Ahead, not ago: timeAgoNoDot on a future stamp collapses to "now".
|
||||
stringRes(R.string.poll_results_closes_in, timeAheadNoDot(endsAt, LocalContext.current))
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollTypeChip(state: PollResultsUiState) {
|
||||
val gray = MaterialTheme.colorScheme.grayText
|
||||
Chip(gray) {
|
||||
Text(
|
||||
text =
|
||||
stringRes(
|
||||
if (state.type == PollType.MULTI_CHOICE) {
|
||||
R.string.poll_multiple_choice
|
||||
} else {
|
||||
R.string.poll_single_choice
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(
|
||||
tint: Color,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, tint.copy(alpha = 0.4f), CircleShape)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalsLine(state: PollResultsUiState) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = state.totalVoters.toString(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.poll_results_voters, state.totalVoters),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(bottom = 3.dp),
|
||||
)
|
||||
|
||||
// Only worth saying when the two numbers can differ — i.e. multiple choice.
|
||||
if (state.totalSelections != state.totalVoters) {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_results_selections, state.totalSelections),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(bottom = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Option bars.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun OptionBar(
|
||||
option: PollOptionResult,
|
||||
isSelected: Boolean,
|
||||
isMyPick: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val winner = MaterialTheme.colorScheme.allGoodColor
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val barColor = if (option.isWinning) winner else accent
|
||||
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue =
|
||||
when {
|
||||
isSelected -> accent
|
||||
option.isWinning -> winner.copy(alpha = 0.6f)
|
||||
else -> MaterialTheme.colorScheme.grayText.copy(alpha = 0.4f)
|
||||
},
|
||||
label = "pollOptionBorder",
|
||||
)
|
||||
|
||||
// Same 800ms tween the feed card uses, so a vote cast there and read here moves identically.
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = option.percent,
|
||||
animationSpec = tween(durationMillis = 800),
|
||||
label = "pollOptionBar",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(SmallishBorder)
|
||||
.border(if (isSelected) 2.dp else 1.dp, borderColor, SmallishBorder)
|
||||
.background(
|
||||
if (option.isWinning) winner.copy(alpha = 0.12f) else MaterialTheme.colorScheme.subtleBorder,
|
||||
).clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.alpha(0.32f)
|
||||
.drawWithContent {
|
||||
clipRect(right = size.width * progress) { drawRect(barColor) }
|
||||
drawContent()
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = option.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (isMyPick) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.poll_results_your_pick),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.poll_results_option_count, option.voters, option.voters),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(10.dp))
|
||||
AvatarStack(option, accountViewModel, nav)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Text(
|
||||
text = "${(option.percent * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.End,
|
||||
color = if (option.isWinning) winner else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarStack(
|
||||
option: PollOptionResult,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Voter list.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun OptionFilterRow(
|
||||
state: PollResultsUiState,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
label = stringRes(R.string.poll_results_all_options),
|
||||
isSelected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
)
|
||||
state.options.forEach { option ->
|
||||
key(option.code) {
|
||||
FilterChip(
|
||||
label = "${option.label} · ${option.voters}",
|
||||
isSelected = option.code == selected,
|
||||
onClick = { onSelect(option.code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(if (isSelected) accent.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(1.dp, if (isSelected) accent.copy(alpha = 0.5f) else MaterialTheme.colorScheme.grayText.copy(alpha = 0.4f), CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isSelected) accent else MaterialTheme.colorScheme.grayText,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A voter is a user, so this is [UserLine] — the app's own row — with the vote handed to the
|
||||
* trailing slot it already exposes, in place of the follow buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun VoterRow(
|
||||
voter: PollVoterRow,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserLine(
|
||||
baseUser = voter.user,
|
||||
accountViewModel = accountViewModel,
|
||||
trailingContent = { VoteChoice(voter) },
|
||||
onClick = { nav.nav(routeFor(voter.user)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoteChoice(voter: PollVoterRow) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier.width(VoteColumnWidth),
|
||||
) {
|
||||
Text(
|
||||
text = voter.labels.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
TimeAgo(voter.createdAt, TimeAgoStyle.Short)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Footer: what the numbers above do not include.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun ResultsFooter(state: PollResultsUiState) {
|
||||
val notes =
|
||||
remember(state) {
|
||||
buildList {
|
||||
if (state.lateVotes > 0) add(R.plurals.poll_results_late_votes to state.lateVotes)
|
||||
if (state.ignoredVotes > 0) add(R.plurals.poll_results_ignored_votes to state.ignoredVotes)
|
||||
if (state.hiddenVoters > 0) add(R.plurals.poll_results_hidden_voters to state.hiddenVoters)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.totalVoters == 0) {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.poll_results_no_votes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (notes.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
notes.forEach { (res, count) ->
|
||||
Text(
|
||||
text = pluralStringResource(res, count, count),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4256,6 +4256,39 @@
|
||||
<string name="social_proof">Social proof</string>
|
||||
<string name="poll_submit">Submit</string>
|
||||
<string name="poll_view_results">View results</string>
|
||||
<string name="poll_results_title">Poll results</string>
|
||||
<string name="poll_results_loading">Loading poll…</string>
|
||||
<string name="poll_results_open">Open</string>
|
||||
<string name="poll_results_closes_in">Closes in %1$s</string>
|
||||
<string name="poll_results_ended">Ended %1$s ago</string>
|
||||
<string name="poll_results_all_options">All options</string>
|
||||
<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>
|
||||
<plurals name="poll_results_voters">
|
||||
<item quantity="one">voter</item>
|
||||
<item quantity="other">voters</item>
|
||||
</plurals>
|
||||
<plurals name="poll_results_vote_count_link">
|
||||
<item quantity="one">%1$d vote ›</item>
|
||||
<item quantity="other">%1$d votes ›</item>
|
||||
</plurals>
|
||||
<plurals name="poll_results_option_count">
|
||||
<item quantity="one">%1$d voter</item>
|
||||
<item quantity="other">%1$d voters</item>
|
||||
</plurals>
|
||||
<plurals name="poll_results_late_votes">
|
||||
<item quantity="one">%1$d vote arrived after the poll closed and is excluded.</item>
|
||||
<item quantity="other">%1$d votes arrived after the poll closed and are excluded.</item>
|
||||
</plurals>
|
||||
<plurals name="poll_results_ignored_votes">
|
||||
<item quantity="one">%1$d response did not pick a valid option and is excluded.</item>
|
||||
<item quantity="other">%1$d responses did not pick a valid option and are excluded.</item>
|
||||
</plurals>
|
||||
<plurals name="poll_results_hidden_voters">
|
||||
<item quantity="one">%1$d voter is hidden by your mute list.</item>
|
||||
<item quantity="other">%1$d voters are hidden by your mute list.</item>
|
||||
</plurals>
|
||||
<string name="restart">Restart</string>
|
||||
<string name="chess_accept">Accept</string>
|
||||
<string name="chess_decline">Decline</string>
|
||||
|
||||
+86
-24
@@ -43,13 +43,72 @@ class PollResponsesCache : UserDependencies {
|
||||
|
||||
class ResponseTally(
|
||||
val allResponses: List<Note> = emptyList(),
|
||||
/**
|
||||
* The poll's own rules. Null until the kind-1068 event arrives — responses often show up
|
||||
* first — in which case the tally stays permissive rather than dropping votes it cannot
|
||||
* yet validate.
|
||||
*/
|
||||
val policy: PollTallyPolicy? = null,
|
||||
) {
|
||||
val votes: Map<User, PollResponseEvent> = allResponses.latestByAuthor()
|
||||
val tally: Map<String, Set<User>> = votes.votesByOption()
|
||||
/** Responses stamped outside the poll's window, excluded from [votes]. */
|
||||
val lateVotes: Int
|
||||
|
||||
/** Voters whose response cast no valid option code at all, excluded from [tally]. */
|
||||
val ignoredVotes: Int
|
||||
|
||||
/** One response per author — the latest one that is actually eligible. */
|
||||
val votes: Map<User, PollResponseEvent>
|
||||
|
||||
/** Option code -> the voters who picked it. */
|
||||
val tally: Map<String, Set<User>>
|
||||
|
||||
/** Voters whose response cast at least one valid option code. */
|
||||
val countedVoters: Set<User>
|
||||
|
||||
init {
|
||||
var late = 0
|
||||
val eligible =
|
||||
if (policy == null) {
|
||||
allResponses
|
||||
} else {
|
||||
allResponses.filter { note ->
|
||||
val event = note.event
|
||||
val inWindow = event !is PollResponseEvent || policy.isInWindow(event.createdAt)
|
||||
if (!inWindow) late++
|
||||
inWindow
|
||||
}
|
||||
}
|
||||
|
||||
lateVotes = late
|
||||
votes = eligible.latestByAuthor()
|
||||
|
||||
val counted = mutableMapOf<String, MutableSet<User>>()
|
||||
val voters = mutableSetOf<User>()
|
||||
votes.forEach { (user, responseEvent) ->
|
||||
val codes = responseEvent.responses()
|
||||
val accepted = policy?.accept(codes) ?: codes.toSet()
|
||||
if (accepted.isNotEmpty()) {
|
||||
voters.add(user)
|
||||
accepted.forEach { code -> counted.getOrPut(code) { mutableSetOf() }.add(user) }
|
||||
}
|
||||
}
|
||||
|
||||
ignoredVotes = votes.size - voters.size
|
||||
countedVoters = voters
|
||||
tally = counted
|
||||
}
|
||||
|
||||
fun winning() = tally.maxByOrNull { it.value.size }?.key
|
||||
|
||||
fun totalVotes() = tally.entries.sumOf { it.value.size }
|
||||
/**
|
||||
* Distinct people whose vote counts. This is the denominator for every percentage: on a
|
||||
* multiple-choice poll someone who ticks three boxes is still one voter, so the bars read
|
||||
* as "share of people" and may sum past 100%.
|
||||
*/
|
||||
fun totalVoters() = countedVoters.size
|
||||
|
||||
/** Boxes ticked across all voters. Equal to [totalVoters] on a single-choice poll. */
|
||||
fun totalSelections() = tally.entries.sumOf { it.value.size }
|
||||
}
|
||||
|
||||
val responses = MutableStateFlow(ResponseTally())
|
||||
@@ -61,6 +120,7 @@ class PollResponsesCache : UserDependencies {
|
||||
responses.update {
|
||||
ResponseTally(
|
||||
it.allResponses + note,
|
||||
it.policy,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,10 +132,23 @@ class PollResponsesCache : UserDependencies {
|
||||
responses.update {
|
||||
ResponseTally(
|
||||
it.allResponses - deleteNote,
|
||||
it.policy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the tally the poll's rules, recomputing it in place. Idempotent — the common case is
|
||||
* the same poll event arriving again from another relay.
|
||||
*/
|
||||
fun updatePolicy(newPolicy: PollTallyPolicy) {
|
||||
if (responses.value.policy == newPolicy) return
|
||||
|
||||
responses.update {
|
||||
ResponseTally(it.allResponses, newPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
fun ResponseTally.filterTo(
|
||||
code: String,
|
||||
forKey: HexKey,
|
||||
@@ -85,11 +158,14 @@ class PollResponsesCache : UserDependencies {
|
||||
|
||||
val usersThatVotedForThisOption = tally[code] ?: emptyList()
|
||||
|
||||
val votes = totalVotes()
|
||||
val voters = totalVoters()
|
||||
|
||||
// Share of voters, not share of selections: a multiple-choice voter who ticks three boxes
|
||||
// is one person, and a bar that reads "70%" should mean "7 in 10 people". Identical to the
|
||||
// selections basis on a single-choice poll.
|
||||
val percent =
|
||||
if (votes > 0) {
|
||||
usersThatVotedForThisOption.size.toFloat() / votes.toFloat()
|
||||
if (voters > 0) {
|
||||
usersThatVotedForThisOption.size.toFloat() / voters.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
@@ -114,9 +190,11 @@ class PollResponsesCache : UserDependencies {
|
||||
responses.filterTo(code, forKey, priority)
|
||||
}
|
||||
|
||||
fun hasPubKeyVoted(user: User): Boolean = responses.value.votes.containsKey(user)
|
||||
// Counted, not merely present: a response whose option codes the poll doesn't recognise casts
|
||||
// no vote, so its author should still be offered the voting controls.
|
||||
fun hasPubKeyVoted(user: User): Boolean = responses.value.countedVoters.contains(user)
|
||||
|
||||
fun hasPubKeyVotedFlow(user: User): Flow<Boolean> = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged()
|
||||
fun hasPubKeyVotedFlow(user: User): Flow<Boolean> = responses.map { it.countedVoters.contains(user) }.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -125,19 +203,3 @@ class TallyResults(
|
||||
val percent: Float = 0.0f,
|
||||
val isWinning: Boolean = false,
|
||||
)
|
||||
|
||||
fun Map<User, PollResponseEvent>.votesByOption(): Map<String, Set<User>> {
|
||||
val tally = mutableMapOf<String, MutableSet<User>>()
|
||||
|
||||
this.forEach { (user, responseEvent) ->
|
||||
responseEvent.responses().forEach { code ->
|
||||
val currentTally = tally[code]
|
||||
if (currentTally == null) {
|
||||
tally[code] = mutableSetOf(user)
|
||||
} else {
|
||||
currentTally.add(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tally
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.commons.model.nip88Polls
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
|
||||
/**
|
||||
* The rules a [PollResponsesCache] tally must apply, taken from the kind-1068 poll itself.
|
||||
*
|
||||
* A tally cannot be computed correctly from the responses alone: whether a response's second
|
||||
* `response` tag counts, whether an option code exists at all, and whether a vote arrived while
|
||||
* the poll was open are all questions only the poll event can answer. Responses routinely arrive
|
||||
* before the poll they reference, so the tally starts permissive (`policy == null`) and is
|
||||
* recomputed once [PollResponsesCache.updatePolicy] delivers this.
|
||||
*/
|
||||
@Immutable
|
||||
data class PollTallyPolicy(
|
||||
/** Option codes the poll actually declares. Anything else is not a vote. */
|
||||
val validCodes: Set<String>,
|
||||
val type: PollType,
|
||||
/** The poll event's own `created_at`; nothing can be voted before the question exists. */
|
||||
val createdAt: Long,
|
||||
/** NIP-88 `endsAt`, or null for a poll that never closes. */
|
||||
val endsAt: Long?,
|
||||
) {
|
||||
/**
|
||||
* NIP-88: the retained response is "the event with the latest timestamp within poll
|
||||
* timeframes" — so a response stamped after the deadline is not a late vote that wins, it is
|
||||
* not a vote at all.
|
||||
*/
|
||||
fun isInWindow(createdAt: Long): Boolean = createdAt >= this.createdAt && (endsAt == null || createdAt <= endsAt)
|
||||
|
||||
/**
|
||||
* The option codes a response actually casts.
|
||||
*
|
||||
* Single choice: NIP-88 says "the first response tag is to be considered the actual response",
|
||||
* so only the first tag is read — a response that lists every option casts no valid vote rather
|
||||
* than one vote per option.
|
||||
*
|
||||
* Multiple choice: "the first response tag pointing to each unique ID counts", so codes are
|
||||
* de-duplicated, order-independent.
|
||||
*
|
||||
* Codes the poll never declared are dropped in both cases.
|
||||
*/
|
||||
fun accept(codes: List<String>): Set<String> =
|
||||
when (type) {
|
||||
PollType.SINGLE_CHOICE -> codes.firstOrNull()?.takeIf { it in validCodes }?.let { setOf(it) } ?: emptySet()
|
||||
PollType.MULTI_CHOICE -> codes.filterTo(mutableSetOf()) { it in validCodes }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(event: PollEvent) =
|
||||
PollTallyPolicy(
|
||||
validCodes = event.options().mapTo(mutableSetOf()) { it.code },
|
||||
type = event.pollType(),
|
||||
createdAt = event.createdAt,
|
||||
endsAt = event.endsAt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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.commons.viewmodels.nip88Polls
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
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.ResponseTally
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/** One option's share of the poll, ready to draw. */
|
||||
@Immutable
|
||||
class PollOptionResult(
|
||||
val code: String,
|
||||
val label: String,
|
||||
val voters: Int,
|
||||
/** Share of *voters*, so multiple-choice bars can sum past 100%. */
|
||||
val percent: Float,
|
||||
val isWinning: Boolean,
|
||||
/** The first few voters in relevance order, for the avatar stack. */
|
||||
val topVoters: List<User>,
|
||||
)
|
||||
|
||||
/** One person's vote: who they are, what they picked, and when. */
|
||||
@Immutable
|
||||
class PollVoterRow(
|
||||
val user: User,
|
||||
val codes: List<String>,
|
||||
val labels: List<String>,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
class PollResultsUiState(
|
||||
val options: List<PollOptionResult> = emptyList(),
|
||||
val voters: List<PollVoterRow> = emptyList(),
|
||||
val totalVoters: Int = 0,
|
||||
val totalSelections: Int = 0,
|
||||
val ignoredVotes: Int = 0,
|
||||
val lateVotes: Int = 0,
|
||||
val hiddenVoters: Int = 0,
|
||||
val myVote: List<String> = emptyList(),
|
||||
val type: PollType = PollType.SINGLE_CHOICE,
|
||||
val endsAt: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Screen state for the poll results page.
|
||||
*
|
||||
* Reads the live tally off the poll [Note] — no new subscription, no second cache — and turns it
|
||||
* into rows the UI can render without doing set arithmetic in composition. The only interactive
|
||||
* state is [selectedOption], which scopes the voter list without touching the summary above it.
|
||||
*
|
||||
* Muting is applied here rather than in the tally: a muted voter still counts toward the totals
|
||||
* (they did vote, and hiding them from the maths would misreport the poll), they are just not
|
||||
* listed, and [PollResultsUiState.hiddenVoters] says how many.
|
||||
*/
|
||||
@Stable
|
||||
class PollResultsViewModel(
|
||||
private val pollNote: Note,
|
||||
private val forKey: HexKey,
|
||||
private val isHidden: (HexKey) -> Boolean,
|
||||
follows: Flow<Set<HexKey>>,
|
||||
hiddenChanges: Flow<Any?>,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
/** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */
|
||||
const val AVATAR_STACK = 4
|
||||
}
|
||||
|
||||
private val _selectedOption = MutableStateFlow<String?>(null)
|
||||
val selectedOption = _selectedOption.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<PollResultsUiState> =
|
||||
combine(
|
||||
pollNote.pollState().responses,
|
||||
follows,
|
||||
_selectedOption,
|
||||
hiddenChanges,
|
||||
) { tally, followSet, selected, _ ->
|
||||
build(tally, followSet, selected)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = build(pollNote.pollState().responses.value, emptySet(), null),
|
||||
)
|
||||
|
||||
fun selectOption(code: String?) {
|
||||
_selectedOption.value = if (_selectedOption.value == code) null else code
|
||||
}
|
||||
|
||||
private fun build(
|
||||
tally: ResponseTally,
|
||||
follows: Set<HexKey>,
|
||||
selected: String?,
|
||||
): PollResultsUiState {
|
||||
val event = pollNote.event as? PollEvent
|
||||
|
||||
// Poll order, not tally order, so the rows never reshuffle as votes arrive. Falls back to
|
||||
// whatever the responses referenced while the kind-1068 event is still in flight.
|
||||
val options = event?.options().orEmpty()
|
||||
val codesInOrder = if (options.isNotEmpty()) options.map { it.code } else tally.tally.keys.sorted()
|
||||
val labels = options.associate { it.code to it.label }
|
||||
|
||||
val byRelevance =
|
||||
compareByDescending<User> { it.pubkeyHex == forKey }
|
||||
.thenByDescending { it.pubkeyHex in follows }
|
||||
.thenBy { it.pubkeyHex }
|
||||
|
||||
val totalVoters = tally.totalVoters()
|
||||
|
||||
val optionResults =
|
||||
codesInOrder.map { code ->
|
||||
val voters = tally.tally[code].orEmpty()
|
||||
PollOptionResult(
|
||||
code = code,
|
||||
label = labels[code] ?: code,
|
||||
voters = voters.size,
|
||||
percent = if (totalVoters > 0) voters.size.toFloat() / totalVoters else 0f,
|
||||
isWinning = voters.isNotEmpty() && code == tally.winning(),
|
||||
topVoters = voters.sortedWith(byRelevance).take(AVATAR_STACK),
|
||||
)
|
||||
}
|
||||
|
||||
// Invert the tally once: voter -> the codes they picked, in poll order.
|
||||
val picks = mutableMapOf<User, MutableList<String>>()
|
||||
codesInOrder.forEach { code ->
|
||||
tally.tally[code]?.forEach { user ->
|
||||
picks.getOrPut(user) { mutableListOf() }.add(code)
|
||||
}
|
||||
}
|
||||
|
||||
var hidden = 0
|
||||
val rows =
|
||||
picks
|
||||
.mapNotNull { (user, codes) ->
|
||||
if (isHidden(user.pubkeyHex)) {
|
||||
hidden++
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (selected != null && selected !in codes) return@mapNotNull null
|
||||
|
||||
PollVoterRow(
|
||||
user = user,
|
||||
codes = codes,
|
||||
labels = codes.map { labels[it] ?: it },
|
||||
createdAt = tally.votes[user]?.createdAt ?: 0L,
|
||||
)
|
||||
}.sortedWith { a, b -> byRelevance.compare(a.user, b.user) }
|
||||
|
||||
return PollResultsUiState(
|
||||
options = optionResults,
|
||||
voters = rows,
|
||||
totalVoters = totalVoters,
|
||||
totalSelections = tally.totalSelections(),
|
||||
ignoredVotes = tally.ignoredVotes,
|
||||
lateVotes = tally.lateVotes,
|
||||
hiddenVoters = hidden,
|
||||
myVote =
|
||||
picks.entries
|
||||
.firstOrNull { it.key.pubkeyHex == forKey }
|
||||
?.value
|
||||
.orEmpty(),
|
||||
type = event?.pollType() ?: PollType.SINGLE_CHOICE,
|
||||
endsAt = event?.endsAt(),
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val pollNote: Note,
|
||||
private val forKey: HexKey,
|
||||
private val isHidden: (HexKey) -> Boolean,
|
||||
private val follows: Flow<Set<HexKey>>,
|
||||
private val hiddenChanges: Flow<Any?>,
|
||||
) : ViewModelProvider.Factory {
|
||||
// The multiplatform signature — commonMain has no java.lang.Class.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(
|
||||
modelClass: KClass<T>,
|
||||
extras: CreationExtras,
|
||||
): T = PollResultsViewModel(pollNote, forKey, isHidden, follows, hiddenChanges) as T
|
||||
}
|
||||
}
|
||||
+173
-2
@@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.commons.model.nip88Polls
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PollResponsesCacheTest {
|
||||
@@ -76,7 +78,7 @@ class PollResponsesCacheTest {
|
||||
val tally = cache.responses.value
|
||||
|
||||
// Exactly one vote counted for this user.
|
||||
assertEquals(1, tally.totalVotes())
|
||||
assertEquals(1, tally.totalVoters())
|
||||
// The winning option is the newer one.
|
||||
assertEquals("no", tally.winning())
|
||||
// Old option carries no voters.
|
||||
@@ -140,6 +142,175 @@ class PollResponsesCacheTest {
|
||||
cache.addResponse(note)
|
||||
cache.addResponse(note) // relay echo of the same note must not double-count
|
||||
|
||||
assertEquals(1, cache.responses.value.totalVotes())
|
||||
assertEquals(1, cache.responses.value.totalVoters())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Poll-aware tally: everything below needs the kind-1068 rules, which ResponseTally only has
|
||||
// once updatePolicy has run.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** Builds a response casting several codes at once, to exercise the per-type accept rules. */
|
||||
private fun multiResponseNote(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
options: List<String>,
|
||||
createdAt: Long,
|
||||
): Note {
|
||||
val event =
|
||||
PollResponseEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("e", pollId)) + options.map { arrayOf("response", it) },
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
val note = Note(id)
|
||||
note.loadEvent(event, user(pubKey), emptyList())
|
||||
return note
|
||||
}
|
||||
|
||||
private fun policy(
|
||||
type: PollType,
|
||||
codes: Set<String> = setOf("yes", "no"),
|
||||
createdAt: Long = 0,
|
||||
endsAt: Long? = null,
|
||||
) = PollTallyPolicy(validCodes = codes, type = type, createdAt = createdAt, endsAt = endsAt)
|
||||
|
||||
@Test
|
||||
fun singleChoiceCountsOnlyTheFirstResponseTag() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
// A client that emits one response tag per option must not land in every bucket.
|
||||
cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no"), createdAt = 10))
|
||||
|
||||
val tally = cache.responses.value
|
||||
assertEquals(1, tally.totalVoters())
|
||||
assertEquals(1, tally.totalSelections())
|
||||
assertEquals(1, tally.tally["yes"]?.size)
|
||||
assertTrue(tally.tally["no"].isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleChoiceRejectsWhenTheFirstTagIsNotAnOption() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
// NIP-88 says the first tag *is* the response — so an invalid first tag is an invalid vote,
|
||||
// not an invitation to look for a valid one further down.
|
||||
cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("bogus", "yes"), createdAt = 10))
|
||||
|
||||
val tally = cache.responses.value
|
||||
assertEquals(0, tally.totalVoters())
|
||||
assertEquals(1, tally.ignoredVotes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiChoiceCountsEveryValidCodeOncePerVoter() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.MULTI_CHOICE))
|
||||
|
||||
cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no", "yes"), createdAt = 10))
|
||||
|
||||
val tally = cache.responses.value
|
||||
// One person, two selections: the duplicate "yes" collapses.
|
||||
assertEquals(1, tally.totalVoters())
|
||||
assertEquals(2, tally.totalSelections())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiChoicePercentagesAreShareOfVotersNotSelections() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.MULTI_CHOICE))
|
||||
|
||||
// Two voters; one picks both options, the other picks only "yes".
|
||||
cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no"), createdAt = 10))
|
||||
cache.addResponse(multiResponseNote("2".repeat(64), "c".repeat(64), listOf("yes"), createdAt = 10))
|
||||
|
||||
val tally = cache.responses.value
|
||||
assertEquals(2, tally.totalVoters())
|
||||
assertEquals(3, tally.totalSelections())
|
||||
|
||||
val forKey = "0".repeat(64)
|
||||
// 2 of 2 people want "yes", 1 of 2 want "no" — bars sum past 100% on purpose.
|
||||
assertEquals(1.0f, cache.currentTally("yes", forKey, emptySet()).percent)
|
||||
assertEquals(0.5f, cache.currentTally("no", forKey, emptySet()).percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun votesOutsideThePollWindowAreExcluded() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE, createdAt = 100, endsAt = 200))
|
||||
|
||||
val voter = "b".repeat(64)
|
||||
cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 150))
|
||||
// Same voter again after the deadline: a later timestamp must not overwrite a valid vote.
|
||||
cache.addResponse(responseNote("2".repeat(64), voter, option = "no", createdAt = 500))
|
||||
// And a vote from before the poll existed is not a vote either.
|
||||
cache.addResponse(responseNote("3".repeat(64), "c".repeat(64), option = "no", createdAt = 50))
|
||||
|
||||
val tally = cache.responses.value
|
||||
assertEquals(1, tally.totalVoters())
|
||||
assertEquals("yes", tally.winning())
|
||||
assertEquals(2, tally.lateVotes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownOptionCodesDoNotDragDownPercentages() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10))
|
||||
cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "spam", createdAt = 10))
|
||||
|
||||
val tally = cache.responses.value
|
||||
assertEquals(1, tally.totalVoters())
|
||||
assertEquals(1, tally.ignoredVotes)
|
||||
assertTrue(tally.tally["spam"].isNullOrEmpty())
|
||||
// The one real vote is 100% of the poll, not 50%.
|
||||
assertEquals(1.0f, cache.currentTally("yes", "0".repeat(64), emptySet()).percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun policyArrivingAfterResponsesRecomputesTheTally() {
|
||||
val cache = PollResponsesCache()
|
||||
|
||||
// Responses regularly beat their poll to the client. Until the poll lands the tally is
|
||||
// permissive, so the bogus code counts.
|
||||
cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10))
|
||||
cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "spam", createdAt = 10))
|
||||
assertEquals(2, cache.responses.value.totalVoters())
|
||||
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
assertEquals(1, cache.responses.value.totalVoters())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updatePolicyIsIdempotent() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10))
|
||||
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
val first = cache.responses.value
|
||||
// Same poll re-delivered by another relay must not churn the tally identity.
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
assertSame(first, cache.responses.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voterWhoseVoteWasIgnoredCanStillVote() {
|
||||
val cache = PollResponsesCache()
|
||||
cache.updatePolicy(policy(PollType.SINGLE_CHOICE))
|
||||
|
||||
val voter = "b".repeat(64)
|
||||
cache.addResponse(responseNote("1".repeat(64), voter, option = "spam", createdAt = 10))
|
||||
|
||||
// They responded, but cast nothing countable — the card should still offer them the
|
||||
// controls rather than showing results for a vote that does not exist.
|
||||
assertFalse(cache.hasPubKeyVoted(user(voter)))
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy
|
||||
import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
@@ -487,6 +488,9 @@ class DesktopLocalCache : ICacheProvider {
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
// Not gated on the early return below: the tally may already hold responses that arrived
|
||||
// before this poll did, and updatePolicy is idempotent for re-delivery from another relay.
|
||||
note.pollState().updatePolicy(PollTallyPolicy.from(event))
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
@@ -510,6 +514,7 @@ class DesktopLocalCache : ICacheProvider {
|
||||
): Boolean {
|
||||
val pollId = event.poll()?.eventId ?: return false
|
||||
val pollNote = getOrCreateNote(pollId)
|
||||
(pollNote.event as? PollEvent)?.let { pollNote.pollState().updatePolicy(PollTallyPolicy.from(it)) }
|
||||
val responseNote = getOrCreateNote(event.id)
|
||||
if (responseNote.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
Vendored
+2
-2
@@ -85,7 +85,7 @@ class DesktopLocalCachePollTest {
|
||||
val pollNote = cache.getNoteIfExists(poll.id)
|
||||
assertTrue(pollNote != null, "poll note must exist")
|
||||
val tally = pollNote.pollState().responses.value
|
||||
assertEquals(1, tally.totalVotes())
|
||||
assertEquals(1, tally.totalVoters())
|
||||
assertEquals("0", tally.winning())
|
||||
}
|
||||
|
||||
@@ -108,6 +108,6 @@ class DesktopLocalCachePollTest {
|
||||
.getNoteIfExists(poll.id)!!
|
||||
.pollState()
|
||||
.responses.value
|
||||
assertEquals(1, tally.totalVotes())
|
||||
assertEquals(1, tally.totalVoters())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user