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 51a2719e46..c8dcdfbeee 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 c913f3de11..2349a7307d 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 fdc6b5520d..ef54222805 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 049ef36e07..8edda46dd0 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 00043b7f1e..801fc8a704 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