feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet

Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.

Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
  interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
  seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
  poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
  so the full tally loads regardless of the viewer's relay set. Votes are
  likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
  its voters, footer shows distinct-voter count + deadline/ended state, and the
  voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
  the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
  a question and >=2 options.

Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
  subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.

Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
  NIP-50 kind filter); SearchResultsList renders poll results interactively and
  SearchScreen fetches their responses.

Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
  ConcurrentModificationException (synchronized(pending)) so relay-health
  reclassify no longer crashes the UI during search.

Ripple/shaping: clickable elements clip to their shape for bounded ripple.

Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.

Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-22 11:09:25 +03:00
co-authored by Claude Opus 4.8
parent 72b8df7f6d
commit b2adb157ff
17 changed files with 1754 additions and 252 deletions
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
data class ContentPreset(
@@ -63,6 +64,7 @@ object KindRegistry {
"wiki" to listOf(WikiNoteEvent.KIND),
"classified" to listOf(ClassifiedsEvent.KIND),
"highlight" to listOf(HighlightEvent.KIND),
"poll" to listOf(PollEvent.KIND),
)
val pseudoKinds: Set<String> = setOf("reply", "media")
@@ -75,6 +77,7 @@ object KindRegistry {
"Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)),
"Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)),
"Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)),
"Polls" to ContentPreset(kinds = listOf(PollEvent.KIND)),
)
fun resolve(alias: String): List<Int>? = aliases[alias.lowercase()]
@@ -0,0 +1,145 @@
/*
* 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.commons.model.nip88Polls
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PollResponsesCacheTest {
private val pollId = "a".repeat(64)
// The tally keys votes by `User` identity, and the real cache returns one `User`
// instance per pubkey (getOrCreateUser). Mirror that here so same-pubkey re-votes
// and hasPubKeyVoted() lookups resolve to the same object.
private val userCache = mutableMapOf<HexKey, User>()
private fun user(pubKey: HexKey): User = userCache.getOrPut(pubKey) { User(pubKey) { addr -> Note(addr.toValue()) } }
/** Builds a kind-1018 response Note authored by [pubKey] choosing [option] at [createdAt]. */
private fun responseNote(
id: HexKey,
pubKey: HexKey,
option: String,
createdAt: Long,
): Note {
val event =
PollResponseEvent(
id = id,
pubKey = pubKey,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("e", pollId),
arrayOf("response", option),
),
content = "",
sig = "0".repeat(128),
)
val note = Note(id)
note.loadEvent(event, user(pubKey), emptyList())
return note
}
@Test
fun latestVoteWinsDedup() {
val cache = PollResponsesCache()
val voter = "b".repeat(64)
// Same voter votes twice; the later-timestamp response must win.
cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 100))
cache.addResponse(responseNote("2".repeat(64), voter, option = "no", createdAt = 200))
val tally = cache.responses.value
// Exactly one vote counted for this user.
assertEquals(1, tally.totalVotes())
// The winning option is the newer one.
assertEquals("no", tally.winning())
// Old option carries no voters.
assertTrue(tally.tally["yes"].isNullOrEmpty())
}
@Test
fun tallyPercentReflectsVoteShare() {
val cache = PollResponsesCache()
val forKey = "0".repeat(64)
cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10))
cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "yes", createdAt = 10))
cache.addResponse(responseNote("3".repeat(64), "d".repeat(64), option = "no", createdAt = 10))
val yes = cache.currentTally("yes", forKey, emptySet())
val no = cache.currentTally("no", forKey, emptySet())
assertEquals(2f / 3f, yes.percent)
assertEquals(1f / 3f, no.percent)
assertTrue(yes.isWinning)
assertFalse(no.isWinning)
}
@Test
fun wotPrioritySortOrdersUsers() {
val cache = PollResponsesCache()
val forKey = "f".repeat(64) // the logged-in user
val followed = "e".repeat(64)
val stranger = "d".repeat(64)
// Three voters all pick "yes": self, a followed user, and a stranger.
cache.addResponse(responseNote("1".repeat(64), forKey, option = "yes", createdAt = 10))
cache.addResponse(responseNote("2".repeat(64), stranger, option = "yes", createdAt = 10))
cache.addResponse(responseNote("3".repeat(64), followed, option = "yes", createdAt = 10))
val tally = cache.currentTally("yes", forKey, priorityAccounts = setOf(followed))
val order = tally.users.map { it.pubkeyHex }
// Self first, then followed (WoT priority), then the stranger.
assertEquals(listOf(forKey, followed, stranger), order)
}
@Test
fun hasPubKeyVotedTracksVoter() {
val cache = PollResponsesCache()
val voter = "b".repeat(64)
val other = "c".repeat(64)
cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 10))
assertTrue(cache.hasPubKeyVoted(user(voter)))
assertFalse(cache.hasPubKeyVoted(user(other)))
}
@Test
fun addResponseIsIdempotentForSameNote() {
val cache = PollResponsesCache()
val note = responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)
cache.addResponse(note)
cache.addResponse(note) // relay echo of the same note must not double-count
assertEquals(1, cache.responses.value.totalVotes())
}
}
@@ -0,0 +1,106 @@
# Desktop Polls (NIP-88) — Manual Testing Sheet
**Feature:** render + vote + create polls on Amethyst Desktop
**Branch:** `worktree-feat+desktop-polls` (worktree `.claude/worktrees/feat+desktop-polls`)
**Plan:** `docs/plans/2026-07-16-feat-desktop-polls-nip88-plan.md`
**Status when written:** code-complete; compile + unit tests + spotless GREEN; **manual run not yet done.**
## Automated gates already passing
- [x] `./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin` — clean
- [x] `./gradlew :commons:jvmTest --tests "*nip88Polls*"` — 5 pass (dedup, tally %, WoT sort, hasVoted, idempotency)
- [x] `./gradlew :desktopApp:test --tests "*Poll*"` — 2 pass (response links into tally; relay-echo dedup)
- [x] `./gradlew :commons:spotlessKotlinCheck :desktopApp:spotlessKotlinCheck` — clean
## How to run
```bash
cd .claude/worktrees/feat+desktop-polls
./gradlew :desktopApp:run
```
Log in (existing account, or NIP-46 bunker). Use a relay set that carries polls — good sources: relays where clients post NIP-88 polls, or create one yourself (Test C) and read it back. A **second client** (Amethyst Android, or `amy`) is useful to cross-verify events on the wire.
---
## A. Rendering (feed + thread)
- [ ] A1. A kind-1068 poll appears in the **Home/Global feed as a poll card** (description + options), NOT as plain text. *(If polls never show: verify `DesktopFeedFilters.isFeedNote` includes `PollEvent` and `FEED_KINDS` has 1068.)*
- [ ] A2. Open the poll in a **thread column** → renders as a poll card there too.
- [ ] A3. Single-choice poll shows **radio**-style option rows; multi-choice shows **checkbox**-style rows with a **Submit** button.
- [ ] A4. Before voting, **no percentages/tally are shown** — only actionable options + a **"View results"** button.
- [ ] A5. Tapping **"View results"** reveals the tally without casting a vote; a way back to voting exists (unless ended/author).
- [ ] A6. A poll authored by **you**, seen in your own feed, shows **results-only** (you cannot vote).
- [ ] A7. An **ended** poll (deadline in the past) shows results-only, no vote controls.
- [ ] A8. Media/description of the poll render via the normal note card (links, images in the description behave as usual).
## B. Voting
- [ ] B1. Cast a **single-choice** vote → card immediately flips to results (optimistic), your option marked as your vote.
- [ ] B2. Results show a **% bar per option**, a **winning** highlight, and **voter avatars** (up to ~4) + "+N".
- [ ] B3. Voter avatars are **ordered with people you follow first** (WoT). Verify by having a followed account vote — their avatar should sort ahead of strangers.
- [ ] B4. The bar does **not** do a distracting 0→N sweep when opening an already-tallied poll (first-frame animation guard).
- [ ] B5. **Multi-choice**: select 2 options → Submit → both recorded; results reflect both.
- [ ] B6. **Multi-choice empty submit is rejected** — with nothing selected, Submit does nothing / is disabled (no empty response event sent).
- [ ] B7. **Change vote**: after voting, use **"Change vote"** → re-open options → pick a different option → tally updates so the **new** choice wins for you (newest response wins).
- [ ] B8. Cross-check on a second client (Android/amy): your vote is a **kind-1018** event referencing the poll via a lowercase `e` tag.
- [ ] B9. **Scroll-away during send** (stress the scope fix): cast a vote and immediately scroll the poll out of view. Re-find it / check a second client — the vote should have **broadcast to relays**, not just shown locally. *(This validates `voteOnPoll` runs on the long-lived `appScope`, not the card scope.)*
## C. Creating a poll
- [ ] C1. Open the composer; toggle **Poll** on → poll option editor appears; image attachment is disabled while Poll is on.
- [ ] C2. Add/remove options; **minimum 2 non-blank** options enforced before send is allowed.
- [ ] C3. Toggle **Single vs Multiple** choice.
- [ ] C4. Set a **duration** (Never / 1d / 3d / 7d). "Never" = open-ended (no deadline).
- [ ] C5. Send → a **kind-1068 PollEvent** is published (verify on a second client): correct options, `polltype`, and `endsAt` (absent for "Never").
- [ ] C6. The poll you created appears in your feed and is votable from **another** account/client; its tally updates as votes arrive.
## D. Edge cases
- [ ] D1. Poll with an unusually **long option label** wraps/renders without breaking layout.
- [ ] D2. A poll received with **0 options** (malformed) does not crash the feed (renders degraded / skipped).
- [ ] D3. Receiving **many responses from multiple relays** converges to a stable, non-inflated tally (no double counting of the same response).
- [ ] D4. Late votes arriving **after** a poll's deadline: they may still count in the tally, but the card stays results-only (no re-vote UI).
## E. Regression (nothing else broke)
- [ ] E1. Normal text notes, reposts, and reactions still render + behave in the feed.
- [ ] E2. Composing a normal note (Poll toggle OFF) works exactly as before, including image attachment.
- [ ] E3. Thread view still loads reactions/zaps/reposts for non-poll notes.
---
---
## F. Search "Polls" content-type filter (added 2026-07-20)
*Feature: filter search to only polls + interact with them. Plan: `docs/plans/2026-07-20-feat-desktop-search-polls-facet-plan.md`.*
- [ ] F1. Open the **Search** column → advanced filter panel shows a **"Polls"** chip alongside Notes/Articles/Media/Channels/Communities/Wiki.
- [ ] F2. Enter a query, select **Polls** → results contain **only** poll notes (kind 1068); other content types are excluded.
- [ ] F3. Results appear under a dedicated **"Polls" section** (poll icon) and render as **interactive `DesktopPollCard`** — options visible, not plain text.
- [ ] F4. **Vote from search** (dedicated Search screen): cast a vote on a poll in results → flips to results/optimistic tally, and a kind-1018 event is published (cross-check on a 2nd client).
- [ ] F5. Deselect the Polls chip → results return to mixed content; other facets still work.
- [ ] F6. Section collapse/expand + "Show all N more" work like the other search sections.
- [ ] F7. **Feed header quick-search** (the search box in the feed header): polls render as cards **and are now votable** (account threaded 2026-07-20).
## G. Cross-context consistency fixes (2026-07-20)
*Fixes for reported bugs: "can't always tap depending on how it's opened" + "only see my own answer, no other tallies".*
- [ ] G1. **Thread view:** open a poll into a thread → you can vote, and **after voting the card correctly shows your choice** as selected (previously your vote-state didn't register — missing `myPubKeyHex`).
- [ ] G2. **Thread tallies:** a poll opened in a thread shows **other people's votes**, not just yours.
- [ ] G3. **Profile tabs (Notes/Replies):** polls on a user's profile are votable and reflect your vote correctly.
- [ ] G4. **Dedicated Search tallies:** filter to Polls → results now show **existing tallies from others** (search fetches kind-1018 responses via `requestInteractions`), not just your own vote.
- [ ] G5. **Feed header quick-search:** polls there are now **votable** (account threaded).
- [ ] G6. **Consistency:** the SAME poll shows consistent vote-state + tallies whether opened in feed, thread, profile, or search.
**Remaining known gaps (expected):**
- **Notifications tab** renders polls as the compact notification card (not interactive) — out of scope.
- **Poll posted as a thread *reply*** (not root) renders via the thread's custom reply card (not interactive) — edge case, deferred.
- Feed-header quick-search fetches tallies only after the poll is also seen in a context that requests interactions; the **dedicated Search** column always fetches them.
## Known caveats (expected, not bugs)
- **Same-second re-vote:** if you change your vote **within the same 1-second** as the first, the tally may not flip until a later-second vote (tie-break on `createdAt` uses strict `>` with no id fallback). Wait ~1s between re-votes to see B7 flip reliably.
- **Option labels are plain text (v1):** links/custom-emoji inside an option label are shown literally, not hyperlinked/rendered (no desktop rich-text path for option labels yet).
- **Deadline = preset chips (v1):** Never/1d/3d/7d instead of a full date/time picker.
- **Not wired this PR (deferred):** poll rendering in profile/bookmarks/search/notifications tabs (still show as plain notes there); poll-draft round-trip; zap-weighted polls.
## If something fails — where to look
| Symptom | Check |
|---|---|
| Polls never appear in feed | `feeds/DesktopFeedFilters.kt` `isFeedNote` (PollEvent), `subscriptions/FilterBuilders.kt` `FEED_KINDS`=…,1068 |
| Poll shows but tally always empty | kind-1018 sub: `ui/FeedScreen.kt` fetch-interactions filter + `DesktopRelaySubscriptionsCoordinator.requestInteractions` (`e` tag) |
| Vote shows locally but never reaches relays | `DesktopPollCard.castVote` must launch on `localCache.appScope`; `voteOnPoll` in `ui/NoteActions.kt` |
| Double-counted votes | `DesktopLocalCache.consumePollResponse` new-event gate (line ~385) |
| Created poll malformed | `ComposeNoteDialog.publishPoll``PollEvent.build` options/type/endsAt |
@@ -58,13 +58,16 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -129,6 +132,17 @@ class DesktopLocalCache : ICacheProvider {
val paymentTracker = NwcPaymentTracker()
/**
* Long-lived, cache-scoped coroutine scope for fire-and-forget work that must
* outlive any single composition — e.g. the optimistic-consume → relay-broadcast
* pair of a poll vote (see [com.vitorpamplona.amethyst.desktop.ui.voteOnPoll]).
* Using a card's [androidx.compose.runtime.rememberCoroutineScope] there would let
* scrolling the card out of composition cancel the broadcast after the local consume,
* leaving the vote visible locally but never sent. Uses a [SupervisorJob] so one
* failed job doesn't tear down the rest.
*/
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun trackNoteAuthor(
note: Note,
authorPubkey: HexKey,
@@ -337,6 +351,14 @@ class DesktopLocalCache : ICacheProvider {
consumeBlossomServerList(event, relay)
}
is PollEvent -> {
consumePoll(event, relay)
}
is PollResponseEvent -> {
consumePollResponse(event, relay)
}
else -> {
false
}
@@ -455,6 +477,49 @@ class DesktopLocalCache : ICacheProvider {
return true
}
/**
* Consumes a kind 1068 poll event (NIP-88).
* Creates a Note in the cache like a text note, minus reply-linking — a poll is
* always a root post. The [Note.pollState] tally is populated by the responses.
*/
private fun consumePoll(
event: PollEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
trackNoteAuthor(note, event.pubKey)
relay?.let { note.addRelay(it) }
return true
}
/**
* Consumes a kind 1018 poll response event (NIP-88).
* Resolves the referenced poll, loads the response note, and links it into the
* poll's tally. Mirrors Android `LocalCache.consume(PollResponseEvent)`: the
* [com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache.addResponse]
* call and the `true` return happen only on a genuinely new event, so a relay echo
* of the user's own optimistically-consumed vote can't double-count (id-dedup here
* plus `addResponse`'s own containment guard).
*/
private fun consumePollResponse(
event: PollResponseEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val pollId = event.poll()?.eventId ?: return false
val pollNote = getOrCreateNote(pollId)
val responseNote = getOrCreateNote(event.id)
if (responseNote.event != null) return false
val author = getOrCreateUser(event.pubKey)
responseNote.loadEvent(event, author, emptyList())
trackNoteAuthor(responseNote, event.pubKey)
relay?.let { responseNote.addRelay(it) }
pollNote.pollState().addResponse(responseNote)
return true
}
/**
* NIP-18 quote reposts: a note carrying a `q` tag is a quote-repost of the quoted
* note, so it counts as a boost in the quoted note's repost counter alongside
@@ -821,7 +886,7 @@ class DesktopLocalCache : ICacheProvider {
requestNote?.let { req -> pending.zappedNote?.addZapPayment(req, note) }
// Invoke callback on IO dispatcher
GlobalScope.launch(Dispatchers.IO) {
appScope.launch {
pending.onResponse(event)
}
@@ -37,9 +37,11 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
private fun isFeedNote(event: Event?): Boolean =
event is TextNoteEvent ||
event is PollEvent ||
event.isRenderableRepost()
private fun List<Note>.deduplicateReposts(): List<Note> =
@@ -294,6 +294,11 @@ class DesktopRelaySubscriptionsCoordinator(
kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND),
tags = mapOf("e" to noteIds),
),
// Poll responses (kind 1018) targeting these notes (NIP-88, lowercase `e`)
Filter(
kinds = listOf(com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent.KIND),
tags = mapOf("e" to noteIds),
),
)
val listener =
@@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
* Provides convenience functions for creating relay subscription filters.
*/
object FilterBuilders {
private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent
private val FEED_KINDS = listOf(1, 6, 16, 1068) // TextNoteEvent, RepostEvent, GenericRepostEvent, PollEvent
/**
* Creates a filter for text notes (kind 1) from all authors.
@@ -43,6 +43,9 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -56,6 +59,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -66,6 +70,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
@@ -114,6 +120,9 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -228,6 +237,14 @@ fun ComposeNoteDialog(
var syncDraft by remember { mutableStateOf(false) }
var isSavingDraft by remember { mutableStateOf(false) }
// Poll (NIP-88) composer state. `wantsPoll` gates the poll UI; options start with
// two blank fields (a poll needs ≥2 non-blank options to publish).
var wantsPoll by remember { mutableStateOf(false) }
val pollOptions = remember { mutableStateListOf("", "") }
var pollType by remember { mutableStateOf(PollType.SINGLE_CHOICE) }
// Optional poll deadline, expressed as seconds-from-now (null = open-ended).
var pollDurationDays by remember { mutableStateOf<Int?>(null) }
// Image compression: global default + optional per-post override.
// Override resets after every successful send so the next post
// starts from the saved default again.
@@ -377,7 +394,21 @@ fun ComposeNoteDialog(
}
val scheduleAt = scheduledForSec
if (postAsPicture) {
if (wantsPoll) {
val endsAt =
pollDurationDays?.let { days ->
TimeUtils.now() + days * 24L * 60L * 60L
}
publishPoll(
description = content,
options = pollOptions.map { it.trim() }.filter { it.isNotEmpty() },
pollType = pollType,
endsAt = endsAt,
account = account,
relayManager = relayManager,
relays = selectedRelays,
)
} else if (postAsPicture) {
val pictureMetas = buildPictureMetas(uploadResults)
publishPicture(
description = content,
@@ -434,8 +465,9 @@ fun ComposeNoteDialog(
Modifier
.width(780.dp)
// Cap the dialog height so a tall composer (e.g. the schedule
// picker expanded) can't push the Cancel/Schedule buttons off
// screen — the body scrolls instead (see the content Column).
// picker expanded, or the poll composer with many options) can't
// push the Cancel/Publish buttons off screen — the body scrolls
// instead (see the content Column).
.heightIn(max = 760.dp)
.padding(16.dp)
.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget)
@@ -458,206 +490,246 @@ fun ComposeNoteDialog(
color = MaterialTheme.colorScheme.onSurface,
)
replyTo?.let { reply ->
Spacer(Modifier.height(8.dp))
Text(
"Replying to: ${reply.content.take(50)}${if (reply.content.length > 50) "..." else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Scrollable content area so the pinned Cancel/Publish row below stays
// reachable even when the poll section grows with many options.
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
replyTo?.let { reply ->
Spacer(Modifier.height(8.dp))
Text(
"Replying to: ${reply.content.take(50)}${if (reply.content.length > 50) "..." else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
quoteOf?.let { quoted ->
Spacer(Modifier.height(8.dp))
Text(
"Quoting: ${quoted.content.take(50)}${if (quoted.content.length > 50) "..." else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
quoteOf?.let { quoted ->
Spacer(Modifier.height(8.dp))
Text(
"Quoting: ${quoted.content.take(50)}${if (quoted.content.length > 50) "..." else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(16.dp))
Box {
OutlinedTextField(
value = if (postAsPicture) TextFieldValue("") else contentField,
onValueChange = {
contentField = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
label = {
Text(
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
)
},
placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") },
enabled = !isPosting && !postAsPicture,
maxLines = if (postAsPicture) 1 else 10,
)
Box {
OutlinedTextField(
value = if (postAsPicture) TextFieldValue("") else contentField,
onValueChange = {
contentField = it
errorMessage = null
},
modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
label = {
Text(
if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
)
},
placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") },
enabled = !isPosting && !postAsPicture,
maxLines = if (postAsPicture) 1 else 10,
)
// Mention autocomplete dropdown
if (mentionSuggestions.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
items(mentionSuggestions, key = { it.pubkeyHex }) { user ->
MentionSuggestionRow(
user = user,
onClick = {
val npub = user.pubkeyNpub()
val replacement = "nostr:$npub "
val cursorEnd = contentField.selection.end
val newText =
contentField.text.replaceRange(
mentionWordStart,
cursorEnd,
replacement,
)
val newCursor = mentionWordStart + replacement.length
contentField = TextFieldValue(newText, TextRange(newCursor))
mentionSuggestions = emptyList()
mentionQuery = null
},
)
// Mention autocomplete dropdown
if (mentionSuggestions.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
items(mentionSuggestions, key = { it.pubkeyHex }) { user ->
MentionSuggestionRow(
user = user,
onClick = {
val npub = user.pubkeyNpub()
val replacement = "nostr:$npub "
val cursorEnd = contentField.selection.end
val newText =
contentField.text.replaceRange(
mentionWordStart,
cursorEnd,
replacement,
)
val newCursor = mentionWordStart + replacement.length
contentField = TextFieldValue(newText, TextRange(newCursor))
mentionSuggestions = emptyList()
mentionQuery = null
},
)
}
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// MediaAttachmentRow fills its width, so give it a weighted slot;
// otherwise it consumes the whole Row and pushes the schedule
// button off the right edge (making it invisible).
Box(Modifier.weight(1f)) {
MediaAttachmentRow(
attachedFiles = attachedFiles,
isUploading = uploadState.isUploading,
onAttach = {
val files = DesktopFilePicker.pickMediaFiles()
attachedFiles.addAll(files)
},
onPaste = {
val files = ClipboardPasteHandler.getClipboardFiles()
attachedFiles.addAll(files)
},
onRemove = { attachedFiles.remove(it) },
)
}
// Picture posts aren't schedulable in v1 — hide the toggle then.
if (!postAsPicture) {
DesktopScheduleAtButton(
isActive = scheduledForSec != null,
onClick = {
scheduledForSec =
if (scheduledForSec != null) {
null
} else {
sanitizeScheduleTime(presetInOneHour())
}
},
)
}
}
if (scheduledForSec != null && !postAsPicture) {
Spacer(Modifier.height(8.dp))
DesktopScheduleAtPicker(
scheduledForSec = scheduledForSec ?: 0L,
onChanged = { scheduledForSec = it },
// Poll toggle — mutually exclusive with image posting (a poll carries no
// media attachments). Disabled while there are attached files.
Row(verticalAlignment = Alignment.CenterVertically) {
FilterChip(
selected = wantsPoll,
onClick = { wantsPoll = !wantsPoll },
enabled = attachedFiles.isEmpty(),
label = { Text("Poll") },
leadingIcon =
if (wantsPoll) {
{ Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else {
null
},
)
}
if (wantsPoll) {
PollComposerSection(
options = pollOptions,
pollType = pollType,
onPollTypeChange = { pollType = it },
pollDurationDays = pollDurationDays,
onDurationChange = { pollDurationDays = it },
)
}
// Media attachment + scheduling — hidden for polls (a poll carries no
// media attachments and isn't schedulable in v1).
if (!wantsPoll) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// MediaAttachmentRow fills its width, so give it a weighted slot;
// otherwise it consumes the whole Row and pushes the schedule
// button off the right edge (making it invisible).
Box(Modifier.weight(1f)) {
MediaAttachmentRow(
attachedFiles = attachedFiles,
isUploading = uploadState.isUploading,
onAttach = {
val files = DesktopFilePicker.pickMediaFiles()
attachedFiles.addAll(files)
},
onPaste = {
val files = ClipboardPasteHandler.getClipboardFiles()
attachedFiles.addAll(files)
},
onRemove = { attachedFiles.remove(it) },
)
}
// Picture posts aren't schedulable in v1 — hide the toggle then.
if (!postAsPicture) {
DesktopScheduleAtButton(
isActive = scheduledForSec != null,
onClick = {
scheduledForSec =
if (scheduledForSec != null) {
null
} else {
sanitizeScheduleTime(presetInOneHour())
}
},
)
}
}
if (scheduledForSec != null && !postAsPicture) {
Spacer(Modifier.height(8.dp))
DesktopScheduleAtPicker(
scheduledForSec = scheduledForSec ?: 0L,
onChanged = { scheduledForSec = it },
)
}
}
// Server selector + per-post quality + post type — shown when files are attached
if (attachedFiles.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
ServerSelector(
servers = effectiveServers,
selectedServer = selectedServer,
onServerSelected = { selectedServer = it },
)
// Quality override chip — only when images are attached
// (no point picking a JPEG preset for a video upload).
if (hasImages) {
QualitySelectorChip(
activeQuality = activeQuality,
isOverride = perPostQualityOverride != null,
onSelect = { perPostQualityOverride = it },
onReset = { perPostQualityOverride = null },
)
}
// Post type toggle — only when images are attached
if (hasImages) {
PostTypeSelector(
isPicture = postAsPicture,
onToggle = { postAsPicture = it },
)
}
}
}
Spacer(Modifier.height(4.dp))
// Character count
Text(
"${content.length} characters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
errorMessage?.let { error ->
Spacer(Modifier.height(8.dp))
SelectionContainer {
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
uploadState.error?.let { error ->
Spacer(Modifier.height(4.dp))
SelectionContainer {
Text(
"Upload error: $error",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(8.dp))
ComposeRelayPicker(
pickerState = pickerState,
selectedRelays = selectedRelays,
onToggleRelay = { url ->
selectedRelays =
if (url in selectedRelays) {
selectedRelays - url
} else {
selectedRelays + url
}
},
)
}
// Server selector + per-post quality + post type — shown when files are attached
if (attachedFiles.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
ServerSelector(
servers = effectiveServers,
selectedServer = selectedServer,
onServerSelected = { selectedServer = it },
)
// Quality override chip — only when images are attached
// (no point picking a JPEG preset for a video upload).
if (hasImages) {
QualitySelectorChip(
activeQuality = activeQuality,
isOverride = perPostQualityOverride != null,
onSelect = { perPostQualityOverride = it },
onReset = { perPostQualityOverride = null },
)
}
// Post type toggle — only when images are attached
if (hasImages) {
PostTypeSelector(
isPicture = postAsPicture,
onToggle = { postAsPicture = it },
)
}
}
}
Spacer(Modifier.height(4.dp))
// Character count
Text(
"${content.length} characters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
errorMessage?.let { error ->
Spacer(Modifier.height(8.dp))
SelectionContainer {
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
uploadState.error?.let { error ->
Spacer(Modifier.height(4.dp))
SelectionContainer {
Text(
"Upload error: $error",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(8.dp))
ComposeRelayPicker(
pickerState = pickerState,
selectedRelays = selectedRelays,
onToggleRelay = { url ->
selectedRelays =
if (url in selectedRelays) {
selectedRelays - url
} else {
selectedRelays + url
}
},
)
Spacer(Modifier.height(8.dp))
// NIP-37 draft-sync opt-in — only meaningful for plain notes.
@@ -704,61 +776,74 @@ fun ComposeNoteDialog(
// Save-as-draft: always writes a local row; optionally publishes a
// NIP-37 encrypted event. Local save still succeeds if sync fails.
OutlinedButton(
onClick = {
if (content.isBlank()) {
errorMessage = "Draft cannot be empty"
return@OutlinedButton
}
scope.launch {
isSavingDraft = true
errorMessage = null
var syncError: String? = null
try {
if (syncDraft) {
syncError =
syncDraftToRelays(
content = content,
dTag = draftTag,
account = account,
relayManager = relayManager,
replyTo = replyTo,
quoteOf = quoteOf,
relays = selectedRelays,
)
}
noteDraftStore.save(
NoteDraft(
dTag = draftTag,
content = content,
updatedAt = TimeUtils.now(),
synced = syncDraft && syncError == null,
accountPubkey = account.pubKeyHex,
),
)
if (syncError != null) {
errorMessage = "Draft saved locally, but sync failed: $syncError"
} else {
onDismiss()
}
} catch (e: Exception) {
errorMessage = "Failed to save draft: ${e.message}"
} finally {
isSavingDraft = false
// Not shown while composing a poll — polls aren't draftable in v1.
if (!wantsPoll) {
OutlinedButton(
onClick = {
if (content.isBlank()) {
errorMessage = "Draft cannot be empty"
return@OutlinedButton
}
}
},
enabled = !isPosting && !isSavingDraft && content.isNotBlank(),
) {
Text(if (isSavingDraft) "Saving..." else "Save as draft")
scope.launch {
isSavingDraft = true
errorMessage = null
var syncError: String? = null
try {
if (syncDraft) {
syncError =
syncDraftToRelays(
content = content,
dTag = draftTag,
account = account,
relayManager = relayManager,
replyTo = replyTo,
quoteOf = quoteOf,
relays = selectedRelays,
)
}
noteDraftStore.save(
NoteDraft(
dTag = draftTag,
content = content,
updatedAt = TimeUtils.now(),
synced = syncDraft && syncError == null,
accountPubkey = account.pubKeyHex,
),
)
if (syncError != null) {
errorMessage = "Draft saved locally, but sync failed: $syncError"
} else {
onDismiss()
}
} catch (e: Exception) {
errorMessage = "Failed to save draft: ${e.message}"
} finally {
isSavingDraft = false
}
}
},
enabled = !isPosting && !isSavingDraft && content.isNotBlank(),
) {
Text(if (isSavingDraft) "Saving..." else "Save as draft")
}
Spacer(Modifier.width(8.dp))
}
Spacer(Modifier.width(8.dp))
// A poll needs a question (description) and at least two options.
val pollValid = content.isNotBlank() && pollOptions.count { it.trim().isNotEmpty() } >= 2
Button(
onClick = {
if (wantsPoll) {
if (!pollValid) {
errorMessage = "A poll needs a question and at least two options"
return@Button
}
runPublish(null, emptySet())
return@Button
}
if (content.isBlank() && attachedFiles.isEmpty()) {
errorMessage = "Note cannot be empty"
return@Button
@@ -786,7 +871,9 @@ fun ComposeNoteDialog(
}
runPublish(null, emptySet())
},
enabled = !isPosting && !isSavingDraft && (content.isNotBlank() || attachedFiles.isNotEmpty()),
enabled =
!isPosting && !isSavingDraft &&
if (wantsPoll) pollValid else (content.isNotBlank() || attachedFiles.isNotEmpty()),
) {
Text(
when {
@@ -982,6 +1069,40 @@ private suspend fun publishPicture(
}
}
private suspend fun publishPoll(
description: String,
options: List<String>,
pollType: PollType,
endsAt: Long?,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
relays: Set<NormalizedRelayUrl>,
) {
withContext(Dispatchers.IO) {
if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode")
}
require(options.size >= 2) { "A poll needs at least two options" }
// Deterministic per-position codes; labels come straight from the fields.
val optionTags = options.mapIndexed { index, label -> OptionTag(index.toString(), label) }
val template =
PollEvent.build(
description = description,
options = optionTags,
endsAt = endsAt,
relays = relays.toList(),
pollType = pollType,
) {
hashtags(findHashtags(description))
}
val signedEvent = account.signer.sign(template)
relayManager.publish(signedEvent, relays)
}
}
private suspend fun publishNote(
content: String,
account: AccountState.LoggedIn,
@@ -1174,6 +1295,90 @@ private suspend fun syncDraftToRelays(
}
}
/**
* Poll composer body: N option fields (add/remove, ≥2), a single/multi choice chip pair,
* and an optional duration (deadline) chip row. The description is the main note text field.
*/
@Composable
private fun PollComposerSection(
options: SnapshotStateList<String>,
pollType: PollType,
onPollTypeChange: (PollType) -> Unit,
pollDurationDays: Int?,
onDurationChange: (Int?) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEachIndexed { index, value ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
OutlinedTextField(
value = value,
onValueChange = { options[index] = it },
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text("Option ${index + 1}") },
)
IconButton(
onClick = { if (options.size > 2) options.removeAt(index) },
enabled = options.size > 2,
) {
Icon(MaterialSymbols.Close, contentDescription = "Remove option", modifier = Modifier.size(20.dp))
}
}
}
OutlinedButton(onClick = { options.add("") }) {
Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Add option")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = pollType == PollType.SINGLE_CHOICE,
onClick = { onPollTypeChange(PollType.SINGLE_CHOICE) },
label = { Text("Single choice") },
)
FilterChip(
selected = pollType == PollType.MULTI_CHOICE,
onClick = { onPollTypeChange(PollType.MULTI_CHOICE) },
label = { Text("Multiple choice") },
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Ends:", style = MaterialTheme.typography.bodySmall)
listOf<Pair<String, Int?>>(
"Never" to null,
"1d" to 1,
"3d" to 3,
"7d" to 7,
).forEach { (label, days) ->
FilterChip(
selected = pollDurationDays == days,
onClick = { onDurationChange(days) },
label = { Text(label) },
leadingIcon =
if (pollDurationDays == days) {
{ Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize)) }
} else {
null
},
)
}
}
}
}
@Composable
private fun MentionSuggestionRow(
user: User,
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubsc
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopPollCard
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
@@ -159,6 +160,8 @@ import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
@@ -247,6 +250,21 @@ private fun FeedNoteCardBody(
myPubKeyHex: String? = null,
onFollow: ((String) -> Unit)? = null,
) {
if (event is PollEvent) {
DesktopPollCard(
note = note,
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = myPubKeyHex,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
onHashtagClick = onHashtagClick,
)
return
}
val isRepost = event is RepostEvent || event is GenericRepostEvent
if (isRepost) {
@@ -273,6 +291,22 @@ private fun FeedNoteCardBody(
return
}
// A boosted poll must still render as an interactive poll card, not a plain note.
if (originalEvent is PollEvent) {
DesktopPollCard(
note = originalNote,
event = originalEvent,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = myPubKeyHex,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
onHashtagClick = onHashtagClick,
)
return
}
val reactionCount = remember(reactionsState) { originalNote.countReactions() }
val replyCount = remember(repliesState) { originalNote.replies.size }
val repostCount = remember(metadataState) { originalNote.boosts.size }
@@ -859,6 +893,11 @@ fun FeedScreen(
kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND),
tags = mapOf("e" to interactionNoteIds),
),
// Poll responses (kind 1018) referencing these notes (NIP-88, lowercase `e`).
Filter(
kinds = listOf(PollResponseEvent.KIND),
tags = mapOf("e" to interactionNoteIds),
),
),
relays = allRelayUrls,
onEvent = { event, _, relay, _ ->
@@ -1117,6 +1156,7 @@ fun FeedScreen(
onSearchClick = openFullSearch,
relayManager = relayManager,
localCache = localCache,
account = account,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
@@ -1156,6 +1196,7 @@ private fun FeedTabsHeader(
onSearchClick: () -> Unit = {},
relayManager: DesktopRelayConnectionManager? = null,
localCache: DesktopLocalCache? = null,
account: AccountState.LoggedIn? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
) {
@@ -1508,6 +1549,9 @@ private fun FeedTabsHeader(
onNavigateToThread(noteId)
},
localCache = localCache,
relayManager = relayManager,
account = account,
myPubKeyHex = account?.pubKeyHex,
modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(),
)
} else if (isSearching) {
@@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -1396,6 +1398,41 @@ private suspend fun reactToNote(
}
}
/**
* Casts a NIP-88 poll vote: builds a kind-1018 [PollResponseEvent] referencing [poll],
* signs it, optimistically consumes it locally (so the tally + hasVoted gate flip
* immediately), then broadcasts to all relays. The relay echo of the same signed event
* is deduped by id, so no double count.
*
* MUST be launched on a long-lived scope (e.g. `localCache.appScope`) never a card's
* `rememberCoroutineScope()` so scrolling the poll out of composition between the
* local consume and the broadcast can't cancel the send.
*/
suspend fun voteOnPoll(
poll: PollEvent,
responses: Set<String>,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
) {
if (responses.isEmpty()) return
withContext(Dispatchers.IO) {
val template = PollResponseEvent.build(EventHintBundle(poll), responses)
val signed = account.signer.sign(template)
localCache.consume(signed, null, wasVerified = true)
// Publish to the poll's OWN declared relays (NIP-88 `relay` tags) as well as our
// connected relays — the poll author and other viewers read votes from the poll's
// relays, which we may not be connected to. broadcastToAll alone would lose the vote
// for everyone but us (mirrors the read path in DesktopPollCard.responseRelays).
val targetRelays = (poll.relays() + relayManager.connectedRelays.value).toSet()
if (targetRelays.isNotEmpty()) {
relayManager.publish(signed, targetRelays)
} else {
relayManager.broadcastToAll(signed)
}
}
}
/**
* Adds an event to bookmarks (public or private).
* Returns the new bookmark list event, or null if operation failed.
@@ -51,6 +51,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -109,6 +110,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import kotlinx.coroutines.launch
@Composable
@@ -361,6 +363,23 @@ fun SearchScreen(
}
}
// Fetch interactions (incl. kind-1018 poll responses) for poll results so their
// tallies populate — NIP-50 search returns the polls but not their responses.
val pollResultIds =
remember(noteResults) {
noteResults.filter { it.kind == PollEvent.KIND }.map { it.id }
}
DisposableEffect(pollResultIds, subscriptionsCoordinator, searchRelays) {
val coordinator = subscriptionsCoordinator
val subId =
if (coordinator != null && pollResultIds.isNotEmpty() && searchRelays.isNotEmpty()) {
coordinator.requestInteractions(pollResultIds, searchRelays)
} else {
null
}
onDispose { subId?.let { coordinator?.releaseInteractions(it) } }
}
// History state
val historyItems by SearchHistoryStore.history.collectAsState()
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
@@ -720,6 +739,9 @@ fun SearchScreen(
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
localCache = localCache,
relayManager = relayManager,
account = account,
myPubKeyHex = account?.pubKeyHex,
modifier = Modifier.padding(horizontal = sidePadding),
)
} else if (!debouncedQuery.isEmpty && !isSearching) {
@@ -304,6 +304,7 @@ fun ThreadScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account?.pubKeyHex,
nwcConnection = nwcConnection,
onReply = { rootNote.event?.let { onReply(it) } },
onZapFeedback = onZapFeedback,
@@ -944,6 +944,7 @@ fun UserProfileScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account?.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
@@ -1034,6 +1035,7 @@ fun UserProfileScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account?.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
@@ -0,0 +1,650 @@
/*
* 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.desktop.ui.note
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip88Polls.TallyResults
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.ui.voteOnPoll
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* A single poll option paired with its (stable, per-event) tally flow so each option
* row is its own leaf collector no combining all options into one flow (which would
* cause a recomposition storm).
*/
private class PollOptionFlow(
val option: OptionTag,
val results: Flow<TallyResults>,
val currentResults: () -> TallyResults,
)
/**
* Desktop NIP-88 poll card. Reuses [NoteCard] for the author/description/media header
* (via [bottomContent] slot for the interactive options) and renders the tally itself.
*
* Hide-until-voted (decision #2): controls are shown unless the viewer is the author,
* has voted, the poll ended, or opted into "View results". Re-vote allowed (decision #6):
* results view offers "Change vote".
*/
@Composable
fun DesktopPollCard(
note: Note,
event: PollEvent,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
myPubKeyHex: String?,
onNavigateToThread: (String) -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onHashtagClick: ((String) -> Unit)? = null,
) {
val options = remember(event) { event.options() }
if (options.isEmpty()) return
val pollState = remember(note) { note.pollState() }
val forKey = myPubKeyHex ?: ""
// Load this poll's responses from the poll's OWN declared relays (NIP-88 `relay` tags)
// unioned with the viewer's connected relays. Votes are published to the poll's relays,
// which the viewer usually isn't subscribed to — so the feed/thread/search interaction
// fetches (which only query the viewer's relays) miss them and the tally shows just the
// viewer's own vote. Querying the poll's declared relays makes the full tally load in
// any context that renders this card.
val connectedRelays by relayManager.connectedRelays.collectAsState()
val responseRelays =
remember(event, connectedRelays) {
(event.relays() + connectedRelays).toSet()
}
rememberSubscription(responseRelays, relayManager = relayManager) {
if (responseRelays.isEmpty()) return@rememberSubscription null
SubscriptionConfig(
subId = generateSubId("poll-resp-${event.id.take(8)}"),
filters =
listOf(
Filter(
kinds = listOf(PollResponseEvent.KIND),
tags = mapOf("e" to listOf(event.id)),
),
),
relays = responseRelays,
onEvent = { ev, _, relay, _ -> localCache.consume(ev, relay, wasVerified = false) },
)
}
// One stable flow per option, built once per event (Delta #6).
val optionFlows =
remember(event) {
options.map { option ->
PollOptionFlow(
option = option,
results = pollState.tallyFlow(option.code, forKey, localCache.followedUsers),
currentResults = { pollState.currentTally(option.code, forKey, localCache.followedUsers.value) },
)
}
}
val pollType = remember(event) { event.pollType() }
val hasEnded = remember(event) { event.hasEnded() }
val isMyPoll = myPubKeyHex != null && event.pubKey == myPubKeyHex
// A read-only (watch-only) account can't sign — show results instead of dead controls.
val canVote = account != null && !account.isReadOnly
// Seed the voted-gate synchronously to avoid a first-frame flash (Delta #7).
val myUser = remember(note, myPubKeyHex) { myPubKeyHex?.let { localCache.getOrCreateUser(it) } }
val hasVotedSeed = remember(pollState, myUser) { myUser?.let { pollState.hasPubKeyVoted(it) } ?: false }
val hasVoted by
remember(pollState, myUser) {
myUser?.let { pollState.hasPubKeyVotedFlow(it) } ?: flowOf(false)
}.collectAsState(hasVotedSeed)
// Local UI state keyed by note id so LazyColumn slot recycling can't leak one
// poll's selection into another (Delta #9). `viewingResults` = opted into results
// before voting; `revoting` = tapped "Change vote" to reopen controls after voting.
var viewingResults by remember(note.idHex) { mutableStateOf(false) }
var revoting by remember(note.idHex) { mutableStateOf(false) }
// Tap a result row to see who voted for that option.
var voterPopup by remember(note.idHex) { mutableStateOf<Pair<String, List<User>>?>(null) }
// Total votes + deadline label for the footer.
val tallyState by pollState.responses.collectAsState()
// Distinct voters (not total selections) so a multi-choice voter counts once.
val totalVotes = tallyState.votes.size
// Pre-seed a multi-choice re-vote with the viewer's existing selection.
val myCurrentVote =
remember(tallyState, myUser) {
myUser?.let { tallyState.votes[it]?.responses()?.toSet() } ?: emptySet()
}
val endsAtSec = remember(event) { event.endsAt() }
val deadlineLabel =
remember(endsAtSec, hasEnded) {
endsAtSec?.let { (if (hasEnded) "Ended " else "Ends ") + formatPollTimestamp(it) }
}
val displayData = remember(event) { event.toNoteDisplayData(localCache) }
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onHashtagClick = onHashtagClick,
onNavigateToThread = onNavigateToThread,
bottomContent = {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Results gate (decision #2): author / ended / already-voted / opted-in
// see results — unless the viewer explicitly reopened controls to re-vote.
val showResults = !revoting && (isMyPoll || hasVoted || hasEnded || viewingResults || !canVote)
if (showResults) {
optionFlows.forEach { of ->
key(of.option.code) {
PollResultRow(of, forKey) { label, voters ->
voterPopup = label to voters
}
}
}
if (!hasEnded && !isMyPoll && canVote && hasVoted) {
Text(
text = "Change vote",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { revoting = true }
.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
} else {
when (pollType) {
PollType.SINGLE_CHOICE ->
SingleChoiceOptions(options, account) { code ->
revoting = false
castVote(event, setOf(code), account, relayManager, localCache)
}
PollType.MULTI_CHOICE ->
MultiChoiceOptions(note, options, account, myCurrentVote) { codes ->
revoting = false
castVote(event, codes, account, relayManager, localCache)
}
}
Text(
text = if (revoting) "Back to results" else "View results",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.clickable {
if (revoting) revoting = false else viewingResults = true
}.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
if (totalVotes > 0 || deadlineLabel != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "$totalVotes ${if (totalVotes == 1) "vote" else "votes"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
deadlineLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
},
)
voterPopup?.let { (label, voters) ->
VoterListPopup(
optionLabel = label,
voters = voters,
forKey = forKey,
onDismiss = { voterPopup = null },
onNavigateToProfile = onNavigateToProfile,
)
}
}
private fun castVote(
event: PollEvent,
codes: Set<String>,
account: AccountState.LoggedIn?,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
) {
if (account == null || account.isReadOnly || codes.isEmpty()) return
// Launch on the cache-scoped scope, NOT the card's scope, so the consume→broadcast
// pair can't be half-cancelled when the card leaves composition (Delta #2).
localCache.appScope.launch {
voteOnPoll(event, codes, account, relayManager, localCache)
}
}
@Composable
private fun SingleChoiceOptions(
options: List<OptionTag>,
account: AccountState.LoggedIn?,
onRespond: (String) -> Unit,
) {
options.forEach { option ->
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.then(
if (account != null) {
Modifier.clickable { onRespond(option.code) }
} else {
Modifier
},
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.RadioButtonUnchecked,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(text = option.label, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
private fun MultiChoiceOptions(
note: Note,
options: List<OptionTag>,
account: AccountState.LoggedIn?,
initialSelection: Set<String>,
onRespond: (Set<String>) -> Unit,
) {
// Keyed by note id so recycling doesn't leak selection across polls (Delta #9);
// seeded with the viewer's existing vote so a re-vote starts from prior choices.
var selected by remember(note.idHex) { mutableStateOf(initialSelection) }
options.forEach { option ->
val isChecked = option.code in selected
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.clickable {
selected = if (isChecked) selected - option.code else selected + option.code
},
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// No CheckBox glyph in the subset font — a bordered box with a Check
// glyph when selected (avoids a new codepoint / font regen).
Box(
modifier =
Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.then(
if (isChecked) {
Modifier.background(MaterialTheme.colorScheme.primary)
} else {
Modifier.border(
1.dp,
MaterialTheme.colorScheme.outline,
RoundedCornerShape(4.dp),
)
},
),
contentAlignment = Alignment.Center,
) {
if (isChecked) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
Text(text = option.label, style = MaterialTheme.typography.bodyMedium)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(
onClick = { onRespond(selected) },
enabled = account != null && selected.isNotEmpty(),
) {
Text("Submit")
}
}
}
@Composable
private fun PollResultRow(
of: PollOptionFlow,
forKey: String,
onShowVoters: (String, List<User>) -> Unit,
) {
val tally by of.results.collectAsState(of.currentResults())
// First-frame bar guard: snap on first emission, animate afterwards (Delta #10).
val animated = remember { Animatable(tally.percent) }
LaunchedEffect(tally.percent) {
animated.animateTo(tally.percent)
}
val isMyVote = forKey.isNotEmpty() && tally.users.any { it.pubkeyHex == forKey }
val winning = tally.isWinning
val barColor = if (winning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
// Border marks YOUR choice (primary); the winner is conveyed by the bar fill color.
val borderColor =
when {
isMyVote -> MaterialTheme.colorScheme.primary
winning -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.outline
}
val borderWidth = if (isMyVote) 2.dp else 1.dp
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(borderWidth, borderColor, RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.clickable { onShowVoters(of.option.label, tally.users) },
) {
Box(
modifier =
Modifier
.matchParentSize()
.alpha(0.32f)
.drawWithContent {
clipRect(right = size.width * animated.value) {
drawRect(barColor)
}
drawContent()
},
)
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (isMyVote) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = "Your vote",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = of.option.label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isMyVote) FontWeight.SemiBold else FontWeight.Normal,
)
}
Spacer(Modifier.width(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
VoterGallery(tally.users, forKey)
Spacer(Modifier.width(8.dp))
Text(
text = "${(tally.percent * 100).toInt()}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
)
}
}
}
}
@Composable
private fun VoterGallery(
users: List<User>,
forKey: String,
) {
if (users.isEmpty()) return
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy((-10).dp),
) {
users.take(4).forEachIndexed { index, user ->
key(user.pubkeyHex) {
val isMe = forKey.isNotEmpty() && user.pubkeyHex == forKey
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 24.dp,
// Earlier avatars draw on top so the leftmost (you, sorted first) is
// front-most instead of buried under the next ones; ring your own.
modifier =
Modifier
.zIndex((users.size - index).toFloat())
.then(
if (isMe) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
} else {
Modifier
},
),
)
}
}
if (users.size > 4) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = "+${users.size - 4}",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@Composable
private fun VoterListPopup(
optionLabel: String,
voters: List<User>,
forKey: String,
onDismiss: () -> Unit,
onNavigateToProfile: (String) -> Unit,
) {
Popup(
alignment = Alignment.Center,
offset = IntOffset(0, 0),
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
ElevatedCard(modifier = Modifier.widthIn(max = 320.dp)) {
Column(
modifier =
Modifier
.verticalScroll(rememberScrollState())
.heightIn(max = 360.dp)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "${voters.size} ${if (voters.size == 1) "vote" else "votes"} · $optionLabel",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
HorizontalDivider()
if (voters.isEmpty()) {
Text(
text = "No votes yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
voters.forEach { user ->
key(user.pubkeyHex) {
val isMe = forKey.isNotEmpty() && user.pubkeyHex == forKey
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable {
onNavigateToProfile(user.pubkeyHex)
onDismiss()
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 28.dp,
)
Text(
text = user.toBestDisplayName() + if (isMe) " (you)" else "",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
}
private val POLL_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, HH:mm")
private fun formatPollTimestamp(epochSeconds: Long): String =
Instant
.ofEpochSecond(epochSeconds)
.atZone(ZoneId.systemDefault())
.format(POLL_TIME_FORMAT)
@@ -61,12 +61,16 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopPollCard
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadge
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
@Composable
fun SearchResultsList(
@@ -74,6 +78,10 @@ fun SearchResultsList(
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
localCache: DesktopLocalCache? = null,
relayManager: DesktopRelayConnectionManager? = null,
account: AccountState.LoggedIn? = null,
myPubKeyHex: String? = null,
onHashtagClick: ((String) -> Unit)? = null,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
@@ -89,7 +97,8 @@ fun SearchResultsList(
// Group notes by kind
val textNotes = notes.filter { it.kind == 1 }
val articles = notes.filter { it.kind == LongTextNoteEvent.KIND }
val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND }
val polls = notes.filter { it.kind == PollEvent.KIND }
val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND && it.kind != PollEvent.KIND }
// Per-section collapsed state (absent = expanded)
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
@@ -252,6 +261,56 @@ fun SearchResultsList(
}
}
// Polls section (interactive cards — read tallies + vote)
if (polls.isNotEmpty()) {
item(key = "divider-polls") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
val collapsed = collapsedSections["polls"] == true
stickyHeader(key = "header-polls") {
SortableHeader(
title = "Polls",
count = polls.size,
icon = MaterialSymbols.Poll,
options = SearchSortOrder.EVENT_OPTIONS,
selected = eventSortOrder,
onSelect = { state.updateEventSortOrder(it) },
collapsed = collapsed,
onToggleCollapse = { collapsedSections["polls"] = !collapsed },
)
}
if (!collapsed) {
items(polls.take(5), key = { "poll-${it.id}" }) { event ->
PollSearchItem(
event = event as PollEvent,
localCache = localCache,
relayManager = relayManager,
account = account,
myPubKeyHex = myPubKeyHex,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
onHashtagClick = onHashtagClick,
)
}
if (polls.size > 5) {
item(key = "polls-expand") {
ExpandableSection(
remaining = polls.drop(5),
) { event ->
PollSearchItem(
event = event as PollEvent,
localCache = localCache,
relayManager = relayManager,
account = account,
myPubKeyHex = myPubKeyHex,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
onHashtagClick = onHashtagClick,
)
}
}
}
}
}
// Other section
if (otherNotes.isNotEmpty()) {
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
@@ -320,6 +379,49 @@ private fun wotBadgeFor(userHex: String): (@Composable androidx.compose.foundati
}
}
@Composable
private fun PollSearchItem(
event: PollEvent,
localCache: DesktopLocalCache?,
relayManager: DesktopRelayConnectionManager?,
account: AccountState.LoggedIn?,
myPubKeyHex: String?,
onNavigateToThread: (String) -> Unit,
onNavigateToProfile: (String) -> Unit,
onHashtagClick: ((String) -> Unit)?,
) {
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
) {
if (localCache != null && relayManager != null) {
// Interactive: read tallies + vote. Note is resolved from the cache; option
// rendering comes from the event, so an empty (unconsumed) note still renders.
DesktopPollCard(
note = localCache.getOrCreateNote(event.id),
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = myPubKeyHex,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
onHashtagClick = onHashtagClick,
)
} else {
// Read-only fallback when the live cache/relay manager isn't available.
NoteCard(
note = event.rememberDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}
}
@Composable
private fun SortableHeader(
title: String,
@@ -0,0 +1,113 @@
/*
* 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.desktop.cache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* NIP-88 poll consumption: a kind-1068 poll becomes a renderable Note, and a kind-1018
* response is linked into that poll Note's `pollState()` tally. Second identical response
* (a relay echo) must not double-count.
*/
class DesktopLocalCachePollTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
private fun signedPoll(
signer: NostrSignerSync,
createdAt: Long,
): PollEvent =
signer.sign(
createdAt = createdAt,
kind = PollEvent.KIND,
tags =
arrayOf(
arrayOf("option", "0", "Yes"),
arrayOf("option", "1", "No"),
arrayOf("polltype", "singlechoice"),
),
content = "Pick one",
)
private fun signedResponse(
signer: NostrSignerSync,
pollId: String,
option: String,
createdAt: Long,
): PollResponseEvent =
signer.sign(
createdAt = createdAt,
kind = PollResponseEvent.KIND,
tags =
arrayOf(
arrayOf("e", pollId),
arrayOf("response", option),
),
content = "",
)
@Test
fun `a poll response is linked into the poll's tally`() {
val cache = DesktopLocalCache()
val author = NostrSignerSync(KeyPair())
val voter = NostrSignerSync(KeyPair())
val poll = signedPoll(author, createdAt = 1_700_000_000)
assertTrue(cache.consume(poll, relayUrl, wasVerified = true), "poll should be consumed")
val response = signedResponse(voter, poll.id, option = "0", createdAt = 1_700_000_100)
assertTrue(cache.consume(response, relayUrl, wasVerified = true), "response should be consumed")
val pollNote = cache.getNoteIfExists(poll.id)
assertTrue(pollNote != null, "poll note must exist")
val tally = pollNote.pollState().responses.value
assertEquals(1, tally.totalVotes())
assertEquals("0", tally.winning())
}
@Test
fun `a duplicate response is not counted twice`() {
val cache = DesktopLocalCache()
val author = NostrSignerSync(KeyPair())
val voter = NostrSignerSync(KeyPair())
val poll = signedPoll(author, createdAt = 1_700_000_000)
cache.consume(poll, relayUrl, wasVerified = true)
val response = signedResponse(voter, poll.id, option = "1", createdAt = 1_700_000_100)
assertTrue(cache.consume(response, relayUrl, wasVerified = true))
// Same signed event echoed back by another relay — id-dedup must reject it.
assertTrue(!cache.consume(response, relayUrl, wasVerified = true))
val tally =
cache
.getNoteIfExists(poll.id)!!
.pollState()
.responses.value
assertEquals(1, tally.totalVotes())
}
}
@@ -37,7 +37,7 @@ class FilterBuildersTest {
fun testTextNotesGlobal() {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(50, filter.limit)
assertNull(filter.authors)
assertNull(filter.tags)
@@ -51,7 +51,7 @@ class FilterBuildersTest {
val until = 1640995200L // 2022-01-01
val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(100, filter.limit)
assertEquals(since, filter.since)
assertEquals(until, filter.until)
@@ -62,7 +62,7 @@ class FilterBuildersTest {
val authors = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(25, filter.limit)
assertNull(filter.tags)
@@ -74,7 +74,7 @@ class FilterBuildersTest {
val since = 1609459200L
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(10, filter.limit)
assertEquals(since, filter.since)
@@ -432,7 +432,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertTrue(!filter.isEmpty())
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(50, filter.limit)
}
@@ -442,7 +442,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
assertTrue(!filter.isEmpty())
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(listOf(1, 6, 16, 1068), filter.kinds)
assertEquals(followedUsers, filter.authors)
assertEquals(50, filter.limit)
}
@@ -458,7 +458,7 @@ class FilterBuildersTest {
assertTrue(!contactListFilter.isEmpty())
assertEquals(listOf(0), metadataFilter.kinds)
assertEquals(listOf(1, 6, 16), postsFilter.kinds)
assertEquals(listOf(1, 6, 16, 1068), postsFilter.kinds)
assertEquals(listOf(3), contactListFilter.kinds)
}