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:
Claude
2026-08-03 15:52:37 +00:00
parent 1bd39335b2
commit 846eeeb904
13 changed files with 1320 additions and 33 deletions
@@ -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 {
@@ -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,
@@ -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,
)
}
}
}
}
+33
View File
@@ -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>
@@ -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
}
@@ -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(),
)
}
}
@@ -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
}
}
@@ -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)))
}
}
@@ -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)
@@ -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())
}
}