Skip to content

fix(polls): show the author their own results, read NIP-22 comments, cap fan-out - #658

Open
dmnyc wants to merge 2 commits into
barrydeen:mainfrom
dmnyc:fix/poll-details-and-nip22-comments
Open

dmnyc wants to merge 2 commits into
barrydeen:mainfrom
dmnyc:fix/poll-details-and-nip22-comments

Conversation

@dmnyc

@dmnyc dmnyc commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Closes #657.

Four things surfaced on one poll. Device tested on an A54.

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.

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

for (url in Nip88.parsePollRelays(poll)) {
    if (url !in sentUrls) relayPool.sendToRelayOrEphemeral(url, msg)
}

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.

…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 barrydeen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in E) → 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.
@dmnyc

dmnyc commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

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 site

Confirmed all three: MetadataFetcher.requestPollVotes / requestZapPollVotes (:244, :270) and SocialActionManager.publishPollVote (:562) were uncapped, and RichContent:1177 calls the first pair whenever a poll merely renders. So the 480-relay poll still opened ~480 ephemeral connections on scroll, exactly as you said.

Worth naming how I missed it: I grepped for parsePollRelays(poll) — with that literal parameter name — and these sites pass pollEvent. One word's difference hid three call sites, and the one I did find was the one that mattered least.

Took the shared-helper suggestion: Nip88.MAX_POLL_RELAY_HINTS with Nip88.cappedPollRelays / Nip69.cappedZapPollRelays sitting next to the parsers. All five connection-opening sites use them, and the local constant I'd put in FeedSubscriptionManager is gone. Grepping the raw parsers now returns nothing outside the two NIP files, so a future call site has to deliberately reach past a capped accessor.

2. Nested comments

Confirmed, and it's exactly the bug class this PR claimed to fix. The gate now accepts an uppercase E equal to the root when the event is a comment.

One deliberate deviation from your patch: I left A and I out. Their values are addressable coordinates and external identifiers rather than event ids, so they can never equal rootId — including them would read as support that isn't there. The code comment says so, and the coordinate comparison you mentioned is separate work.

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 e at a parent comment, uppercase E at the root), so every one would have been dropped by the old gate. They render with correct parentage now.

Minor

  • Nip22.isComment() is used by the fix, as you predicted.
  • Feed-side reply counts still ignoring kind 1111 (FEED_KINDS, EventRouter) — agreed that's follow-up scope; I'll open an issue so it doesn't get lost.

testDebugUnitTest passes.

dmnyc added a commit to dmnyc/dark-wisp that referenced this pull request Sep 16, 2026
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`.
dmnyc added a commit to zapcooking/zap_cooking_android that referenced this pull request Sep 16, 2026
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(polls): show results to the author, cap relay fan-out, add refresh

2 participants