From 3a78dd6afe0ab8d1f2a2db4347ead3cddfb398e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 02:22:18 +0000 Subject: [PATCH 1/2] feat: add "View results" option to polls that prevents voting after viewing Adds a "View results" link below poll voting options. Once a user clicks it, the poll results are shown and the user can no longer vote on that poll. The viewed state is persisted in account settings via SharedPreferences. https://claude.ai/code/session_01EkUYT4giQPUvbAJZ54o1se --- .../amethyst/LocalPreferences.kt | 4 +++ .../vitorpamplona/amethyst/model/Account.kt | 4 +++ .../amethyst/model/AccountSettings.kt | 16 +++++++++ .../amethyst/ui/note/types/Poll.kt | 35 +++++++++++++++---- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +++ amethyst/src/main/res/values/strings.xml | 1 + 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 59a852696..51a2719e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -131,6 +131,7 @@ private object PrefKeys { const val SIGNER_PACKAGE_NAME = "signer_package_name" const val HAS_DONATED_IN_VERSION = "has_donated_in_version" const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids" + const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids" const val PENDING_ATTESTATIONS = "pending_attestations" const val ALL_ACCOUNT_INFO = "all_saved_accounts_info" @@ -391,6 +392,7 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) + putStringSet(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, settings.viewedPollResultNoteIds.value) putString( PrefKeys.PENDING_ATTESTATIONS, @@ -476,6 +478,7 @@ object LocalPreferences { val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() + val viewedPollResultNoteIds = getStringSet(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) ?: setOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) @@ -598,6 +601,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), + viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d05c96312..c913f3de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2152,6 +2152,10 @@ class Account( fun dismissPollNotification(noteId: String) = settings.dismissPollNotification(noteId) + fun hasViewedPollResults(noteId: String) = settings.hasViewedPollResults(noteId) + + fun markPollResultsViewed(noteId: String) = settings.markPollResultsViewed(noteId) + init { Log.d("AccountRegisterObservers", "Init") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7946ee644..fdc6b5520 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -194,6 +194,7 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), + val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, ) : EphemeralChatRepository, @@ -695,6 +696,21 @@ class AccountSettings( } } + // --- + // viewed poll results + // --- + + fun hasViewedPollResults(noteId: String) = viewedPollResultNoteIds.value.contains(noteId) + + fun markPollResultsViewed(noteId: String) { + if (!viewedPollResultNoteIds.value.contains(noteId)) { + viewedPollResultNoteIds.update { + it + noteId + } + saveAccountSettings() + } + } + // ---- // last read flows // ---- 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 5767a90dc..049ef36e0 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 @@ -265,11 +265,14 @@ fun RenderPollCard( RenderPollCard( card = card, + noteId = event.id, onRespond = { responses -> accountViewModel.launchSigner { accountViewModel.account.pollRespond(event, responses) } }, + onViewResults = { accountViewModel.markPollResultsViewed(event.id) }, + hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) }, resultContent = galleryUser, labelContent = labelContent, ) @@ -278,7 +281,10 @@ fun RenderPollCard( @Composable fun RenderPollCard( card: PollCard, + noteId: String = "", onRespond: (Set) -> Unit, + onViewResults: () -> Unit = {}, + hasViewedResults: () -> Boolean = { false }, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { @@ -296,12 +302,29 @@ fun RenderPollCard( val haveIVoted by card.haveIVotedFlow.collectAsStateWithLifecycle(haveIVoted) if (haveIVoted) { RenderResults(card, resultContent, labelContent) - } else if (card.hasEnded()) { + } else if (card.hasEnded() || hasViewedResults()) { RenderResults(card, resultContent, labelContent) } else { - when (card.type) { - PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) - PollType.MULTI_CHOICE -> RenderMultiChoiceOptions(card, labelContent, onRespond) + var viewingResults by remember { mutableStateOf(false) } + if (viewingResults) { + RenderResults(card, resultContent, labelContent) + } else { + when (card.type) { + PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) + PollType.MULTI_CHOICE -> RenderMultiChoiceOptions(card, labelContent, onRespond) + } + + Text( + text = stringRes(R.string.poll_view_results), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clickable { + onViewResults() + viewingResults = true + }.padding(vertical = 4.dp), + ) } } } @@ -615,7 +638,7 @@ fun RenderPollManualPreview() { ThemeComparisonColumn { Column(Modifier.padding(10.dp)) { - RenderPollCard(poll, {}, {}) { _, label -> + RenderPollCard(poll, onRespond = {}, resultContent = {}) { _, label -> Text( text = label, ) @@ -663,7 +686,7 @@ fun RenderPollManualLongPreview() { ThemeComparisonColumn { Column(Modifier.padding(10.dp)) { - RenderPollCard(poll, {}, {}) { _, label -> + RenderPollCard(poll, onRespond = {}, resultContent = {}) { _, label -> Text( text = label, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index bf3ba6f9d..00043b7f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1128,6 +1128,10 @@ class AccountViewModel( fun dismissPollNotification(noteId: String) = account.dismissPollNotification(noteId) + fun hasViewedPollResults(noteId: String) = account.hasViewedPollResults(noteId) + + fun markPollResultsViewed(noteId: String) = account.markPollResultsViewed(noteId) + fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom.value fun translateTo() = account.settings.syncedSettings.languages.translateTo.value diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 261c5f8a1..ea2c30d38 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1789,6 +1789,7 @@ Connected Social proof Submit + View results Restart Accept Decline From c79ef634de33ef3dd97f56d3cf41bc22b2d527cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:38:08 +0000 Subject: [PATCH 2/2] fix: store expiration timestamps in viewedPollResultNoteIds to prevent unbounded growth Changes viewedPollResultNoteIds from Set to Map where the value is the expiration timestamp. Uses the poll's endsAt date if set and in the future, otherwise now + 24 hours. Expired entries are pruned on each new insertion. Serialized as JSON instead of StringSet. https://claude.ai/code/session_01EkUYT4giQPUvbAJZ54o1se --- .../amethyst/LocalPreferences.kt | 10 +++++-- .../vitorpamplona/amethyst/model/Account.kt | 5 +++- .../amethyst/model/AccountSettings.kt | 28 +++++++++++++++---- .../amethyst/ui/note/types/Poll.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 5 +++- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 51a2719e4..c8dcdfbee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -392,7 +392,10 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) - putStringSet(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, settings.viewedPollResultNoteIds.value) + putString( + PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, + JsonMapper.toJson(settings.viewedPollResultNoteIds.value), + ) putString( PrefKeys.PENDING_ATTESTATIONS, @@ -478,7 +481,7 @@ object LocalPreferences { val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() - val viewedPollResultNoteIds = getStringSet(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) ?: setOf() + val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) @@ -531,6 +534,7 @@ object LocalPreferences { val zapPaymentRequestServer = async { parseOrNull(zapPaymentRequestServerStr) } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } + val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } val pendingAttestations = async { parseOrNull>(pendingAttestationsStr) ?: mapOf() } val latestUserMetadata = async { parseEventOrNull(latestUserMetadataStr) } val latestContactList = async { parseEventOrNull(latestContactListStr) } @@ -601,7 +605,7 @@ object LocalPreferences { lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), - viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds), + viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c913f3de1..2349a7307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2154,7 +2154,10 @@ class Account( fun hasViewedPollResults(noteId: String) = settings.hasViewedPollResults(noteId) - fun markPollResultsViewed(noteId: String) = settings.markPollResultsViewed(noteId) + fun markPollResultsViewed( + noteId: String, + pollEndsAt: Long?, + ) = settings.markPollResultsViewed(noteId, pollEndsAt) init { Log.d("AccountRegisterObservers", "Init") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index fdc6b5520..ef5422280 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -194,7 +195,7 @@ class AccountSettings( val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), - val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(setOf()), + val viewedPollResultNoteIds: MutableStateFlow> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, ) : EphemeralChatRepository, @@ -700,17 +701,34 @@ class AccountSettings( // viewed poll results // --- - fun hasViewedPollResults(noteId: String) = viewedPollResultNoteIds.value.contains(noteId) + fun hasViewedPollResults(noteId: String): Boolean { + val expiresAt = viewedPollResultNoteIds.value[noteId] ?: return false + return expiresAt > TimeUtils.now() + } - fun markPollResultsViewed(noteId: String) { - if (!viewedPollResultNoteIds.value.contains(noteId)) { + fun markPollResultsViewed( + noteId: String, + pollEndsAt: Long?, + ) { + if (noteId !in viewedPollResultNoteIds.value) { + val expiresAt = + if (pollEndsAt != null && pollEndsAt > TimeUtils.now()) { + pollEndsAt + } else { + TimeUtils.now() + TimeUtils.ONE_DAY + } viewedPollResultNoteIds.update { - it + noteId + pruneExpiredViews(it) + (noteId to expiresAt) } saveAccountSettings() } } + private fun pruneExpiredViews(views: Map): Map { + val now = TimeUtils.now() + return views.filterValues { it > now } + } + // ---- // last read flows // ---- 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 049ef36e0..8edda46dd 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 @@ -271,7 +271,7 @@ fun RenderPollCard( accountViewModel.account.pollRespond(event, responses) } }, - onViewResults = { accountViewModel.markPollResultsViewed(event.id) }, + onViewResults = { accountViewModel.markPollResultsViewed(event.id, event.endsAt()) }, hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) }, resultContent = galleryUser, labelContent = labelContent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 00043b7f1..801fc8a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1130,7 +1130,10 @@ class AccountViewModel( fun hasViewedPollResults(noteId: String) = account.hasViewedPollResults(noteId) - fun markPollResultsViewed(noteId: String) = account.markPollResultsViewed(noteId) + fun markPollResultsViewed( + noteId: String, + pollEndsAt: Long?, + ) = account.markPollResultsViewed(noteId, pollEndsAt) fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom.value