From c79ef634de33ef3dd97f56d3cf41bc22b2d527cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:38:08 +0000 Subject: [PATCH] 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