Conversation
…cap fan-out Four fixes that surfaced together on one poll. The author couldn't see results on their own poll. `showResults` gated on `hasVoted || isEnded`, so the one person who can't vote on a poll — whoever posted it — had no way to see the tally. iOS has always had the author exception; this adds it. A reader who hasn't voted still sees options rather than numbers, so the running count can't sway their choice. The zap-poll variant gated on `hasVoted || isClosed || totalSats > 0`, which revealed results to everyone as soon as any sats landed. It now follows the same rule as a normal poll. Comment threads rendered as empty. The reply filter and the thread's event gate both admitted kind 1 only, so a note whose replies are all NIP-22 comments (kind 1111) showed "No replies yet" with a blank count. Every reply on the poll that prompted this was a 1111. Reading comments is enough to fix what's visible; composing a reply to a comment still builds a kind-1 event, which NIP-22 forbids, and is left for a follow-up along with the rest of the helper. Poll relay fan-out was uncapped. Every relay a poll advertised got an ephemeral connection, and a poll in the wild advertises 480 of them. Capped at 3, matching iOS, which gets complete tallies from that many. Also adds the per-choice voter breakdown to the details drawer, mirroring iOS: choices ordered by tally descending, each expanding into the list of voters who picked it. The voter data was already being collected with latest-wins revote handling — nothing was reading it.
barrydeen
left a comment
There was a problem hiding this comment.
Reviewed locally (branch compiles, testDebugUnitTest passes). The four changes do what the description says and the author-results / per-choice-voter work looks correct — but I think we need to address two things before merging.
1. The relay fan-out cap misses the path that actually triggers
The cap is applied only to the feed-engagement REQs in FeedSubscriptionManager. The identical uncapped loop is still live in MetadataFetcher.requestPollVotes / requestZapPollVotes (MetadataFetcher.kt:244, :270), and those fire via EventRepository.requestPollVotes from RichContent.kt:1177 — i.e. every time a poll merely renders on screen. So a poll advertising 480 relays still opens ~480 ephemeral connections the moment you scroll past it; that's the main trigger path and it's untouched. SocialActionManager.publishPollVote (SocialActionManager.kt:562) broadcasts the signed vote to every advertised relay with no cap either.
Suggested fix: expose the cap in one shared place (e.g. MAX_POLL_RELAY_HINTS in Nip88, or a small helper like Nip88.cappedPollRelays(poll, limit = 3)) and apply it at all four call sites — FeedSubscriptionManager (done), MetadataFetcher x2, and SocialActionManager x1. Otherwise the 480-relay poll still DoS-adjacently wedges the connection pool exactly as before.
2. Nested NIP-22 comments are admitted, then dropped by the root-validation check
ThreadViewModel's collector now admits kind 1111 (good), but the validation right after still requires a lowercase e tag with value == rootId:
if (event.id != rootId &&
event.tags.none { it.size >= 2 && it[0] == "e" && it[1] == rootId }) {
return@collect
}Per the NIP-22 spec, a comment scopes to its root with an uppercase E/A/I tag; the lowercase e tag names the immediate parent. So:
- top-level comments (parent == root, lowercase
e== rootId) → pass, render — matches the A54 test. - replies to comments (lowercase
e== parent comment, root only inE) → fail this check and get silently dropped.
Relay-side this isn't the problem (#e filters are case-insensitive per NIP-01, so nested comments do arrive) — we throw them away client-side. Net effect: comment threads render flat one level deep, which is exactly the bug class this PR is fixing. The PR body acknowledges the compose-side limitation but not this read-side gap.
Suggested fix: in the validation, accept uppercase root tags for comments, e.g.:
event.tags.none {
it.size >= 2 &&
((it[0] == "e" && it[1] == rootId) ||
(Nip22.isComment(event) && it[0] in listOf("E", "I", "A") && it[1] == rootId))
}(and for A tags, compare against 30023:...:d style addressables if the root is addressable). Nip10.getReplyTarget's legacy last-lowercase-e fallback already resolves nested parents correctly, so rebuildTree hooks them up fine once they get past the gate.
Minor (non-blocking)
Nip22.isComment()is currently unused dead code — would become used with the fix above.- Feed-side reply counts still ignore kind 1111 (
FEED_KINDS,EventRouter), so reply badges on comment-only threads stay wrong. Presumably follow-up scope, but worth an issue so it doesn't get lost.
Everything else LGs: getPollVoters correctly reuses the latest-wins voter state (no double counting on revote), the drawer keys re-read on tally change, and the voter breakdown exposes nothing that isn't already public in kind-1018 events.
Addresses review feedback. The cap only covered the feed-engagement REQs. The identical uncapped loops were still live in `MetadataFetcher.requestPollVotes` / `requestZapPollVotes`, which fire from `RichContent` whenever a poll merely renders — the main trigger path — and in `SocialActionManager.publishPollVote`, which broadcast a signed vote to every advertised relay. A 480-relay poll still opened ~480 ephemeral connections on scroll. The cap now lives in one place: `Nip88.MAX_POLL_RELAY_HINTS`, with `Nip88.cappedPollRelays` / `Nip69.cappedZapPollRelays` next to the parsers. All five connection-opening sites use them, and the raw parsers are now referenced nowhere outside the two NIP files, so a new call site has to reach past a capped accessor to get the uncapped list. Admitting kind 1111 wasn't enough on its own: the root check immediately after required a LOWERCASE `e` tag equal to the root. A NIP-22 comment scopes to its root with an uppercase `E`; lowercase `e` names its immediate parent. So top-level comments passed and every reply to a comment was dropped client-side, leaving threads flat one level deep. Relays weren't the problem — `#e` filters are case-insensitive per NIP-01, so those events do arrive. Uppercase `A` / `I` roots are deliberately not checked: their values are addressable coordinates and external identifiers rather than event ids, so they can never equal `rootId`. Threads rooted on those need coordinate comparison, which is separate work. Verified on emulator against a thread whose 25 comments are all nested — every one would have been dropped by the old gate.
|
Both blocking issues fixed in 3447be8. Thanks — the fan-out one is the more embarrassing miss of the two, and you were right that it left the main trigger path untouched. 1. Cap now applies at every call siteConfirmed all three: Worth naming how I missed it: I grepped for Took the shared-helper suggestion: 2. Nested commentsConfirmed, and it's exactly the bug class this PR claimed to fix. The gate now accepts an uppercase One deliberate deviation from your patch: I left Verified on an emulator rather than assuming. The poll that prompted this PR turned out to be useless as a test — 7 top-level comments, zero nested — so I searched relays for a thread that exercises the path. In a 400-event sample, 92 threads had nested comments, so this is common rather than an edge case. Opened the busiest: 25 comments scoped to that root, all 25 nested (lowercase Minor
|
Ports the review fixes from barrydeen/wisp#658; the same two defects are present here. The cap only covered the feed-engagement REQs. The identical uncapped loops were still live in `MetadataFetcher.requestPollVotes` / `requestZapPollVotes`, which fire from `RichContent` whenever a poll merely renders — the main trigger path — and in `SocialActionManager.publishPollVote`, which broadcast a signed vote to every advertised relay. The cap now lives in one place: `Nip88.MAX_POLL_RELAY_HINTS`, with `Nip88.cappedPollRelays` / `Nip69.cappedZapPollRelays` next to the parsers. All five connection-opening sites use them, and the raw parsers are referenced nowhere outside the two NIP files. Admitting kind 1111 wasn't enough on its own: the root check immediately after required a LOWERCASE `e` tag equal to the root. A NIP-22 comment scopes to its root with an uppercase `E`; lowercase `e` names its immediate parent. So top-level comments passed and every reply to a comment was dropped client-side, leaving threads flat one level deep. Uppercase `A` / `I` roots are deliberately not checked: their values are addressable coordinates and external identifiers rather than event ids, so they can never equal `rootId`.
Ports the review fixes from barrydeen#658; both defects are present here too, and the comment one predates that PR. The cap only covered the feed-engagement REQs. The identical uncapped loops were still live in `MetadataFetcher.requestPollVotes` / `requestZapPollVotes`, which fire from `RichContent` whenever a poll merely renders — the main trigger path — and in `SocialActionManager.publishPollVote`, which broadcast a signed vote to every advertised relay. The cap now lives in one place: `Nip88.MAX_POLL_RELAY_HINTS`, with `Nip88.cappedPollRelays` / `Nip69.cappedZapPollRelays` next to the parsers. All five connection-opening sites use them, and the raw parsers are referenced nowhere outside the two NIP files. This client already read kind 1111, but the root check required a LOWERCASE `e` tag equal to the root. A NIP-22 comment scopes to its root with an uppercase `E`; lowercase `e` names its immediate parent. So top-level comments passed and every reply to a comment was dropped client-side — comment threads have been rendering flat one level deep here all along, independently of the poll work. Uppercase `A` / `I` roots are deliberately not checked: their values are addressable coordinates and external identifiers rather than event ids, so they can never equal `rootId`.
Closes #657.
Four things surfaced on one poll. Device tested on an A54.
The author couldn't see results on their own poll
showResultsgated onhasVoted || isEnded, so the one person who can't vote on a poll — whoever posted it — had no way to see the tally. iOS has always had the author exception; this adds it.What deliberately doesn't change: a reader who hasn't voted still sees options rather than numbers. Showing the tally to everyone was considered and rejected, because the running count sways the vote.
The zap-poll variant gated on
hasVoted || isClosed || totalSats > 0, revealing results to everyone the moment any sats landed. It now follows the same rule.Comment threads rendered as empty
The reply filter and the thread's event gate both admitted kind 1 only. A note whose replies are all NIP-22 comments (kind 1111) showed "No replies yet" with a blank reply count. On the poll that prompted this, every reply was a 1111 — pulling them straight off the relays gives
{1111: 5}, not a single kind 1.Reading comments is all this changes. Composing a reply to a comment still builds a kind-1 event, which NIP-22 forbids — a reply to a comment must itself be kind 1111. That needs the rest of the helper and is left for a follow-up, so expect comments to render correctly but a reply posted to one to thread wrongly elsewhere.
Poll relay fan-out was uncapped
Every relay a poll advertises got an ephemeral connection, with no cap — and a poll in the wild advertises 480 of them. Capped at 3 for both poll kinds, matching iOS, which gets complete tallies from that many.
Per-choice voter breakdown
Added to the details drawer, mirroring iOS: choices ordered by tally descending, each expanding into the list of voters who picked it, tappable through to a profile. The voter data was already being collected — with latest-wins revote handling, so a revote moves someone rather than double-counting — and nothing was reading it.