fix: store expiration timestamps in viewedPollResultNoteIds to prevent unbounded growth

Changes viewedPollResultNoteIds from Set<String> to Map<String, Long> 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
This commit is contained in:
Claude
2026-04-05 15:38:08 +00:00
parent 3a78dd6afe
commit c79ef634de
5 changed files with 39 additions and 11 deletions
@@ -392,7 +392,10 @@ object LocalPreferences {
) )
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.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( putString(
PrefKeys.PENDING_ATTESTATIONS, PrefKeys.PENDING_ATTESTATIONS,
@@ -478,7 +481,7 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, 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 localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null)
@@ -531,6 +534,7 @@ object LocalPreferences {
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) } val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() }
val pendingAttestations = async { parseOrNull<Map<HexKey, String>>(pendingAttestationsStr) ?: mapOf() } val pendingAttestations = async { parseOrNull<Map<HexKey, String>>(pendingAttestationsStr) ?: mapOf() }
val latestUserMetadata = async { parseEventOrNull<MetadataEvent>(latestUserMetadataStr) } val latestUserMetadata = async { parseEventOrNull<MetadataEvent>(latestUserMetadataStr) }
val latestContactList = async { parseEventOrNull<ContactListEvent>(latestContactListStr) } val latestContactList = async { parseEventOrNull<ContactListEvent>(latestContactListStr) }
@@ -601,7 +605,7 @@ object LocalPreferences {
lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds), dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds), viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
pendingAttestations = MutableStateFlow(pendingAttestations.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(), backupNipA3PaymentTargets = latestPaymentTargets.await(),
) )
@@ -2154,7 +2154,10 @@ class Account(
fun hasViewedPollResults(noteId: String) = settings.hasViewedPollResults(noteId) 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 { init {
Log.d("AccountRegisterObservers", "Init") Log.d("AccountRegisterObservers", "Init")
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -194,7 +195,7 @@ class AccountSettings(
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()), val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()), val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()), val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()), val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()), val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
) : EphemeralChatRepository, ) : EphemeralChatRepository,
@@ -700,17 +701,34 @@ class AccountSettings(
// viewed poll results // 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) { fun markPollResultsViewed(
if (!viewedPollResultNoteIds.value.contains(noteId)) { 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 { viewedPollResultNoteIds.update {
it + noteId pruneExpiredViews(it) + (noteId to expiresAt)
} }
saveAccountSettings() saveAccountSettings()
} }
} }
private fun pruneExpiredViews(views: Map<String, Long>): Map<String, Long> {
val now = TimeUtils.now()
return views.filterValues { it > now }
}
// ---- // ----
// last read flows // last read flows
// ---- // ----
@@ -271,7 +271,7 @@ fun RenderPollCard(
accountViewModel.account.pollRespond(event, responses) accountViewModel.account.pollRespond(event, responses)
} }
}, },
onViewResults = { accountViewModel.markPollResultsViewed(event.id) }, onViewResults = { accountViewModel.markPollResultsViewed(event.id, event.endsAt()) },
hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) }, hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) },
resultContent = galleryUser, resultContent = galleryUser,
labelContent = labelContent, labelContent = labelContent,
@@ -1130,7 +1130,10 @@ class AccountViewModel(
fun hasViewedPollResults(noteId: String) = account.hasViewedPollResults(noteId) 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 fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom.value