mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.8
parent
72b8df7f6d
commit
b2adb157ff
+3
@@ -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()]
|
||||
|
||||
+145
@@ -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 |
|
||||
Vendored
+67
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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> =
|
||||
|
||||
+5
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+446
-241
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+650
@@ -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)
|
||||
+103
-1
@@ -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,
|
||||
|
||||
Vendored
+113
@@ -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())
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user