Skip to content

Support Poll display#194

Merged
aquarion merged 14 commits into
mainfrom
feature/poll-display
Jul 11, 2026
Merged

Support Poll display#194
aquarion merged 14 commits into
mainfrom
feature/poll-display

Conversation

@aquarion

Copy link
Copy Markdown
Owner

Summary

  • Displays Mastodon poll results (option bars with vote counts/percentages, total votes, open/closed status, own-vote highlighting) on posts that carry a poll, with an external "Vote →" link to the original post (voting stays on Mastodon — no in-app vote submission, no new OAuth scopes)
  • PostNormalizer::fromMastodon() normalizes poll data defensively (survives malformed/federated payloads, preserves an explicit null per-option vote count for polls that hide results until closing)
  • Bluesky-only posts are unaffected — AT Protocol has no native poll concept, so poll is simply absent on those posts
  • New PollResults component wired into all three PostAnimator render branches (image posts, panel-only posts, body-text posts), including a fix so poll-only posts (no body/media) render instead of silently disappearing, and correctly fire onReady so the feed's auto-advance doesn't stall

Closes #193

Design spec: docs/superpowers/specs/2026-07-10-poll-display-design.md
Implementation plan: docs/superpowers/plans/2026-07-10-poll-display.md

Test Plan

  • Backend: php artisan test --compact — 364/364 passing (includes 7 poll-specific normalization tests: full poll, no poll, expired multiple-choice with own votes, malformed/missing fields, missing options entirely, hidden per-option vote counts)
  • Frontend: npx vitest run — 171/171 passing (includes 9 PollResults tests + 5 PostAnimator poll-integration tests across all 3 render branches, own-vote highlighting, hidden-vote handling, and the onReady fix)
  • tsc --noEmit, Pint, ESLint all clean
  • Manual browser check with a live Mastodon poll post (not done — no seeded/demo poll data exists, feed only renders from a live OAuth-connected account)

aquarion added 12 commits July 10, 2026 22:13
Design for showing Mastodon poll results with an external vote link, before implementation.
Add null-coalescing fallbacks to normalizeMastodonPoll() matching the
defensive style used by sibling normalizer methods (mastodonReplyTo,
mastodonQuotedPost), since federated instances and forks can omit
nominally-required poll fields. Previously a poll missing options or
an option missing a title would throw an uncaught ErrorException and
break normalization for the entire post.
The panel fade-in useGSAP effect still gated on reply_to/quoted_post
only, so a poll-only post (no body, no media, no reply/quote) fell
through without ever calling onReady, stalling feed auto-advance.
array_key_exists (not ??) so an explicit null from Mastodon — sent for
options in an open multiple-choice poll to hide per-option counts —
survives normalization instead of collapsing to a fake zero-vote count.
This was silently breaking the frontend's "votes hidden" UI path, caught
by a whole-feature review after each task's isolated tests had passed.
@aquarion aquarion marked this pull request as ready for review July 11, 2026 16:12
Copilot AI review requested due to automatic review settings July 11, 2026 16:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end support for displaying Mastodon poll results in the feed by normalizing poll data on the backend, extending the frontend Post type, and rendering a new PollResults UI in all PostAnimator branches (including poll-only posts), with accompanying tests and design/plan docs.

Changes:

  • Backend: normalize Mastodon poll payloads (including defensive defaults) and add unit tests.
  • Frontend: add Poll/PollOption types, new PollResults component + tests, and integrate into PostAnimator (plus poll-only onReady behavior).
  • Docs: add design spec and implementation plan for poll display.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Unit/Feed/PostNormalizerTest.php Adds unit tests covering poll normalization scenarios (normal, missing, expired/multiple, malformed, hidden counts).
app/Services/Feed/PostNormalizer.php Normalizes Mastodon poll data into the feed post shape.
resources/js/types/post.ts Extends the Post type with optional poll support and defines Poll/PollOption.
resources/js/components/feed/Attribution.tsx Exports timeSince for reuse by poll UI.
resources/js/components/feed/PollResults.tsx New poll-results UI component (bars, counts/percentages, status, own-vote highlight, external vote link).
resources/js/components/feed/PollResults.test.tsx Unit tests for PollResults rendering and behaviors.
resources/js/components/feed/PostAnimator.tsx Integrates PollResults into image, panel-only, and body-text render branches; fixes poll-only posts rendering/onReady.
resources/js/components/feed/PostAnimator.poll.test.tsx Integration tests ensuring polls render across all PostAnimator branches and onReady fires for poll-only posts.
docs/superpowers/specs/2026-07-10-poll-display-design.md Design spec for poll display behavior and UI.
docs/superpowers/plans/2026-07-10-poll-display.md Detailed implementation plan and verification checklist.
Comments suppressed due to low confidence (1)

resources/js/components/feed/Attribution.tsx:10

  • timeSince only formats past timestamps ("Xm ago") and returns "just now" for future times. PollResults uses this for expires_at, so open polls will incorrectly show "Closes just now" instead of "Closes in …".
