Support Poll display#194
Conversation
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.
There was a problem hiding this comment.
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
pollpayloads (including defensive defaults) and add unit tests. - Frontend: add
Poll/PollOptiontypes, newPollResultscomponent + tests, and integrate intoPostAnimator(plus poll-onlyonReadybehavior). - 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
timeSinceonly formats past timestamps ("Xm ago") and returns "just now" for future times.PollResultsuses this forexpires_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.
| import { timeSince } from './Attribution'; | ||
| import { PANEL_CLASS } from './PostAnimator'; | ||
|
|
There was a problem hiding this comment.
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.
| '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'] ?? [], | ||
| ]; |
There was a problem hiding this comment.
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.
| sensitive_media: boolean; | ||
| poll?: Poll; | ||
| } |
There was a problem hiding this comment.
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.
|
Addressed both the low-confidence
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.
Summary
PostNormalizer::fromMastodon()normalizes poll data defensively (survives malformed/federated payloads, preserves an explicitnullper-option vote count for polls that hide results until closing)pollis simply absent on those postsPollResultscomponent wired into all threePostAnimatorrender 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 fireonReadyso the feed's auto-advance doesn't stallCloses #193
Design spec:
docs/superpowers/specs/2026-07-10-poll-display-design.mdImplementation plan:
docs/superpowers/plans/2026-07-10-poll-display.mdTest Plan
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)npx vitest run— 171/171 passing (includes 9PollResultstests + 5PostAnimatorpoll-integration tests across all 3 render branches, own-vote highlighting, hidden-vote handling, and theonReadyfix)tsc --noEmit, Pint, ESLint all clean