diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt index eb6bf79b79..5e3dc5f45c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -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 = 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? = aliases[alias.lowercase()] diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt new file mode 100644 index 0000000000..9db5dce702 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt @@ -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() + + 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()) + } +} diff --git a/desktopApp/plans/2026-07-16-desktop-polls-manual-testing-sheet.md b/desktopApp/plans/2026-07-16-desktop-polls-manual-testing-sheet.md new file mode 100644 index 0000000000..e70d4f4157 --- /dev/null +++ b/desktopApp/plans/2026-07-16-desktop-polls-manual-testing-sheet.md @@ -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 | diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index d3ea5999fc..af026c5746 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -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) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 13fb4ff300..ebcfb2e98d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -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.deduplicateReposts(): List = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 9aff62678a..b70c4e2bea 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -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 = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 64eae951a9..9b2c043d53 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 9eedb59385..fe7a29c773 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -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(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, + pollType: PollType, + endsAt: Long?, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + relays: Set, +) { + 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, + 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>( + "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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1eace52ec5..ac737368e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 7a52fb1d08..9c91070f36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -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, + 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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 29f56c5047..ba976472ea 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index f637ae4523..ba8a07a4b4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8540b85045..896b9d930f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt new file mode 100644 index 0000000000..6fa5219d06 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt @@ -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, + 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>?>(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, + 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, + 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, + account: AccountState.LoggedIn?, + initialSelection: Set, + onRespond: (Set) -> 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) -> 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, + 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, + 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index 68bd2e8fbc..c225256ee4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -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() } @@ -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, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt new file mode 100644 index 0000000000..88c32fb660 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt @@ -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()) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt index c32cb76657..9ce9d808bb 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt @@ -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) }