export function timeSince(dateStr: string): string {
    const seconds = Math.floor(
        (Date.now() - new Date(dateStr).getTime()) / 1000,
    );


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3 to +5
import { timeSince } from './Attribution';
import { PANEL_CLASS } from './PostAnimator';

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — extracted PANEL_CLASS to a new resources/js/components/feed/panel-class.ts module. Both PostAnimator.tsx and PollResults.tsx now import from there instead of one importing from the other. Commit d9357c8.

Comment thread app/Services/Feed/PostNormalizer.php Outdated
Comment on lines +242 to +260
'id' => $poll['id'] ?? null,
'expires_at' => $poll['expires_at'] ?? null,
'expired' => (bool) ($poll['expired'] ?? false),
'multiple' => (bool) ($poll['multiple'] ?? false),
'votes_count' => $poll['votes_count'] ?? 0,
'options' => array_map(
// array_key_exists (not ??) — Mastodon sends an explicit `null` for
// options in an open multiple-choice poll before it closes to hide
// per-option counts, and that null must survive to the frontend's
// "votes hidden" UI. `?? 0` would collapse it to a fake zero-vote count.
fn (array $opt) => [
'title' => $opt['title'] ?? '',
'votes_count' => array_key_exists('votes_count', $opt) ? $opt['votes_count'] : 0,
],
$poll['options'] ?? [],
),
'voted' => (bool) ($poll['voted'] ?? false),
'own_votes' => $poll['own_votes'] ?? [],
];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed both. Verified against actual Mastodon API — this is confirmed as a real gap, not theoretical: a malformed federated payload where options is non-array, or contains non-array entries, would throw a TypeError out of array_map's typed closure and break normalization for the whole post, contradicting the graceful-degradation goal. Now filters defensively (non-array options → empty list, non-array entries dropped). Also changed id to default to '' instead of null to match the frontend Poll.id: string contract. Added Pest tests for both. Commit d9357c8.

Comment on lines 83 to 85
sensitive_media: boolean;
poll?: Poll;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — changed to poll?: Poll | null to match the actual backend contract: Mastodon posts without a poll get an explicit null, Bluesky posts omit the key entirely. Commit d9357c8.

- timeSince(): future dates (poll expiry) always hit the `seconds < 60`
  branch since any negative number satisfies it, so open polls always
  showed "Closes just now" regardless of actual time remaining. Now
  branches on sign and formats future dates as "in Xm/Xh/Xd".
- normalizeMastodonPoll(): a malformed payload where `options` is
  non-array, or contains non-array entries, would throw a TypeError
  from array_map's typed closure instead of degrading gracefully.
  Now filters defensively.
- Default poll `id` to '' instead of null — the frontend Poll type
  declares `id: string`, and null violated that contract.
- Post.poll type changed from `poll?: Poll` to `poll?: Poll | null`,
  matching the real backend contract (explicit null for a Mastodon
  post with no poll, key entirely absent for Bluesky).
- Extracted PANEL_CLASS to a new panel-class.ts module, removing the
  circular import between PostAnimator.tsx and PollResults.tsx.

From GitHub Copilot's automated review on PR #194.
@aquarion

Copy link
Copy Markdown
Owner Author

Addressed both the low-confidence timeSince finding and all 3 inline comments from the automated review:

  • timeSince "Closes just now" bug: confirmed real — any future date hit the seconds < 60 branch since all negative numbers satisfy it, so open polls always showed "Closes just now" regardless of actual time remaining. Fixed to branch on sign and format future dates as "in Xm/Xh/Xd".
  • Circular import: extracted PANEL_CLASS to a new panel-class.ts module.
  • options TypeError risk + id: null mismatch: fixed both — malformed options payloads now degrade gracefully instead of throwing, and id defaults to '' to match the frontend's string contract.
  • poll?: Poll vs backend's explicit null: changed to poll?: Poll | null.

All fixed in d9357c8, with new regression tests. Full suite: 366 backend / 172 frontend, all green.

…193)

- normalizeMastodonPoll: log a warning (matching FeedAggregator's
  established convention for malformed third-party data) when a poll
  payload has non-array options or non-array option entries, instead
  of silently degrading with zero observability trail.
- Add @return array{...} PHPDoc shape to normalizeMastodonPoll, per
  CLAUDE.md's array-shape-in-PHPDoc guideline (matching 5 sibling
  private methods in the same file that already have one).
- Add dedicated Attribution.test.tsx covering timeSince() directly
  (past/future, all four duration bands, zero-second boundary) —
  previously only indirectly exercised at a single 1-hour mark via
  PollResults.test.tsx.
- Add a zero-vote poll test to PollResults.test.tsx (division-by-zero
  guard was correct by inspection but unasserted).
- Add a poll + reply_to + link_url combined-panel test to
  PostAnimator.poll.test.tsx — the code already supports this
  combination but it was never exercised together.
- Add readonly modifiers to Poll/PollOption (first use of readonly in
  post.ts; zero-risk since nothing in the codebase mutates these
  fields, and object-spread test patterns are unaffected).

From /pr-review-toolkit:review-pr (5 parallel agents: code-reviewer,
pr-test-analyzer, comment-analyzer, silent-failure-hunter,
type-design-analyzer) on PR #194.
@aquarion aquarion merged commit c392554 into main Jul 11, 2026
5 checks passed
@aquarion aquarion deleted the feature/poll-display branch July 11, 2026 17:29
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.

Support Poll display

2 participants