From 6053dc4f1d5766b7603fefb3aae2f89f7b28fde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:47:50 +0000 Subject: [PATCH] docs(commons): reuse the app's user row for poll voters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three revisions to the poll results proposal and its mockup. Voter rows are no longer a bespoke row. A voter is a user, and the app already draws users one way: SlimListItem with UserPicture / UsernameDisplay / AboutDisplay, which is what UserCompose is. The results row wants the first three verbatim and differs only in the trailing slot, where the vote goes instead of the follow buttons — so the proposal adds a trailingContent parameter to UserCompose with a default that leaves every existing call site unchanged, rather than forking the row. The second line is the profile's about text, and the bespoke follows/you chips are gone; ordering already carries that. Drops the privacy call-out entirely. Moves the audience filter, sort control and voter search out of the first version into their own later phase; option chips remain the only filter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- commons/plans/2026-08-03-poll-results-page.md | 112 +++++-- .../assets/2026-08-03-poll-results-page.html | 291 +++++++++--------- 2 files changed, 232 insertions(+), 171 deletions(-) diff --git a/commons/plans/2026-08-03-poll-results-page.md b/commons/plans/2026-08-03-poll-results-page.md index 2a1bd9e142..d2ad3a2cdf 100644 --- a/commons/plans/2026-08-03-poll-results-page.md +++ b/commons/plans/2026-08-03-poll-results-page.md @@ -190,59 +190,110 @@ more than 100 responses truncates silently. │ You voted: nos.lol [Change vote] │ only when signed in + voted ├─────────────────────────────────────────────┤ │ [All] [nos.lol] [damus] [purplepag.es] │ option filter chips -│ [Everyone ▾] [Recent ▾] 🔍 search │ audience + sort + search ├─────────────────────────────────────────────┤ -│ [avatar] you nos.lol 2d ago │ voter rows -│ [avatar] Bob (follows) damus 2d ago │ -│ [avatar] Carol nos.lol 1d ago │ +│ ⬤ Ana Reis nos.lol 2d ago │ voter rows — SlimListItem, +│ Building things on nostr. │ same anatomy as UserCompose +│ ⬤ Bruno Sá damus 2d ago │ (§6.1): picture / name / +│ Relay operator, Lisbon. │ about, vote in the trailing +│ ⬤ npub1q8f…j3xw damus 22h ago│ slot │ … │ ├─────────────────────────────────────────────┤ │ 380 of ~412 loaded from 6 relays · 3 ignored│ completeness footer (§4) └─────────────────────────────────────────────┘ ``` +Audience filter, sort and voter search are **deliberately not in the first +version** — see §5.3. + ### 5.2 Behaviour decisions | Question | Decision | Why | |---|---|---| | Percent basis | single-choice: % of voters; multi-choice: % of voters, bars may exceed 100% in sum, each row captioned "N of M voters" | §3.1 — matches how people read a poll | | Voter list default | flat list of **all** voters, each row showing which option(s) they chose | answers "who voted for what" in one screen without drilling | +| Voter row | `SlimListItem` with the same slots `UserCompose` fills — `UserPicture` / `UsernameDisplay` / `AboutDisplay` — and the vote in `trailingContent` (§6.1) | a voter is a user; it should look like every other user row in the app | | Option chips | filter the voter list to one option; the summary block never filters | keeps totals stable while browsing | -| Audience filter | `Everyone` (default) / `Following` / `Following + WoT` | reuses `account.allFollows.flow`, already the tally's priority set | -| Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" and a toggle | consistent with every other user list in the app | -| Sort | `Relevance` (you → follows → pubkey — today's `filterTo` comparator), `Newest`, `Oldest` | relevance is already implemented; the other two are one comparator each | -| Ordering of you | your own row always pinned first in Relevance, and your vote echoed in the "You voted" strip | already the `filterTo` behaviour | +| Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" | consistent with every other user list in the app | +| Ordering | you first, then follows, then pubkey — today's `filterTo` comparator, no sort control | the ordering already exists and already does the useful thing | | Live updates | the page is a live view of `pollState().responses`; new votes animate in | it's a StateFlow already; no refresh button needed except for the relay backfill | | Closed polls | status chip flips to "Ended · 2d ago", "Change vote" disappears, late votes excluded (§3.3) with an "N late votes excluded" footnote | spec compliance, and it explains a number that would otherwise look wrong | | Empty state | "No votes yet" + the option rows at 0 | | -### 5.3 Privacy call-out +### 5.3 Deferred to a later pass -NIP-88 responses are **public, unencrypted events** — this page doesn't disclose -anything new, but it makes a fact legible that many users have not internalised. -Two small additions belong with this work: +Three controls are specified here so the layout leaves room for them, but are +**not** part of the first version: -- an ⓘ affordance on the results header: *"Poll votes are public. Anyone can see - who voted for what."* -- the same one-liner next to the vote controls in `RenderPollCard` **before** the - first vote — the honest place to say it is where the choice is made, not after. +- **audience filter** (`Everyone` / `Following` / `Following + WoT`), +- **sort control** (`Newest` / `Oldest` on top of the default relevance order), +- **voter search**. -There is no "anonymous vote" option to offer; saying so plainly is the whole fix. +Each is cheap on its own — the follow set and the comparator both already exist — +but none of them is needed to answer "how many votes did each answer get, and who +voted for what". They land once the page is real and it's clear which of them +people actually reach for. Until then the list is ordered you → follows → rest, +and the option chips are the only filter. ## 6. Code structure Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in `commons`, screens/nav platform-native): +### 6.1 The voter row is an existing user row + +A voter is a user, and the app already has one way to draw a user in a list. +`UserCompose` (`amethyst/…/ui/note/UserCompose.kt:52`) is `SlimListItem` +(`ui/layouts/listItem/SlimListItemLayout.kt:168`) with four slots filled: + +| Slot | Filled with | Renders | +|---|---|---| +| `leadingContent` | `UserPicture(user, Size55dp, …)` | avatar, with its own loading/blank handling | +| `headlineContent` | `UsernameDisplay(user, accountViewModel)` | the account's petname → `bestName()` → `pubkeyDisplayHex()`, with custom-emoji support via `CreateTextWithEmoji` | +| `supportingContent` | `AboutDisplay(user, accountViewModel)` | the profile's about text, one line, ellipsized, in `placeholderText` | +| `trailingContent` | `UserActionOptions(…)` | follow/unfollow + list buttons | + +The results row wants the first three **verbatim** and only differs in the +fourth, where the vote goes instead of the follow buttons. So don't write a new +row — add a slot to the existing one: + +```kotlin +@Composable +fun UserCompose( + baseUser: User, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, + trailingContent: @Composable () -> Unit = { UserActionOptions(baseUser, accountViewModel, nav) }, +) { … } +``` + +Every existing call site is unchanged by the default; the poll screen passes the +option label + relative timestamp. This also means voter rows inherit, for free, +the things a hand-rolled row would silently lose: petname overrides, emoji in +display names, the npub fallback when metadata hasn't arrived, and the +tap-to-profile navigation from `routeFor(user)`. + +Two consequences worth stating: + +- **The second line is the user's about text, not their npub.** That is what the + rest of the app shows, and the npub already appears in the headline when there + is no metadata to show instead. +- **No bespoke "follows" or "you" chip.** `UserCompose` has never had one, and the + ordering (you → follows → rest) already carries that information. If a follow + marker is wanted later it should come from the existing follow-state renderer + used elsewhere, not a new badge invented for this screen. + +### 6.2 New and edited files + **New in `commons` (shared):** | File | Contents | |---|---| | `model/nip88Polls/PollTallyPolicy.kt` | §3.5 | | `model/nip88Polls/PollResponsesCache.kt` *(edit)* | poll-aware `ResponseTally`, `totalVoters()`, `totalSelections()`, `rejected`, `lateVotes` | -| `viewmodels/nip88Polls/PollResultsViewModel.kt` | holds the poll `Note`; exposes `StateFlow` (header + option rows + filtered/sorted voter rows + completeness meta); owns filter/sort/search `MutableStateFlow`s; drives the backfill. Sibling in style to `viewmodels/LiveStreamTopZappersViewModel.kt`, with the usual nested `Factory` | +| `viewmodels/nip88Polls/PollResultsViewModel.kt` | holds the poll `Note`; exposes `StateFlow` (header + option rows + voter rows + completeness meta); owns the option-chip selection; drives the backfill. Sibling in style to `viewmodels/LiveStreamTopZappersViewModel.kt`, with the usual nested `Factory` | | `relayClient/…/polls/PollResponsesFilterAssembler.kt` | §4 | -| `ui/note/polls/PollResultsHeader.kt`, `PollOptionBreakdown.kt`, `PollVoterRow.kt` | slot-based shared composables — the user avatar/name cell is a `@Composable` slot supplied by each platform (`UserCompose` on Android, desktop's own row), per `compose-slot-api-pattern` | +| `ui/note/polls/PollResultsHeader.kt`, `PollOptionBreakdown.kt` | slot-based shared composables for the header and the option bars, per `compose-slot-api-pattern`. **No shared voter-row composable** — each platform's own user row fills that job (§6.1) | **New in `amethyst` (Android):** @@ -259,10 +310,14 @@ Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in - `DeckColumnType.PollResults(noteId)` in `ui/deck/DeckColumnType.kt` (plus the `title()` and `typeKey()` branches — those `when`s are exhaustive) and a render branch in `DeckColumnContainer.kt`. Keep `VoterListPopup` as the quick peek and - add a **"See all voters"** footer that opens the column. + add a **"See all voters"** footer that opens the column. Its voter rows follow + the same rule as §6.1 against desktop's own user-row composable rather than the + bespoke avatar+name pair in `VoterListPopup`. **Edited (entry points):** +- `UserCompose.kt` — add the `trailingContent` slot (§6.1). Default keeps every + existing call site identical. - `Poll.kt` — make the totals line and the `UserGallery` `+N` chip clickable → `nav.nav(Route.PollResults(noteId))`; add a "N votes" count next to the percentage (currently absent). @@ -295,8 +350,8 @@ very different tally models. the denominator (§3.4); multi-choice `totalVoters` vs `totalSelections` (§3.1); policy arriving *after* responses recomputes the tally (§3.5); duplicate pubkey latest-wins (existing behaviour, lock it in). -- New `PollResultsViewModelTest` with Turbine: filter/sort/search transitions, - mute exclusion, live insertion of a new vote. +- New `PollResultsViewModelTest` with Turbine: option-chip transitions, mute + exclusion, live insertion of a new vote. - Manual: a >100-response poll (paging), a poll whose `relay` tags the viewer isn't connected to (completeness), a multi-choice poll (denominators). @@ -306,9 +361,10 @@ very different tally models. |---|---|---| | 0 | Poll-aware tally (§3) — commons + tests | yes — fixes the inline card's numbers on both platforms | | 1 | Shared subscription + backfill (§4) | yes — Android tallies stop being short | -| 2 | `PollResultsViewModel` + shared composables + Android screen + route + entry points | yes — the feature | -| 3 | Filters, sort, search, completeness footer, privacy call-out (§5.2–5.3) | polish | -| 4 | Desktop column; then zap polls (§7) | parity, then breadth | +| 2 | `PollResultsViewModel` + shared composables + `UserCompose` trailing slot + Android screen + route + entry points | yes — the feature | +| 3 | Desktop column | parity | +| 4 | Audience filter, sort, voter search (§5.3) | once it's clear which are actually reached for | +| 5 | Zap polls (§7) | breadth | Phases 0 and 1 are worth landing even if the page itself is deferred: they are bug fixes wearing a feature's clothes. @@ -323,7 +379,9 @@ bug fixes wearing a feature's clothes. (`Poll.kt:298-336`, `hasViewedPollResults`). Proposal: the page respects the same gate — reaching it counts as opting in and calls `markPollResultsViewed`, exactly like today's "View results" link. -3. **WoT tier in the audience filter** — worth it, or do `Everyone` / `Following` - cover the need? +3. **Trailing slot vs. a second entry point.** §6.1 adds a `trailingContent` + parameter to `UserCompose` with a default that preserves every call site. The + alternative is a separate `UserComposeWithTrailing` — more files, no behaviour + difference. Flagging it because it edits a composable used across the app. 4. **Export/share results** (copy as text, or a rendered image) — desirable, but listed as out of scope until the page exists. diff --git a/commons/plans/assets/2026-08-03-poll-results-page.html b/commons/plans/assets/2026-08-03-poll-results-page.html index 20524de2f1..2697e5eb6f 100644 --- a/commons/plans/assets/2026-08-03-poll-results-page.html +++ b/commons/plans/assets/2026-08-03-poll-results-page.html @@ -40,7 +40,7 @@ --app-accent: #6200ee; --app-good: #339900; --app-warn: #c09b14; - --app-following: #00897b; + --app-placeholder: color-mix(in srgb, #1c1c1c 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .08); --font-ui: ui-sans-serif, system-ui, "Segoe UI", Helvetica, Arial, sans-serif; @@ -78,7 +78,7 @@ --app-accent: #bb86fc; --app-good: #99cc33; --app-warn: #e1c419; - --app-following: #03dac5; + --app-placeholder: color-mix(in srgb, #e6e6e6 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .6), 0 0 0 1px #2b2b2b; } } @@ -109,7 +109,7 @@ --app-accent: #bb86fc; --app-good: #99cc33; --app-warn: #e1c419; - --app-following: #03dac5; + --app-placeholder: color-mix(in srgb, #e6e6e6 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .6), 0 0 0 1px #2b2b2b; } @@ -139,7 +139,7 @@ --app-accent: #6200ee; --app-good: #339900; --app-warn: #c09b14; - --app-following: #00897b; + --app-placeholder: color-mix(in srgb, #1c1c1c 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .08); } @@ -323,7 +323,8 @@ letter-spacing: .02em; flex: none; } - .avatar-sm { width: 22px; height: 22px; font-size: 9px; } + /* UserPicture at Size55dp, the size every user list in the app uses. */ + .avatar-user { width: 48px; height: 48px; font-size: 15px; } .avatar-xs { width: 21px; height: 21px; font-size: 8.5px; box-shadow: 0 0 0 2px var(--app-bg); @@ -514,74 +515,35 @@ .fchip:focus-visible, .app-btn:focus-visible { outline: 2px solid var(--app-accent); outline-offset: 2px; } - .filter-row { display: flex; gap: 7px; align-items: center; } - .select { - font-size: 11.5px; - padding: 4px 10px; - border-radius: 8px; - border: 1px solid var(--app-outline-2); - color: var(--app-on-2); - display: inline-flex; - gap: 6px; - align-items: center; - white-space: nowrap; - } - .search { - flex: 1; - font-size: 11.5px; - padding: 4px 10px; - border-radius: 8px; - border: 1px solid var(--app-outline-2); - color: var(--app-on-2); - display: flex; - gap: 6px; - align-items: center; - min-width: 0; - } - /* voter list */ + /* Voter list. Row anatomy is UserCompose's, not a new one: SlimListItem with + UserPicture / UsernameDisplay / AboutDisplay, and the vote in the trailing + slot where the follow buttons normally sit. */ .voters { display: flex; flex-direction: column; } .voter { display: flex; align-items: center; - gap: 10px; - padding: 10px 16px; + gap: 12px; + padding: 11px 16px; border-bottom: 1px solid var(--app-outline-2); } .voter:last-child { border-bottom: 0; } .voter-id { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } .voter-name { - font-size: 13.5px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; + font-size: 14.5px; + font-weight: 700; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } - .voter-handle { font-size: 11.5px; color: var(--app-on-2); } - .tag-follows { - font-size: 9.5px; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--app-following); - border: 1px solid color-mix(in srgb, var(--app-following) 45%, transparent); - border-radius: 4px; - padding: 0 4px; - font-weight: 700; - flex: none; - } - .tag-you { - font-size: 9.5px; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--app-accent); - border: 1px solid color-mix(in srgb, var(--app-accent) 45%, transparent); - border-radius: 4px; - padding: 0 4px; - font-weight: 700; - flex: none; + /* AboutDisplay: profile about text, one line, ellipsized, placeholderText + (= onSurface at 42%). */ + .voter-about { + font-size: 12.5px; + color: var(--app-placeholder); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .voter-vote { text-align: right; @@ -590,8 +552,8 @@ gap: 1px; flex: none; } - .voter-choice { font-size: 12px; font-weight: 600; } - .voter-when { font-size: 11px; color: var(--app-on-2); font-variant-numeric: tabular-nums; } + .voter-choice { font-size: 12.5px; font-weight: 600; } + .voter-when { font-size: 11px; color: var(--app-placeholder); font-variant-numeric: tabular-nums; } .app-foot { padding: 11px 16px 14px; @@ -716,21 +678,7 @@ gap: 9px; } - .privacy-line { - font-size: 11.5px; - color: var(--app-on-2); - display: flex; - gap: 6px; - align-items: baseline; - } - .privacy-line b { color: var(--app-warn); font-weight: 700; } - .vote-opt { - border: 1px solid var(--app-outline-2); - border-radius: 10px; - padding: 10px 13px; - font-size: 14px; - } /* ── Decision A/B ───────────────────────────────────────── */ .ab { @@ -1062,7 +1010,8 @@
BS
LN
RW
-
+130
+
CF
+
+129
35%
@@ -1074,7 +1023,9 @@
19%
@@ -1089,22 +1040,64 @@
-
Proposed — the same card, two additions
+
Proposed — the same card, one addition
Which relay should Amethyst suggest first to brand-new users?
-
nos.lol
-
relay.damus.io
-
purplepag.es
-
Poll votes are public. Anyone can see who voted for what.
-
-
View results
-
380 votes
+
+
+
+
+
nos.lol
+ +
175
+
46%
+
+
+
+
+
+
relay.damus.io
+ +
133
+
35%
+
+
+
+
+
+
purplepag.es
+ +
72
+
19%
+
+
+
+
+
380 votes ›

- Two changes to the card itself: a vote count beside the percentage, and the privacy - line placed where the choice is made rather than after it. The avatar stack and the - count both become the entry point to the full results screen. + One change to the card itself: the vote count beside each percentage. The count + under the options and the avatar stack both become the entry point to the full results + screen — so the +171 stops being a dead end.

@@ -1165,11 +1158,12 @@
175 of 380 voters
+
MK
+
AR
+
TS
+
JD
+
+171
+
46%
@@ -1182,10 +1176,12 @@
133 of 380 voters
+
BS
+
LN
+
RW
+
CF
+
+129
+
35%
@@ -1197,10 +1193,12 @@
72 of 380 voters
+
HM
+
NF
+
PS
+
EK
+
+68
+
19%
@@ -1219,75 +1217,69 @@ -
- Everyone ▾ - Relevance ▾ - ⚲ Search voters -
-
VP
+
VP
-
you you
-
npub1gcx…wq7d
+
you
+
Android + KMP. Nostr all day.
nos.lol
2d ago
-
BS
+
BS
-
Bruno Sá follows
-
bruno@nostrplebs.com
+
Bruno Sá
+
Relay operator in Lisbon. Runs a small paid relay.
relay.damus.io
2d ago
-
MK
+
MK
-
mira.k follows
-
npub18ht…2m4v
+
mira.k
+
Designer. Making nostr less frightening.
nos.lol
2d ago
-
AR
+
AR
-
Ana Reis follows
-
npub1t4qz…p09h
+
Ana Reis
+
Photographer, mostly film.
nos.lol
1d ago
-
RW
+
RW
relaywrangler
-
npub1kk9m…4zzt
+
Self-hosting everything since 2011.
purplepag.es
1d ago
-
LN
+
npub1q8f…j3xw
-
no profile found
relay.damus.io
22h ago
-
TS
+
TS
tomás
-
npub1w2ns…6vhc
+
café, código, bicicleta
nos.lol
14h ago
-
HM
+
HM
hodlmatrix
-
npub1xr7d…5ktp
+
21M or bust.
purplepag.es
9h ago
@@ -1351,20 +1343,23 @@
Filter the list, never the summary

Option chips scope the voter list only — the bars above stay put, so the totals can't - appear to change while you browse. Audience is Everyone / Following / Following + WoT, - reusing the follow set the tally already sorts by. + appear to change while you browse. An audience filter, a sort control and voter search + are deliberately held back; the list is ordered you → follows → rest, and it's + not yet clear which of the three people would actually reach for.

6
-
One row per person, with their choice on it
+
A voter row is just a user row

- The default is a flat list of everyone, so "who voted for what" is answered without - drilling into an option. You are pinned first, follows next — that ordering already - exists in filterTo. Multiple-choice rows list every option - that person picked. + Not a new row type — SlimListItem with exactly the slots + UserCompose fills: UserPicture, + UsernameDisplay, AboutDisplay. Only the trailing slot differs, + carrying the vote where the follow buttons normally sit. So the second line is the + person's about text, petnames and display-name emoji work, and the npub fallback + (row six) appears on its own when no profile has arrived — all for free.

@@ -1680,23 +1675,26 @@
-
VP
+
VP
-
you you
+
you
+
Android + KMP. Nostr all day.
nos.lol
2d ago
-
BS
+
BS
-
Bruno Sá follows
+
Bruno Sá
+
Relay operator in Lisbon.
relay.damus.io
2d ago
-
MK
+
MK
-
mira.k follows
+
mira.k
+
Designer. Making nostr less frightening.
nos.lol
2d ago
@@ -1789,18 +1787,23 @@
P2
-
The screenViewModel + shared composables + Android route + entry points from the card.
+
The screenViewModel + shared composables + the UserCompose trailing slot + Android route + entry points from the card.
The feature
P3
-
Filters, sort, search, completeness footer, privacy lineEverything in §02 notes 5 and 7.
-
Polish
+
Desktop deck columnSame ViewModel, same option bars, desktop's own user row.
+
Parity
P4
-
Desktop column, then zap pollsKind 6969 shares the shell but not the tally — it is weighted by sats, not by people.
-
Parity, then breadth
+
Audience filter, sort, voter searchHeld back on purpose — added once it's clear which of the three people reach for.
+
Only if wanted
+
+
+
P5
+
Zap pollsKind 6969 shares the shell but not the tally — it is weighted by sats, not by people.
+
Breadth