diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7ffa080688..322e793196 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 64510793f8..dda13a51ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -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) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1b10a93069..9e5249d0c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ShareNoteAsQrScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } + composableFromEndArgs { PollResultsScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { UrlScreen(it, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 399db4dc84..5d18946dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 0bcc0df398..4697b203f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -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, @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt new file mode 100644 index 0000000000..02512e05ce --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..54644f7698 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4256,6 +4256,39 @@ Social proof Submit View results + Poll results + Loading poll… + Open + Closes in %1$s + Ended %1$s ago + All options + Your vote + · %1$d selections + No votes yet + + voter + voters + + + %1$d vote › + %1$d votes › + + + %1$d voter + %1$d voters + + + %1$d vote arrived after the poll closed and is excluded. + %1$d votes arrived after the poll closed and are excluded. + + + %1$d response did not pick a valid option and is excluded. + %1$d responses did not pick a valid option and are excluded. + + + %1$d voter is hidden by your mute list. + %1$d voters are hidden by your mute list. + Restart Accept Decline diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt index 95bfb6a3ad..59d638f321 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -43,13 +43,72 @@ class PollResponsesCache : UserDependencies { class ResponseTally( val allResponses: List = 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 = allResponses.latestByAuthor() - val tally: Map> = 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 + + /** Option code -> the voters who picked it. */ + val tally: Map> + + /** Voters whose response cast at least one valid option code. */ + val countedVoters: Set + + 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>() + val voters = mutableSetOf() + 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 = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged() + fun hasPubKeyVotedFlow(user: User): Flow = 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.votesByOption(): Map> { - val tally = mutableMapOf>() - - 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 -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt new file mode 100644 index 0000000000..87851187b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt @@ -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, + 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): Set = + 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(), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt new file mode 100644 index 0000000000..4a41adef91 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -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, +) + +/** One person's vote: who they are, what they picked, and when. */ +@Immutable +class PollVoterRow( + val user: User, + val codes: List, + val labels: List, + val createdAt: Long, +) + +@Immutable +class PollResultsUiState( + val options: List = emptyList(), + val voters: List = emptyList(), + val totalVoters: Int = 0, + val totalSelections: Int = 0, + val ignoredVotes: Int = 0, + val lateVotes: Int = 0, + val hiddenVoters: Int = 0, + val myVote: List = 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>, + hiddenChanges: Flow, +) : ViewModel() { + companion object { + /** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */ + const val AVATAR_STACK = 4 + } + + private val _selectedOption = MutableStateFlow(null) + val selectedOption = _selectedOption.asStateFlow() + + val uiState: StateFlow = + 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, + 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 { 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>() + 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>, + private val hiddenChanges: Flow, + ) : ViewModelProvider.Factory { + // The multiplatform signature — commonMain has no java.lang.Class. + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T = PollResultsViewModel(pollNote, forKey, isHidden, follows, hiddenChanges) as T + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt index 9db5dce702..101b2db4f4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt @@ -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, + 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 = 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))) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index af026c5746..4bee88c1a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt index 88c32fb660..a5b55291df 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt @@ -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()) } }