From 638bad36724b045fdac9c0b1e8103bf8487fe0e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 20:24:41 +0000 Subject: [PATCH 1/3] feat: show open polls at top of notification screen Adds an "Open Polls" section at the top of the notification feed that displays the user's PollEvent (NIP-88) and ZapPollEvent (kind 6969) that are still active. Polls without a closing time are assumed to expire 1 day after creation. https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q --- .../loggedIn/notifications/CardFeedView.kt | 29 +++++ .../notifications/OpenPollsSection.kt | 105 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 135 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index dfe09eb44..54f7189a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -165,6 +165,7 @@ private fun FeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val openPolls by openPollsState(accountViewModel) LazyColumn( modifier = Modifier.fillMaxSize(), @@ -175,6 +176,34 @@ private fun FeedLoaded( ShowDonationCard(accountViewModel, nav) } + if (openPolls.isNotEmpty()) { + item { + OpenPollsSectionHeader() + } + + itemsIndexed( + items = openPolls, + key = { _, item -> "open-poll-${item.idHex}" }, + contentType = { _, _ -> "OpenPoll" }, + ) { _, note -> + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + baseNote = note, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = routeForLastRead, + isBoostedNote = false, + isQuotedNote = false, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + itemsIndexed( items = items.list, key = { _, item -> item.id() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt new file mode 100644 index 000000000..619ceae07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt @@ -0,0 +1,105 @@ +/* + * 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.notifications + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun openPollsState(accountViewModel: AccountViewModel) = + produceState(initialValue = emptyList(), accountViewModel) { + value = + withContext(Dispatchers.IO) { + findOpenPolls(accountViewModel) + } + } + +private fun findOpenPolls(accountViewModel: AccountViewModel): List { + val userPubkeyHex = accountViewModel.account.userProfile().pubkeyHex + val now = TimeUtils.now() + val oneDayInSeconds = TimeUtils.ONE_DAY + + val openPolls = mutableListOf() + + LocalCache.notes.forEach { _, note -> + val event = note.event ?: return@forEach + + when (event) { + is PollEvent -> { + if (event.pubKey == userPubkeyHex) { + val endsAt = event.endsAt() + val isOpen = + if (endsAt != null) { + endsAt > now + } else { + event.createdAt + oneDayInSeconds > now + } + if (isOpen) { + openPolls.add(note) + } + } + } + + is ZapPollEvent -> { + if (event.pubKey == userPubkeyHex) { + val closedAt = event.closedAt() + val isOpen = + if (closedAt != null) { + closedAt > now + } else { + event.createdAt + oneDayInSeconds > now + } + if (isOpen) { + openPolls.add(note) + } + } + } + } + } + + return openPolls.sortedByDescending { it.createdAt() } +} + +@Composable +fun OpenPollsSectionHeader() { + Text( + text = stringRes(R.string.open_polls), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 136d0096d..071d36ad1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -258,6 +258,7 @@ "Error loading replies: " Try again No notifications yet. + Open Polls Feed is empty. Refresh created From b6e54db81b126b95e4776fede5aca4050b3df3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 21:38:48 +0000 Subject: [PATCH 2/3] refactor: use LocalCache.observeNotes flow for open polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace LocalCache.notes.forEach iteration with observeNotes using a Filter for PollEvent.KIND and ZapPollEvent.KIND by the current user. This makes the open polls list reactive — it updates automatically when new polls arrive instead of only computing once on composition. https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q --- .../notifications/OpenPollsSection.kt | 71 ++++++++----------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt index 619ceae07..39223b078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt @@ -24,74 +24,61 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map @Composable fun openPollsState(accountViewModel: AccountViewModel) = - produceState(initialValue = emptyList(), accountViewModel) { - value = - withContext(Dispatchers.IO) { - findOpenPolls(accountViewModel) - } - } + remember(accountViewModel) { + val userPubkeyHex = accountViewModel.account.userProfile().pubkeyHex -private fun findOpenPolls(accountViewModel: AccountViewModel): List { - val userPubkeyHex = accountViewModel.account.userProfile().pubkeyHex + val filter = + Filter( + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + authors = listOf(userPubkeyHex), + ) + + accountViewModel.account.cache + .observeNotes(filter) + .map { notes -> filterOpenPolls(notes) } + .flowOn(Dispatchers.IO) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + +private fun filterOpenPolls(notes: List): List { val now = TimeUtils.now() val oneDayInSeconds = TimeUtils.ONE_DAY - val openPolls = mutableListOf() - - LocalCache.notes.forEach { _, note -> - val event = note.event ?: return@forEach + return notes.filter { note -> + val event = note.event ?: return@filter false when (event) { is PollEvent -> { - if (event.pubKey == userPubkeyHex) { - val endsAt = event.endsAt() - val isOpen = - if (endsAt != null) { - endsAt > now - } else { - event.createdAt + oneDayInSeconds > now - } - if (isOpen) { - openPolls.add(note) - } - } + val endsAt = event.endsAt() + if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now } is ZapPollEvent -> { - if (event.pubKey == userPubkeyHex) { - val closedAt = event.closedAt() - val isOpen = - if (closedAt != null) { - closedAt > now - } else { - event.createdAt + oneDayInSeconds > now - } - if (isOpen) { - openPolls.add(note) - } - } + val closedAt = event.closedAt() + if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now } - } - } - return openPolls.sortedByDescending { it.createdAt() } + else -> false + } + }.sortedByDescending { it.createdAt() } } @Composable From 98cda3ba71206c81cfc652067b09b6bb7f4d08d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 21:41:13 +0000 Subject: [PATCH 3/3] style: apply spotless formatting to OpenPollsSection https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q --- .../notifications/OpenPollsSection.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt index 39223b078..ec1952309 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt @@ -62,23 +62,26 @@ private fun filterOpenPolls(notes: List): List { val now = TimeUtils.now() val oneDayInSeconds = TimeUtils.ONE_DAY - return notes.filter { note -> - val event = note.event ?: return@filter false + return notes + .filter { note -> + val event = note.event ?: return@filter false - when (event) { - is PollEvent -> { - val endsAt = event.endsAt() - if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now + when (event) { + is PollEvent -> { + val endsAt = event.endsAt() + if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now + } + + is ZapPollEvent -> { + val closedAt = event.closedAt() + if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now + } + + else -> { + false + } } - - is ZapPollEvent -> { - val closedAt = event.closedAt() - if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now - } - - else -> false - } - }.sortedByDescending { it.createdAt() } + }.sortedByDescending { it.createdAt() } } @Composable