From 1c8f2778734754ea3f6f7c659f420170d24bb8ff Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Fri, 10 Jul 2026 22:13:53 +0000 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=93=96=20Add=20poll=20display=20des?= =?UTF-8?q?ign=20spec=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for showing Mastodon poll results with an external vote link, before implementation. --- .../specs/2026-07-10-poll-display-design.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-poll-display-design.md diff --git a/docs/superpowers/specs/2026-07-10-poll-display-design.md b/docs/superpowers/specs/2026-07-10-poll-display-design.md new file mode 100644 index 0000000..c18d656 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-poll-display-design.md @@ -0,0 +1,108 @@ +# Poll Display Design + +**Date:** 2026-07-10 +**Issue:** #193 — "Support Poll display" + +## Problem + +Posts in the feed can carry a poll (Mastodon status API `poll` field), but Bloom currently drops that data entirely during normalization — polls are invisible in the feed. + +## Goal + +When a post contains a poll, display the current results (options, vote counts/percentages, total votes, open/closed status, and the connected account's own vote if any) with a "Vote" button that links out to the original post, where voting actually happens. + +## Scope + +- **Mastodon only.** Bluesky's AT Protocol has no native poll lexicon today (confirmed via `PostNormalizer::fromBluesky` and a graphify-assisted codebase scan — no `poll` references anywhere in `app/Services`). The `Post.poll` field is optional everywhere; Bluesky-sourced posts simply never populate it. If Bluesky adds poll support later, this is a normalizer-only change. +- **External voting only.** The vote button opens `post.original_url` in a new tab (same pattern as `Attribution.tsx`). No in-app vote submission, no new OAuth scopes, no new API endpoints, no migrations. + +## Design + +### 1. Data model — `resources/js/types/post.ts` + +```ts +interface PollOption { + title: string; + votes_count: number | null; // null if votes are hidden until the poll closes +} + +interface Poll { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: PollOption[]; + voted: boolean; + own_votes: number[]; // indices into options[] +} + +interface Post { + // ...existing fields + poll?: Poll; +} +``` + +### 2. Backend normalization — `app/Services/Feed/PostNormalizer.php` + +New private helper, called from `fromMastodon()` only: + +```php +private function normalizePoll(array $source): ?array +{ + $poll = $source['poll'] ?? null; + if ($poll === null) { + return null; + } + + return [ + 'id' => $poll['id'], + 'expires_at' => $poll['expires_at'], + 'expired' => $poll['expired'], + 'multiple' => $poll['multiple'], + 'votes_count' => $poll['votes_count'], + 'options' => array_map( + fn (array $opt) => ['title' => $opt['title'], 'votes_count' => $opt['votes_count']], + $poll['options'], + ), + 'voted' => $poll['voted'] ?? false, + 'own_votes' => $poll['own_votes'] ?? [], + ]; +} +``` + +`fromBluesky()` is untouched — the `poll` key is simply absent from Bluesky-normalized posts. + +### 3. Frontend component — `resources/js/components/feed/PollResults.tsx` (new) + +- Each option renders as a horizontal bar, width = `votes_count / poll.votes_count * 100%`, labeled with title, vote count, and percentage. +- If `poll.voted`, the option(s) at indices in `poll.own_votes` get a distinguishing style (checkmark icon + bold border), reusing existing badge/icon primitives from `resources/js/components/ui/`. +- Header row: `"{votes_count} votes total"` plus status — `"Poll closed"` if `expired`, else relative time until `expires_at` (reuse the existing relative-time formatting already used elsewhere in `PostAnimator.tsx`). +- If `poll.multiple`, show a small "multiple choice" label near the header (informational only — no interactive checkboxes, since in-app voting is out of scope). +- Footer: `Vote →`, styled consistently with `Attribution.tsx`'s external-link treatment. + +### 4. Rendering integration — `resources/js/components/feed/PostAnimator.tsx` + +Poll renders as its own block, after body text and before the link card, inside the same content-warning-gated block as the rest of the post body (a CW'd post keeps its poll hidden until the overlay is dismissed): + +```tsx +{post.body} +{post.poll && } +{/* existing context panels */} +{/* existing link card */} +``` + +## Testing Plan + +- **Backend** (`tests/Unit/Feed/PostNormalizerTest.php`, existing file): Mastodon status with a poll normalizes correctly; status without a poll → `poll` key absent/null; multiple-choice poll; expired poll; poll where the connected account has voted (`voted: true`, `own_votes` populated). +- **Frontend** (`resources/js/components/feed/PollResults.test.tsx`, new — matches the `ImageCarousel.test.tsx` / `ProgressBar.test.tsx` per-component convention): bars render with correct percentages; "Poll closed" vs relative expiry text; own-vote highlight; vote link points to `original_url`. +- **Integration**: extend the existing feed smoke/browser test that exercises `PostContent` to cover a Mastodon post with a poll, asserting it renders without JS errors. + +## Files to Change + +- `resources/js/types/post.ts` — add `Poll`, `PollOption`, `Post.poll` +- `app/Services/Feed/PostNormalizer.php` — add `normalizePoll()`, call from `fromMastodon()` +- `resources/js/components/feed/PollResults.tsx` — new component +- `resources/js/components/feed/PollResults.test.tsx` — new test +- `resources/js/components/feed/PostAnimator.tsx` — render `PollResults` conditionally +- `tests/Unit/Feed/PostNormalizerTest.php` — new poll test cases From 07d2560495d65316b1b1a0bd27b283168bfc3c7b Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Fri, 10 Jul 2026 22:17:14 +0000 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=93=96=20Add=20poll=20display=20imp?= =?UTF-8?q?lementation=20plan=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-10-poll-display.md | 825 ++++++++++++++++++ 1 file changed, 825 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-poll-display.md diff --git a/docs/superpowers/plans/2026-07-10-poll-display.md b/docs/superpowers/plans/2026-07-10-poll-display.md new file mode 100644 index 0000000..d29f05c --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-poll-display.md @@ -0,0 +1,825 @@ +# Poll Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show Mastodon poll results (options, vote counts/percentages, total votes, open/closed status, own vote) on posts that carry a poll, with a "Vote" link that opens the original post. + +**Architecture:** Add an optional `poll` field to the normalized `Post` shape (backend `PostNormalizer::fromMastodon`, frontend `Post` type). Render it via a new standalone `PollResults` component, wired into all three render branches of `PostAnimator.tsx` (image posts, no-body/panel-only posts, body-text posts). Bluesky posts never populate `poll` — no Bluesky-side changes. + +**Tech Stack:** Laravel/PHP (Pest tests), React/TypeScript (Vitest + Testing Library), Tailwind, lucide-react icons. + +Spec: `docs/superpowers/specs/2026-07-10-poll-display-design.md` + +--- + +### Task 1: Backend — normalize Mastodon poll data + +**Files:** +- Modify: `app/Services/Feed/PostNormalizer.php` +- Test: `tests/Unit/Feed/PostNormalizerTest.php` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/Unit/Feed/PostNormalizerTest.php`: + +```php +it('normalises a mastodon poll', function () { + $status = [ + 'id' => '555', + 'content' => '

Best editor?

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/555', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '12345', + 'expires_at' => '2024-01-16T10:00:00.000Z', + 'expired' => false, + 'multiple' => false, + 'votes_count' => 30, + 'options' => [ + ['title' => 'Vim', 'votes_count' => 20], + ['title' => 'Emacs', 'votes_count' => 10], + ], + 'voted' => false, + 'own_votes' => [], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll'])->toBe([ + 'id' => '12345', + 'expires_at' => '2024-01-16T10:00:00.000Z', + 'expired' => false, + 'multiple' => false, + 'votes_count' => 30, + 'options' => [ + ['title' => 'Vim', 'votes_count' => 20], + ['title' => 'Emacs', 'votes_count' => 10], + ], + 'voted' => false, + 'own_votes' => [], + ]); +}); + +it('sets poll to null when a mastodon status has no poll', function () { + $status = [ + 'id' => '556', + 'content' => '

No poll here

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/556', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll'])->toBeNull(); +}); + +it('normalises an expired multiple-choice mastodon poll with own votes', function () { + $status = [ + 'id' => '557', + 'content' => '

Pick your toppings

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/557', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '999', + 'expires_at' => '2024-01-14T10:00:00.000Z', + 'expired' => true, + 'multiple' => true, + 'votes_count' => 5, + 'options' => [ + ['title' => 'Cheese', 'votes_count' => 3], + ['title' => 'Pepperoni', 'votes_count' => 2], + ], + 'voted' => true, + 'own_votes' => [0, 1], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll']['expired'])->toBeTrue() + ->and($post['poll']['multiple'])->toBeTrue() + ->and($post['poll']['voted'])->toBeTrue() + ->and($post['poll']['own_votes'])->toBe([0, 1]); +}); + +it('sets poll to null for a bluesky post', function () { + $feedPost = [ + 'post' => [ + 'uri' => 'at://did:plc:abc/app.bsky.feed.post/xyz', + 'record' => [ + 'text' => 'hello bluesky', + 'createdAt' => '2024-01-15T10:00:00.000Z', + ], + 'author' => [ + 'did' => 'did:plc:abc', + 'handle' => 'user.bsky.social', + 'displayName' => 'Test User', + 'avatar' => 'https://example.com/avatar.jpg', + ], + ], + ]; + + $post = (new PostNormalizer)->fromBluesky($feedPost); + + expect($post)->not->toHaveKey('poll'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `php artisan test --compact --filter="poll"` +Expected: FAIL — `$post['poll']` is undefined (the `fromMastodon` array has no `poll` key yet). + +- [ ] **Step 3: Implement `normalizePoll` and wire it into `fromMastodon`** + +In `app/Services/Feed/PostNormalizer.php`, add this private method after `mastodonQuotedPost` (after line 230, before `blueskyReplyTo` at line 232): + +```php + private function normalizeMastodonPoll(array $source): ?array + { + $poll = $source['poll'] ?? null; + + if ($poll === null) { + return null; + } + + return [ + 'id' => $poll['id'], + 'expires_at' => $poll['expires_at'], + 'expired' => (bool) $poll['expired'], + 'multiple' => (bool) $poll['multiple'], + 'votes_count' => $poll['votes_count'], + 'options' => array_map( + fn (array $opt) => [ + 'title' => $opt['title'], + 'votes_count' => $opt['votes_count'], + ], + $poll['options'], + ), + 'voted' => (bool) ($poll['voted'] ?? false), + 'own_votes' => $poll['own_votes'] ?? [], + ]; + } +``` + +Then in `fromMastodon()` (the returned array, after the `'quoted_post'` line at line 57), add: + +```php + 'poll' => $this->normalizeMastodonPoll($source), +``` + +So the array around lines 56-58 reads: + +```php + 'reply_to' => $this->mastodonReplyTo($parentStatus, $host, $mentionsEnabled), + 'quoted_post' => $this->mastodonQuotedPost($source, $host, $quoteStatus, $mentionsEnabled), + 'poll' => $this->normalizeMastodonPoll($source), + 'boosted_by' => $booster, +``` + +Do **not** modify `fromBluesky()` — its returned array simply has no `poll` key, which is what the last test asserts. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `php artisan test --compact --filter="poll"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Run the full PostNormalizer test suite to check for regressions** + +Run: `php artisan test --compact tests/Unit/Feed/PostNormalizerTest.php` +Expected: PASS (all tests, including the 4 new ones) + +- [ ] **Step 6: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Services/Feed/PostNormalizer.php tests/Unit/Feed/PostNormalizerTest.php +git commit -m "🎇 Normalize Mastodon poll data in PostNormalizer (#193)" +``` + +--- + +### Task 2: Frontend — add `Poll`/`PollOption` types + +**Files:** +- Modify: `resources/js/types/post.ts` + +- [ ] **Step 1: Add the types and the `poll` field** + +In `resources/js/types/post.ts`, add after the `QuotedPost` interface (after line 33, before `export interface Post`): + +```ts +export interface PollOption { + title: string; + votes_count: number | null; +} + +export interface Poll { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: PollOption[]; + voted: boolean; + own_votes: number[]; +} +``` + +Then add `poll?: Poll;` to the `Post` interface, immediately after the `sensitive_media: boolean;` line (line 66): + +```ts + sensitive_media: boolean; + poll?: Poll; +} +``` + +- [ ] **Step 2: Verify the project still type-checks** + +Run: `npx tsc --noEmit` +Expected: no new errors (there are no consumers of `Post.poll` yet, so this should be clean) + +- [ ] **Step 3: Commit** + +```bash +git add resources/js/types/post.ts +git commit -m "🎇 Add Poll type to Post shape (#193)" +``` + +--- + +### Task 3: Frontend — export `timeSince` from Attribution.tsx for reuse + +**Files:** +- Modify: `resources/js/components/feed/Attribution.tsx:6` + +- [ ] **Step 1: Export the existing `timeSince` helper** + +In `resources/js/components/feed/Attribution.tsx`, change line 6 from: + +```ts +function timeSince(dateStr: string): string { +``` + +to: + +```ts +export function timeSince(dateStr: string): string { +``` + +No other change — the function body and all existing call sites in this file are unaffected. + +- [ ] **Step 2: Verify existing Attribution tests still pass** + +Run: `npx vitest run resources/js/components/feed/Attribution.test.tsx` +Expected: PASS (if this test file doesn't exist yet, run `npx vitest run resources/js/components/feed` instead and confirm no regressions) + +- [ ] **Step 3: Commit** + +```bash +git add resources/js/components/feed/Attribution.tsx +git commit -m "🔄️ Export timeSince helper from Attribution for reuse (#193)" +``` + +--- + +### Task 4: Frontend — `PollResults` component + +**Files:** +- Create: `resources/js/components/feed/PollResults.tsx` +- Test: `resources/js/components/feed/PollResults.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `resources/js/components/feed/PollResults.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { Poll } from '@/types/post'; +import { PollResults } from './PollResults'; + +const basePoll: Poll = { + id: '1', + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + expired: false, + multiple: false, + votes_count: 30, + options: [ + { title: 'Vim', votes_count: 20 }, + { title: 'Emacs', votes_count: 10 }, + ], + voted: false, + own_votes: [], +}; + +describe('PollResults', () => { + it('renders each option with its vote count and percentage', () => { + render( + , + ); + + expect(screen.getByText('Vim')).toBeInTheDocument(); + expect(screen.getByText(/20 votes/)).toBeInTheDocument(); + expect(screen.getByText(/67%/)).toBeInTheDocument(); + expect(screen.getByText('Emacs')).toBeInTheDocument(); + expect(screen.getByText(/10 votes/)).toBeInTheDocument(); + expect(screen.getByText(/33%/)).toBeInTheDocument(); + }); + + it('shows total vote count', () => { + render( + , + ); + + expect(screen.getByText(/30 votes total/)).toBeInTheDocument(); + }); + + it('shows "Poll closed" for an expired poll', () => { + const expired: Poll = { ...basePoll, expired: true }; + render( + , + ); + + expect(screen.getByText('Poll closed')).toBeInTheDocument(); + }); + + it('does not show "Poll closed" for an open poll', () => { + render( + , + ); + + expect(screen.queryByText('Poll closed')).not.toBeInTheDocument(); + }); + + it('shows a multiple-choice label when the poll allows multiple selections', () => { + const multi: Poll = { ...basePoll, multiple: true }; + render( + , + ); + + expect(screen.getByText(/multiple choice/i)).toBeInTheDocument(); + }); + + it('highlights the option(s) the connected account voted for', () => { + const voted: Poll = { ...basePoll, voted: true, own_votes: [0] }; + render( + , + ); + + expect(screen.getByTestId('poll-option-0')).toHaveAttribute( + 'data-voted', + 'true', + ); + expect(screen.getByTestId('poll-option-1')).toHaveAttribute( + 'data-voted', + 'false', + ); + }); + + it('renders a vote link pointing at the original post', () => { + render( + , + ); + + const link = screen.getByRole('link', { name: /vote/i }); + expect(link).toHaveAttribute('href', 'https://example.com/post/1'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run resources/js/components/feed/PollResults.test.tsx` +Expected: FAIL — `Cannot find module './PollResults'` + +- [ ] **Step 3: Implement `PollResults.tsx`** + +Create `resources/js/components/feed/PollResults.tsx`: + +```tsx +import { Check } from 'lucide-react'; +import type { Poll } from '@/types/post'; +import { timeSince } from './Attribution'; + +const PANEL_CLASS = + 'max-w-[40ch] rounded border border-white/20 bg-black/40 px-4 py-3 text-left text-sm text-white/70 backdrop-blur-sm'; + +function pollStatus(poll: Poll): string { + if (poll.expired) { + return 'Poll closed'; + } + + if (!poll.expires_at) { + return 'Poll open'; + } + + return `Closes ${timeSince(poll.expires_at)}`; +} + +export function PollResults({ + poll, + originalUrl, +}: { + poll: Poll; + originalUrl: string; +}) { + const total = poll.votes_count; + + return ( +
+
+ {pollStatus(poll)} + {poll.multiple && Multiple choice} +
+
+ {poll.options.map((option, index) => { + const votes = option.votes_count ?? 0; + const pct = total > 0 ? Math.round((votes / total) * 100) : 0; + const isOwnVote = poll.voted && poll.own_votes.includes(index); + + return ( +
+
+
+ + {isOwnVote && ( + + )} + {option.title} + + + {votes} votes ({pct}%) + +
+
+ ); + })} +
+
+ + {total} votes total + + + Vote → + +
+
+ ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run resources/js/components/feed/PollResults.test.tsx` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add resources/js/components/feed/PollResults.tsx resources/js/components/feed/PollResults.test.tsx +git commit -m "🎇 Add PollResults component (#193)" +``` + +--- + +### Task 5: Wire `PollResults` into `PostAnimator.tsx` + +**Files:** +- Modify: `resources/js/components/feed/PostAnimator.tsx` + +`PostAnimator` has three separate render paths, and a poll-only post (no body, no media) currently falls through to `return null` — that path needs a poll check added too. + +- [ ] **Step 1: Import `PollResults`** + +At the top of `resources/js/components/feed/PostAnimator.tsx`, add the import after the `ImageCarousel` import (line 14): + +```tsx +import { ImageCarousel } from './ImageCarousel'; +import { MentionChips } from './MentionChips'; +import { PollResults } from './PollResults'; +``` + +(`MentionChips` import moves down one line; `PollResults` is inserted alphabetically between them.) + +- [ ] **Step 2: Include `post.poll` in the "nothing to show" early-`onReady` check** + +Change the `useLayoutEffect` at lines 226-234 from: + +```tsx + useLayoutEffect(() => { + if ( + !body && + !(post.reply_to || post.quoted_post) && + post.media.length === 0 + ) { + onReadyRef.current?.(); + } + }, [body, post.reply_to, post.quoted_post, post.media.length]); +``` + +to: + +```tsx + useLayoutEffect(() => { + if ( + !body && + !(post.reply_to || post.quoted_post || post.poll) && + post.media.length === 0 + ) { + onReadyRef.current?.(); + } + }, [body, post.reply_to, post.quoted_post, post.poll, post.media.length]); +``` + +- [ ] **Step 3: Render the poll in the image-post branch** + +In the `post.media.length > 0` branch, the card currently renders body text, then (conditionally) a panel block with reply/quote/link. Change: + +```tsx + {post.body && ( +
+ +
+ )} + {(post.reply_to || post.quoted_post || post.link_url) && ( +``` + +to: + +```tsx + {post.body && ( +
+ +
+ )} + {post.poll && ( +
+ +
+ )} + {(post.reply_to || post.quoted_post || post.link_url) && ( +``` + +- [ ] **Step 4: Render the poll in the no-body branch (and fix the poll-only fall-through)** + +The `if (!body)` branch currently only renders reply/quote/link panels and falls through to `return null` if none exist. Change the condition (originally at line 491): + +```tsx + if (post.link_url || post.quoted_post || post.reply_to) { + return ( +
+
+``` + +to: + +```tsx + if (post.link_url || post.quoted_post || post.reply_to || post.poll) { + return ( +
+
+``` + +and add the poll block inside that same `panelsRef` div, right before the `{post.link_url && }` block (originally lines 522-528): + +```tsx + {post.poll && ( + + )} + {post.link_url && ( + + )} +``` + +- [ ] **Step 5: Render the poll in the body-text branch** + +In the final return block (the animated text case), add the poll right after the text `div` and before the existing `{post.link_url && }` block (originally starting at line 605): + +```tsx +
+ {post.poll && ( + + )} + {post.link_url && ( + + )} +``` + +- [ ] **Step 6: Write a rendering test covering all three branches** + +Create `resources/js/components/feed/PostAnimator.poll.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Post } from '@/types/post'; +import { PostAnimator } from './PostAnimator'; + +const basePost: Post = { + id: 'p1', + source: 'mastodon', + source_handle: null, + source_instance: 'mastodon.example', + author_name: 'Test User', + author_handle: '@user@mastodon.example', + author_avatar: '', + author_banner: null, + body: '', + media: [], + created_at: new Date().toISOString(), + original_url: 'https://mastodon.example/@user/1', + link_url: null, + link_title: null, + link_favicon: null, + reply_to: null, + quoted_post: null, + boosted_by: null, + boosted_by_avatar: null, + boosted_by_handle: null, + boosted_by_created_at: null, + emojis: {}, + hashtags: [], + chip_mentions: [], + cw_text: null, + cw_is_author_level: false, + cw_label_source: null, + sensitive_media: false, +}; + +const poll = { + id: '1', + expires_at: null, + expired: false, + multiple: false, + votes_count: 10, + options: [ + { title: 'Yes', votes_count: 7 }, + { title: 'No', votes_count: 3 }, + ], + voted: false, + own_votes: [], +}; + +describe('PostAnimator — poll rendering', () => { + it('renders poll results for a poll-only post (no body, no media)', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('No')).toBeInTheDocument(); + }); + + it('renders poll results alongside body text', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + + it('renders poll results on an image post', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + + it('renders nothing but does not crash for a post with no body, no media, and no poll', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); +``` + +- [ ] **Step 7: Run the new test to verify it passes** + +Run: `npx vitest run resources/js/components/feed/PostAnimator.poll.test.tsx` +Expected: PASS (4 tests) + +- [ ] **Step 8: Run the full frontend test suite to check for regressions** + +Run: `npx vitest run` +Expected: PASS (all existing tests plus the new ones) + +- [ ] **Step 9: Commit** + +```bash +git add resources/js/components/feed/PostAnimator.tsx resources/js/components/feed/PostAnimator.poll.test.tsx +git commit -m "🎇 Render poll results in PostAnimator (#193)" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run the full backend test suite** + +Run: `php artisan test --compact` +Expected: PASS + +- [ ] **Step 2: Run the full frontend test suite** + +Run: `npx vitest run` +Expected: PASS + +- [ ] **Step 3: Run Pint and ESLint/Biome checks** + +```bash +vendor/bin/pint --format agent +npx eslint resources/js/components/feed/PollResults.tsx resources/js/components/feed/PostAnimator.tsx resources/js/components/feed/Attribution.tsx +``` + +Expected: no errors (fix any and re-commit if needed) + +- [ ] **Step 4: Manually verify in the browser** + +Start the dev server (`composer run dev` or ask the user if already running), open the app, and confirm: +- A Mastodon post with an open poll shows option bars, percentages, and total votes +- A Mastodon post with an expired poll shows "Poll closed" +- The "Vote →" link opens `original_url` in a new tab +- A poll on a post the connected account already voted on highlights the chosen option(s) +- Bluesky posts are unaffected (no poll ever renders) + +This is a manual check — testing tools cannot verify visual rendering, so explicitly confirm this step was done (or was skipped and why) when reporting completion. From 0d533dd856abe53b48e548c5c10b49e37521f067 Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Fri, 10 Jul 2026 22:19:39 +0000 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=8E=87=20Normalize=20Mastodon=20pol?= =?UTF-8?q?l=20data=20in=20PostNormalizer=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/Feed/PostNormalizer.php | 27 ++++++ tests/Unit/Feed/PostNormalizerTest.php | 117 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/app/Services/Feed/PostNormalizer.php b/app/Services/Feed/PostNormalizer.php index 864de15..572b9d1 100644 --- a/app/Services/Feed/PostNormalizer.php +++ b/app/Services/Feed/PostNormalizer.php @@ -55,6 +55,7 @@ public function fromMastodon(array $status, string $host, ?array $parentStatus = 'link_favicon' => $this->faviconUrl($linkUrl), 'reply_to' => $this->mastodonReplyTo($parentStatus, $host, $mentionsEnabled), 'quoted_post' => $this->mastodonQuotedPost($source, $host, $quoteStatus, $mentionsEnabled), + 'poll' => $this->normalizeMastodonPoll($source), 'boosted_by' => $booster, 'boosted_by_avatar' => $boosterAccount ? $this->safeUrl($boosterAccount['avatar'] ?? '') : null, 'boosted_by_handle' => $boosterAccount ? '@'.$boosterAccount['acct'] : null, @@ -229,6 +230,32 @@ private function mastodonQuotedPost(array $source, string $host, ?array $quoteSt ]; } + private function normalizeMastodonPoll(array $source): ?array + { + $poll = $source['poll'] ?? null; + + if ($poll === null) { + return null; + } + + return [ + 'id' => $poll['id'], + 'expires_at' => $poll['expires_at'], + 'expired' => (bool) $poll['expired'], + 'multiple' => (bool) $poll['multiple'], + 'votes_count' => $poll['votes_count'], + 'options' => array_map( + fn (array $opt) => [ + 'title' => $opt['title'], + 'votes_count' => $opt['votes_count'], + ], + $poll['options'], + ), + 'voted' => (bool) ($poll['voted'] ?? false), + 'own_votes' => $poll['own_votes'] ?? [], + ]; + } + private function blueskyReplyTo(?array $parent, bool $mentionsEnabled): ?array { if ($parent === null || ! isset($parent['record']['text'])) { diff --git a/tests/Unit/Feed/PostNormalizerTest.php b/tests/Unit/Feed/PostNormalizerTest.php index d7b0e9e..605cdba 100644 --- a/tests/Unit/Feed/PostNormalizerTest.php +++ b/tests/Unit/Feed/PostNormalizerTest.php @@ -2450,3 +2450,120 @@ expect($post['cw_label_source'])->toBe('self'); }); + +it('normalises a mastodon poll', function () { + $status = [ + 'id' => '555', + 'content' => '

Best editor?

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/555', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '12345', + 'expires_at' => '2024-01-16T10:00:00.000Z', + 'expired' => false, + 'multiple' => false, + 'votes_count' => 30, + 'options' => [ + ['title' => 'Vim', 'votes_count' => 20], + ['title' => 'Emacs', 'votes_count' => 10], + ], + 'voted' => false, + 'own_votes' => [], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll'])->toBe([ + 'id' => '12345', + 'expires_at' => '2024-01-16T10:00:00.000Z', + 'expired' => false, + 'multiple' => false, + 'votes_count' => 30, + 'options' => [ + ['title' => 'Vim', 'votes_count' => 20], + ['title' => 'Emacs', 'votes_count' => 10], + ], + 'voted' => false, + 'own_votes' => [], + ]); +}); + +it('sets poll to null when a mastodon status has no poll', function () { + $status = [ + 'id' => '556', + 'content' => '

No poll here

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/556', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll'])->toBeNull(); +}); + +it('normalises an expired multiple-choice mastodon poll with own votes', function () { + $status = [ + 'id' => '557', + 'content' => '

Pick your toppings

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/557', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '999', + 'expires_at' => '2024-01-14T10:00:00.000Z', + 'expired' => true, + 'multiple' => true, + 'votes_count' => 5, + 'options' => [ + ['title' => 'Cheese', 'votes_count' => 3], + ['title' => 'Pepperoni', 'votes_count' => 2], + ], + 'voted' => true, + 'own_votes' => [0, 1], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll']['expired'])->toBeTrue() + ->and($post['poll']['multiple'])->toBeTrue() + ->and($post['poll']['voted'])->toBeTrue() + ->and($post['poll']['own_votes'])->toBe([0, 1]); +}); + +it('sets poll to null for a bluesky post', function () { + $feedPost = [ + 'post' => [ + 'uri' => 'at://did:plc:abc/app.bsky.feed.post/xyz', + 'record' => [ + 'text' => 'hello bluesky', + 'createdAt' => '2024-01-15T10:00:00.000Z', + ], + 'author' => [ + 'did' => 'did:plc:abc', + 'handle' => 'user.bsky.social', + 'displayName' => 'Test User', + 'avatar' => 'https://example.com/avatar.jpg', + ], + ], + ]; + + $post = (new PostNormalizer)->fromBluesky($feedPost); + + expect($post)->not->toHaveKey('poll'); +}); From a4eb1986b8da7add70187cdee0bea08097fa87ff Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 09:57:56 +0100 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=AA=B3=20Guard=20normalizeMastodonP?= =?UTF-8?q?oll=20against=20malformed=20poll=20payloads=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/Services/Feed/PostNormalizer.php | 16 +++---- tests/Unit/Feed/PostNormalizerTest.php | 59 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/app/Services/Feed/PostNormalizer.php b/app/Services/Feed/PostNormalizer.php index 572b9d1..0dcdfd2 100644 --- a/app/Services/Feed/PostNormalizer.php +++ b/app/Services/Feed/PostNormalizer.php @@ -239,17 +239,17 @@ private function normalizeMastodonPoll(array $source): ?array } return [ - 'id' => $poll['id'], - 'expires_at' => $poll['expires_at'], - 'expired' => (bool) $poll['expired'], - 'multiple' => (bool) $poll['multiple'], - 'votes_count' => $poll['votes_count'], + '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( fn (array $opt) => [ - 'title' => $opt['title'], - 'votes_count' => $opt['votes_count'], + 'title' => $opt['title'] ?? '', + 'votes_count' => $opt['votes_count'] ?? 0, ], - $poll['options'], + $poll['options'] ?? [], ), 'voted' => (bool) ($poll['voted'] ?? false), 'own_votes' => $poll['own_votes'] ?? [], diff --git a/tests/Unit/Feed/PostNormalizerTest.php b/tests/Unit/Feed/PostNormalizerTest.php index 605cdba..4e7e23e 100644 --- a/tests/Unit/Feed/PostNormalizerTest.php +++ b/tests/Unit/Feed/PostNormalizerTest.php @@ -2567,3 +2567,62 @@ expect($post)->not->toHaveKey('poll'); }); + +it('gracefully degrades a malformed mastodon poll payload missing fields', function () { + $status = [ + 'id' => '558', + 'content' => '

Malformed poll

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/558', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '111', + // expires_at, expired, multiple, votes_count, voted, own_votes all missing. + 'options' => [ + ['title' => 'Only option missing votes_count'], + ['votes_count' => 4], + ], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll'])->toBe([ + 'id' => '111', + 'expires_at' => null, + 'expired' => false, + 'multiple' => false, + 'votes_count' => 0, + 'options' => [ + ['title' => 'Only option missing votes_count', 'votes_count' => 0], + ['title' => '', 'votes_count' => 4], + ], + 'voted' => false, + 'own_votes' => [], + ]); +}); + +it('gracefully degrades a mastodon poll payload missing options entirely', function () { + $status = [ + 'id' => '559', + 'content' => '

Poll missing options

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/559', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '222', + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll']['options'])->toBe([]); +}); From 35b6b1a1f4ab2cbd37ff23f246e0f210a27c907a Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 09:59:09 +0100 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=8E=87=20Add=20Poll=20type=20to=20P?= =?UTF-8?q?ost=20shape=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/types/post.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/resources/js/types/post.ts b/resources/js/types/post.ts index 8edfeca..61f855e 100644 --- a/resources/js/types/post.ts +++ b/resources/js/types/post.ts @@ -32,6 +32,22 @@ export interface QuotedPost { chip_mentions: Mention[]; } +export interface PollOption { + title: string; + votes_count: number | null; +} + +export interface Poll { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: PollOption[]; + voted: boolean; + own_votes: number[]; +} + export interface Post { id: string; source: 'mastodon' | 'bluesky'; @@ -64,6 +80,7 @@ export interface Post { /** Who applied the content warning. 'self' = author labelled their own content; 'external' = third-party labeller (Bluesky only); null = no CW (cw_text is also null). */ cw_label_source: 'self' | 'external' | null; sensitive_media: boolean; + poll?: Poll; } export interface FeedResponse { From 2ed2fe4ca2ab3644eabd7bda7685d0105034a9e5 Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 10:01:49 +0100 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=93=96=20Document=20PollOption.vote?= =?UTF-8?q?s=5Fcount=20nullability=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/types/post.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/types/post.ts b/resources/js/types/post.ts index 61f855e..0e44fc1 100644 --- a/resources/js/types/post.ts +++ b/resources/js/types/post.ts @@ -34,6 +34,7 @@ export interface QuotedPost { export interface PollOption { title: string; + /** Null if per-option vote counts are hidden until the poll closes. */ votes_count: number | null; } From 6a1602f273ad3a4846c06011df1f1c114794aeca Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 10:02:55 +0100 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=94=84=EF=B8=8F=20Export=20timeSinc?= =?UTF-8?q?e=20helper=20from=20Attribution=20for=20reuse=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/components/feed/Attribution.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/feed/Attribution.tsx b/resources/js/components/feed/Attribution.tsx index b5a79dd..6534e80 100644 --- a/resources/js/components/feed/Attribution.tsx +++ b/resources/js/components/feed/Attribution.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import type { Post } from '@/types/post'; import { AuthorChip } from './AuthorChip'; -function timeSince(dateStr: string): string { +export function timeSince(dateStr: string): string { const seconds = Math.floor( (Date.now() - new Date(dateStr).getTime()) / 1000, ); From 6ff90a4fb8e23607b754a8aaf39b52774808a7d1 Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 10:05:39 +0100 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=8E=87=20Add=20PollResults=20compon?= =?UTF-8?q?ent=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/components/feed/PollResults.test.tsx | 115 ++++++++++++++++++ resources/js/components/feed/PollResults.tsx | 88 ++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 resources/js/components/feed/PollResults.test.tsx create mode 100644 resources/js/components/feed/PollResults.tsx diff --git a/resources/js/components/feed/PollResults.test.tsx b/resources/js/components/feed/PollResults.test.tsx new file mode 100644 index 0000000..2224459 --- /dev/null +++ b/resources/js/components/feed/PollResults.test.tsx @@ -0,0 +1,115 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { Poll } from '@/types/post'; +import { PollResults } from './PollResults'; + +const basePoll: Poll = { + id: '1', + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + expired: false, + multiple: false, + votes_count: 30, + options: [ + { title: 'Vim', votes_count: 20 }, + { title: 'Emacs', votes_count: 10 }, + ], + voted: false, + own_votes: [], +}; + +describe('PollResults', () => { + it('renders each option with its vote count and percentage', () => { + render( + , + ); + + expect(screen.getByText('Vim')).toBeInTheDocument(); + expect(screen.getByText(/20 votes/)).toBeInTheDocument(); + expect(screen.getByText(/67%/)).toBeInTheDocument(); + expect(screen.getByText('Emacs')).toBeInTheDocument(); + expect(screen.getByText(/10 votes/)).toBeInTheDocument(); + expect(screen.getByText(/33%/)).toBeInTheDocument(); + }); + + it('shows total vote count', () => { + render( + , + ); + + expect(screen.getByText(/30 votes total/)).toBeInTheDocument(); + }); + + it('shows "Poll closed" for an expired poll', () => { + const expired: Poll = { ...basePoll, expired: true }; + render( + , + ); + + expect(screen.getByText('Poll closed')).toBeInTheDocument(); + }); + + it('does not show "Poll closed" for an open poll', () => { + render( + , + ); + + expect(screen.queryByText('Poll closed')).not.toBeInTheDocument(); + }); + + it('shows a multiple-choice label when the poll allows multiple selections', () => { + const multi: Poll = { ...basePoll, multiple: true }; + render( + , + ); + + expect(screen.getByText(/multiple choice/i)).toBeInTheDocument(); + }); + + it('highlights the option(s) the connected account voted for', () => { + const voted: Poll = { ...basePoll, voted: true, own_votes: [0] }; + render( + , + ); + + expect(screen.getByTestId('poll-option-0')).toHaveAttribute( + 'data-voted', + 'true', + ); + expect(screen.getByTestId('poll-option-1')).toHaveAttribute( + 'data-voted', + 'false', + ); + }); + + it('renders a vote link pointing at the original post', () => { + render( + , + ); + + const link = screen.getByRole('link', { name: /vote/i }); + expect(link).toHaveAttribute('href', 'https://example.com/post/1'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/resources/js/components/feed/PollResults.tsx b/resources/js/components/feed/PollResults.tsx new file mode 100644 index 0000000..7238a41 --- /dev/null +++ b/resources/js/components/feed/PollResults.tsx @@ -0,0 +1,88 @@ +import { Check } from 'lucide-react'; +import type { Poll } from '@/types/post'; +import { timeSince } from './Attribution'; + +const PANEL_CLASS = + 'max-w-[40ch] rounded border border-white/20 bg-black/40 px-4 py-3 text-left text-sm text-white/70 backdrop-blur-sm'; + +function pollStatus(poll: Poll): string { + if (poll.expired) { + return 'Poll closed'; + } + + if (!poll.expires_at) { + return 'Poll open'; + } + + return `Closes ${timeSince(poll.expires_at)}`; +} + +export function PollResults({ + poll, + originalUrl, +}: { + poll: Poll; + originalUrl: string; +}) { + const total = poll.votes_count; + + return ( +
+
+ {pollStatus(poll)} + {poll.multiple && Multiple choice} +
+
+ {poll.options.map((option, index) => { + const votes = option.votes_count ?? 0; + const pct = + total > 0 ? Math.round((votes / total) * 100) : 0; + const isOwnVote = + poll.voted && poll.own_votes.includes(index); + + return ( +
+
+
+ + {isOwnVote && ( + + )} + {option.title} + + + {votes} votes ({pct}%) + +
+
+ ); + })} +
+
+ + {total} votes total + + + Vote → + +
+
+ ); +} From be2a5a167bf8b439e71b1b7da844ce90bdf9bfeb Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 10:09:59 +0100 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=AA=B3=20Fix=20PollResults:=20share?= =?UTF-8?q?=20PANEL=5FCLASS,=20handle=20hidden=20vote=20counts=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/components/feed/PollResults.test.tsx | 19 ++++++++++++++ resources/js/components/feed/PollResults.tsx | 25 +++++++++++-------- resources/js/components/feed/PostAnimator.tsx | 2 +- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/resources/js/components/feed/PollResults.test.tsx b/resources/js/components/feed/PollResults.test.tsx index 2224459..96f6cf5 100644 --- a/resources/js/components/feed/PollResults.test.tsx +++ b/resources/js/components/feed/PollResults.test.tsx @@ -99,6 +99,25 @@ describe('PollResults', () => { ); }); + it('shows "votes hidden" instead of a fake count when votes_count is null', () => { + const hidden: Poll = { + ...basePoll, + options: [ + { title: 'Vim', votes_count: null }, + { title: 'Emacs', votes_count: 10 }, + ], + }; + render( + , + ); + + expect(screen.getByText('votes hidden')).toBeInTheDocument(); + expect(screen.queryByText(/^0 votes/)).not.toBeInTheDocument(); + }); + it('renders a vote link pointing at the original post', () => { render(
{poll.options.map((option, index) => { + const votesHidden = option.votes_count === null; const votes = option.votes_count ?? 0; const pct = - total > 0 ? Math.round((votes / total) * 100) : 0; + !votesHidden && total > 0 + ? Math.round((votes / total) * 100) + : 0; const isOwnVote = poll.voted && poll.own_votes.includes(index); return (
-
+ {!votesHidden && ( +
+ )}
{isOwnVote && ( @@ -63,7 +66,9 @@ export function PollResults({ {option.title} - {votes} votes ({pct}%) + {votesHidden + ? 'votes hidden' + : `${votes} votes (${pct}%)`}
diff --git a/resources/js/components/feed/PostAnimator.tsx b/resources/js/components/feed/PostAnimator.tsx index a3dc2c0..74c4e19 100644 --- a/resources/js/components/feed/PostAnimator.tsx +++ b/resources/js/components/feed/PostAnimator.tsx @@ -19,7 +19,7 @@ gsap.registerPlugin(SplitText); const lastTemplate = { current: undefined as AnimationTemplate | undefined }; const BASE_FONT_SIZE = 40; const LINE_HEIGHT = 1.1; -const PANEL_CLASS = +export const PANEL_CLASS = 'max-w-[40ch] rounded border border-white/20 bg-black/40 px-4 py-3 text-left text-sm text-white/70 backdrop-blur-sm'; function ContextPanel({ From 1b1f83ffe3d67f5c198c1ac588406254e2e6496c Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 10:13:19 +0100 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=8E=87=20Render=20poll=20results=20?= =?UTF-8?q?in=20PostAnimator=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/PostAnimator.poll.test.tsx | 100 ++++++++++++++++++ resources/js/components/feed/PostAnimator.tsx | 27 ++++- 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 resources/js/components/feed/PostAnimator.poll.test.tsx diff --git a/resources/js/components/feed/PostAnimator.poll.test.tsx b/resources/js/components/feed/PostAnimator.poll.test.tsx new file mode 100644 index 0000000..aee3e9f --- /dev/null +++ b/resources/js/components/feed/PostAnimator.poll.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Post } from '@/types/post'; +import { PostAnimator } from './PostAnimator'; + +const basePost: Post = { + id: 'p1', + source: 'mastodon', + source_handle: null, + source_instance: 'mastodon.example', + author_name: 'Test User', + author_handle: '@user@mastodon.example', + author_avatar: '', + author_banner: null, + body: '', + media: [], + created_at: new Date().toISOString(), + original_url: 'https://mastodon.example/@user/1', + link_url: null, + link_title: null, + link_favicon: null, + reply_to: null, + quoted_post: null, + boosted_by: null, + boosted_by_avatar: null, + boosted_by_handle: null, + boosted_by_created_at: null, + emojis: {}, + hashtags: [], + chip_mentions: [], + cw_text: null, + cw_is_author_level: false, + cw_label_source: null, + sensitive_media: false, +}; + +const poll = { + id: '1', + expires_at: null, + expired: false, + multiple: false, + votes_count: 10, + options: [ + { title: 'Yes', votes_count: 7 }, + { title: 'No', votes_count: 3 }, + ], + voted: false, + own_votes: [], +}; + +describe('PostAnimator — poll rendering', () => { + it('renders poll results for a poll-only post (no body, no media)', () => { + render(); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('No')).toBeInTheDocument(); + }); + + it('renders poll results alongside body text', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + + it('renders poll results on an image post', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + + it('renders nothing but does not crash for a post with no body, no media, and no poll', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/resources/js/components/feed/PostAnimator.tsx b/resources/js/components/feed/PostAnimator.tsx index 74c4e19..5834257 100644 --- a/resources/js/components/feed/PostAnimator.tsx +++ b/resources/js/components/feed/PostAnimator.tsx @@ -13,6 +13,7 @@ import type { Mention, Post } from '@/types/post'; import { AuthorChip } from './AuthorChip'; import { ImageCarousel } from './ImageCarousel'; import { MentionChips } from './MentionChips'; +import { PollResults } from './PollResults'; gsap.registerPlugin(SplitText); @@ -226,12 +227,12 @@ export function PostAnimator({ useLayoutEffect(() => { if ( !body && - !(post.reply_to || post.quoted_post) && + !(post.reply_to || post.quoted_post || post.poll) && post.media.length === 0 ) { onReadyRef.current?.(); } - }, [body, post.reply_to, post.quoted_post, post.media.length]); + }, [body, post.reply_to, post.quoted_post, post.poll, post.media.length]); // Measure rendered line widths after DOM settle to compute per-line font sizes useLayoutEffect(() => { @@ -409,6 +410,14 @@ export function PostAnimator({
)} + {post.poll && ( +
+ +
+ )} {(post.reply_to || post.quoted_post || post.link_url) && (
{post.reply_to && ( @@ -488,7 +497,7 @@ export function PostAnimator({ } } - if (post.link_url || post.quoted_post || post.reply_to) { + if (post.link_url || post.quoted_post || post.reply_to || post.poll) { return (
)} + {post.poll && ( + + )} {post.link_url && ( ))}
+ {post.poll && ( + + )} {post.link_url && ( Date: Sat, 11 Jul 2026 10:18:46 +0100 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=AA=B3=20Fix=20onReady=20never=20fi?= =?UTF-8?q?ring=20for=20poll-only=20posts=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../feed/PostAnimator.poll.test.tsx | 47 +++++++++++++++++++ resources/js/components/feed/PostAnimator.tsx | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/resources/js/components/feed/PostAnimator.poll.test.tsx b/resources/js/components/feed/PostAnimator.poll.test.tsx index aee3e9f..b7c6ad8 100644 --- a/resources/js/components/feed/PostAnimator.poll.test.tsx +++ b/resources/js/components/feed/PostAnimator.poll.test.tsx @@ -3,6 +3,39 @@ import { describe, expect, it, vi } from 'vitest'; import type { Post } from '@/types/post'; import { PostAnimator } from './PostAnimator'; +// Mock GSAP so the "fade panels in" effect runs its callback synchronously, +// mirroring the approach used in PostAnimator.test.tsx. +vi.mock('gsap', () => ({ + gsap: { + registerPlugin: vi.fn(), + timeline: vi.fn(() => ({ + to: vi.fn().mockReturnThis(), + fromTo: vi.fn().mockReturnThis(), + kill: vi.fn(), + })), + set: vi.fn(), + fromTo: vi.fn((_target, _from, to) => { + to.onComplete?.(); + + return { kill: vi.fn() }; + }), + }, +})); + +vi.mock('@gsap/react', () => ({ + useGSAP: (callback: () => void | (() => void)) => { + callback(); + }, +})); + +vi.mock('@/lib/animations', () => ({ + pickTemplate: vi.fn(() => vi.fn()), + SplitText: class { + words: unknown[] = []; + revert() {} + }, +})); + const basePost: Post = { id: 'p1', source: 'mastodon', @@ -97,4 +130,18 @@ describe('PostAnimator — poll rendering', () => { expect(container.firstChild).toBeNull(); }); + + it('calls onReady for a poll-only post', () => { + const onReady = vi.fn(); + + render( + , + ); + + expect(onReady).toHaveBeenCalled(); + }); }); diff --git a/resources/js/components/feed/PostAnimator.tsx b/resources/js/components/feed/PostAnimator.tsx index 5834257..0c96fc3 100644 --- a/resources/js/components/feed/PostAnimator.tsx +++ b/resources/js/components/feed/PostAnimator.tsx @@ -362,7 +362,7 @@ export function PostAnimator({ useGSAP(() => { if ( body || - !(post.reply_to || post.quoted_post) || + !(post.reply_to || post.quoted_post || post.poll) || post.media.length > 0 ) { return; @@ -387,7 +387,7 @@ export function PostAnimator({ ); return () => tween.kill(); - }, [post.id, body, post.media.length]); + }, [post.id, body, post.poll, post.media.length]); // Image posts: centered card matching text post layout if (post.media.length > 0) { From 5910322edd372ee4503fa22a458bdcf016546bf7 Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 12:45:20 +0100 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=AA=B3=20Preserve=20explicit=20null?= =?UTF-8?q?=20votes=5Fcount=20for=20hidden=20poll=20options=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/Services/Feed/PostNormalizer.php | 6 ++++- tests/Unit/Feed/PostNormalizerTest.php | 32 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/Services/Feed/PostNormalizer.php b/app/Services/Feed/PostNormalizer.php index 0dcdfd2..2abff55 100644 --- a/app/Services/Feed/PostNormalizer.php +++ b/app/Services/Feed/PostNormalizer.php @@ -245,9 +245,13 @@ private function normalizeMastodonPoll(array $source): ?array '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' => $opt['votes_count'] ?? 0, + 'votes_count' => array_key_exists('votes_count', $opt) ? $opt['votes_count'] : 0, ], $poll['options'] ?? [], ), diff --git a/tests/Unit/Feed/PostNormalizerTest.php b/tests/Unit/Feed/PostNormalizerTest.php index 4e7e23e..9b55103 100644 --- a/tests/Unit/Feed/PostNormalizerTest.php +++ b/tests/Unit/Feed/PostNormalizerTest.php @@ -2626,3 +2626,35 @@ expect($post['poll']['options'])->toBe([]); }); + +it('preserves an explicit null votes_count for hidden per-option poll results', function () { + $status = [ + 'id' => '560', + 'content' => '

Pick one

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/560', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '333', + 'expires_at' => '2024-01-16T10:00:00.000Z', + 'expired' => false, + 'multiple' => true, + 'votes_count' => 4, + 'options' => [ + ['title' => 'Vim', 'votes_count' => null], + ['title' => 'Emacs', 'votes_count' => 4], + ], + 'voted' => false, + 'own_votes' => [], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll']['options'][0]['votes_count'])->toBeNull() + ->and($post['poll']['options'][1]['votes_count'])->toBe(4); +}); From d9357c87c48732fa6d8795e5e1a0b4312689273d Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 17:55:40 +0100 Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=AA=B3=20Fix=20poll=20display=20iss?= =?UTF-8?q?ues=20from=20PR=20review=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/Services/Feed/PostNormalizer.php | 30 ++++---- resources/js/components/feed/Attribution.tsx | 15 ++-- .../js/components/feed/PollResults.test.tsx | 15 ++++ resources/js/components/feed/PollResults.tsx | 2 +- resources/js/components/feed/PostAnimator.tsx | 3 +- resources/js/components/feed/panel-class.ts | 2 + resources/js/types/post.ts | 3 +- tests/Unit/Feed/PostNormalizerTest.php | 71 +++++++++++++++++++ 8 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 resources/js/components/feed/panel-class.ts diff --git a/app/Services/Feed/PostNormalizer.php b/app/Services/Feed/PostNormalizer.php index 2abff55..e4959e4 100644 --- a/app/Services/Feed/PostNormalizer.php +++ b/app/Services/Feed/PostNormalizer.php @@ -238,23 +238,29 @@ private function normalizeMastodonPoll(array $source): ?array return null; } + // options may be entirely absent, non-array, or contain non-array entries on a + // malformed/federated payload — filter defensively so a bad shape degrades to an + // empty/partial list instead of throwing a TypeError out of array_map's typed closure. + $rawOptions = $poll['options'] ?? []; + $options = is_array($rawOptions) + ? array_values(array_filter(array_map( + fn ($opt) => is_array($opt) ? [ + 'title' => $opt['title'] ?? '', + 'votes_count' => array_key_exists('votes_count', $opt) ? $opt['votes_count'] : 0, + ] : null, + $rawOptions, + ))) + : []; + return [ - 'id' => $poll['id'] ?? null, + // 'id' is typed as a required string on the frontend Poll type — default to + // '' rather than null so a malformed payload can't violate that contract. + 'id' => $poll['id'] ?? '', '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'] ?? [], - ), + 'options' => $options, 'voted' => (bool) ($poll['voted'] ?? false), 'own_votes' => $poll['own_votes'] ?? [], ]; diff --git a/resources/js/components/feed/Attribution.tsx b/resources/js/components/feed/Attribution.tsx index 6534e80..48c2c55 100644 --- a/resources/js/components/feed/Attribution.tsx +++ b/resources/js/components/feed/Attribution.tsx @@ -7,26 +7,31 @@ export function timeSince(dateStr: string): string { const seconds = Math.floor( (Date.now() - new Date(dateStr).getTime()) / 1000, ); + // Every existing call site passes a past timestamp, so `future` is always + // false there and output is unchanged. Poll expiry is the first caller + // that can pass a future date. + const future = seconds < 0; + const abs = Math.abs(seconds); - if (seconds < 60) { + if (abs < 60) { return 'just now'; } - const minutes = Math.floor(seconds / 60); + const minutes = Math.floor(abs / 60); if (minutes < 60) { - return `${minutes}m ago`; + return future ? `in ${minutes}m` : `${minutes}m ago`; } const hours = Math.floor(minutes / 60); if (hours < 24) { - return `${hours}h ago`; + return future ? `in ${hours}h` : `${hours}h ago`; } const days = Math.floor(hours / 24); - return `${days}d ago`; + return future ? `in ${days}d` : `${days}d ago`; } function absoluteTime(dateStr: string): string { diff --git a/resources/js/components/feed/PollResults.test.tsx b/resources/js/components/feed/PollResults.test.tsx index 96f6cf5..3d91238 100644 --- a/resources/js/components/feed/PollResults.test.tsx +++ b/resources/js/components/feed/PollResults.test.tsx @@ -68,6 +68,21 @@ describe('PollResults', () => { expect(screen.queryByText('Poll closed')).not.toBeInTheDocument(); }); + it('shows a future-relative "Closes in" time for an open poll, not "just now"', () => { + // basePoll expires 1 hour from now — far enough that a bug treating + // any future date as "just now" (regression: negative seconds < 60) + // would be caught here. + render( + , + ); + + expect(screen.getByText(/Closes in 1h/)).toBeInTheDocument(); + expect(screen.queryByText(/Closes just now/)).not.toBeInTheDocument(); + }); + it('shows a multiple-choice label when the poll allows multiple selections', () => { const multi: Poll = { ...basePoll, multiple: true }; render( diff --git a/resources/js/components/feed/PollResults.tsx b/resources/js/components/feed/PollResults.tsx index f8794ad..d2cd382 100644 --- a/resources/js/components/feed/PollResults.tsx +++ b/resources/js/components/feed/PollResults.tsx @@ -1,7 +1,7 @@ import { Check } from 'lucide-react'; import type { Poll } from '@/types/post'; import { timeSince } from './Attribution'; -import { PANEL_CLASS } from './PostAnimator'; +import { PANEL_CLASS } from './panel-class'; function pollStatus(poll: Poll): string { if (poll.expired) { diff --git a/resources/js/components/feed/PostAnimator.tsx b/resources/js/components/feed/PostAnimator.tsx index 0c96fc3..94c0135 100644 --- a/resources/js/components/feed/PostAnimator.tsx +++ b/resources/js/components/feed/PostAnimator.tsx @@ -14,14 +14,13 @@ import { AuthorChip } from './AuthorChip'; import { ImageCarousel } from './ImageCarousel'; import { MentionChips } from './MentionChips'; import { PollResults } from './PollResults'; +import { PANEL_CLASS } from './panel-class'; gsap.registerPlugin(SplitText); const lastTemplate = { current: undefined as AnimationTemplate | undefined }; const BASE_FONT_SIZE = 40; const LINE_HEIGHT = 1.1; -export const PANEL_CLASS = - 'max-w-[40ch] rounded border border-white/20 bg-black/40 px-4 py-3 text-left text-sm text-white/70 backdrop-blur-sm'; function ContextPanel({ icon, diff --git a/resources/js/components/feed/panel-class.ts b/resources/js/components/feed/panel-class.ts new file mode 100644 index 0000000..ce95cbb --- /dev/null +++ b/resources/js/components/feed/panel-class.ts @@ -0,0 +1,2 @@ +export const PANEL_CLASS = + 'max-w-[40ch] rounded border border-white/20 bg-black/40 px-4 py-3 text-left text-sm text-white/70 backdrop-blur-sm'; diff --git a/resources/js/types/post.ts b/resources/js/types/post.ts index 0e44fc1..1ee8a29 100644 --- a/resources/js/types/post.ts +++ b/resources/js/types/post.ts @@ -81,7 +81,8 @@ export interface Post { /** Who applied the content warning. 'self' = author labelled their own content; 'external' = third-party labeller (Bluesky only); null = no CW (cw_text is also null). */ cw_label_source: 'self' | 'external' | null; sensitive_media: boolean; - poll?: Poll; + /** Absent on Bluesky posts (no poll concept). Explicit null on a Mastodon post with no poll. */ + poll?: Poll | null; } export interface FeedResponse { diff --git a/tests/Unit/Feed/PostNormalizerTest.php b/tests/Unit/Feed/PostNormalizerTest.php index 9b55103..d53a127 100644 --- a/tests/Unit/Feed/PostNormalizerTest.php +++ b/tests/Unit/Feed/PostNormalizerTest.php @@ -2658,3 +2658,74 @@ expect($post['poll']['options'][0]['votes_count'])->toBeNull() ->and($post['poll']['options'][1]['votes_count'])->toBe(4); }); + +it('defaults poll id to an empty string rather than null when missing', function () { + $status = [ + 'id' => '561', + 'content' => '

Poll with no id

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/561', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'options' => [ + ['title' => 'Yes', 'votes_count' => 1], + ], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); + + expect($post['poll']['id'])->toBe(''); +}); + +it('does not throw when a mastodon poll options payload is malformed (non-array options, non-array entries)', function () { + $statusWithStringOptions = [ + 'id' => '562', + 'content' => '

Options is a string

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/562', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '444', + 'options' => 'not-an-array', + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($statusWithStringOptions, 'mastodon.example'); + + expect($post['poll']['options'])->toBe([]); + + $statusWithMixedEntries = [ + 'id' => '563', + 'content' => '

Options has a non-array entry

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/563', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '555', + 'options' => [ + ['title' => 'Valid', 'votes_count' => 2], + 'not-an-array-entry', + null, + ], + ], + ]; + + $post = (new PostNormalizer)->fromMastodon($statusWithMixedEntries, 'mastodon.example'); + + expect($post['poll']['options'])->toBe([ + ['title' => 'Valid', 'votes_count' => 2], + ]); +}); From 732ccad9d0e61eae8599bd6e023b5a67126cd2df Mon Sep 17 00:00:00 2001 From: Nicholas Avenell Date: Sat, 11 Jul 2026 18:21:38 +0100 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=AA=B3=20Address=20/review-pr=20fin?= =?UTF-8?q?dings:=20logging,=20PHPDoc,=20test=20gaps,=20readonly=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/Services/Feed/PostNormalizer.php | 40 +++++++++--- .../js/components/feed/Attribution.test.tsx | 63 +++++++++++++++++++ .../js/components/feed/PollResults.test.tsx | 21 +++++++ .../feed/PostAnimator.poll.test.tsx | 29 +++++++++ resources/js/types/post.ts | 20 +++--- tests/Unit/Feed/PostNormalizerTest.php | 57 +++++++++++++++++ 6 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 resources/js/components/feed/Attribution.test.tsx diff --git a/app/Services/Feed/PostNormalizer.php b/app/Services/Feed/PostNormalizer.php index e4959e4..0ea8bb5 100644 --- a/app/Services/Feed/PostNormalizer.php +++ b/app/Services/Feed/PostNormalizer.php @@ -2,6 +2,8 @@ namespace App\Services\Feed; +use Illuminate\Support\Facades\Log; + class PostNormalizer { private MentionClassifier $mentionClassifier; @@ -230,6 +232,9 @@ private function mastodonQuotedPost(array $source, string $host, ?array $quoteSt ]; } + /** + * @return array{id: string, expires_at: ?string, expired: bool, multiple: bool, votes_count: int, options: array, voted: bool, own_votes: array}|null + */ private function normalizeMastodonPoll(array $source): ?array { $poll = $source['poll'] ?? null; @@ -241,16 +246,37 @@ private function normalizeMastodonPoll(array $source): ?array // options may be entirely absent, non-array, or contain non-array entries on a // malformed/federated payload — filter defensively so a bad shape degrades to an // empty/partial list instead of throwing a TypeError out of array_map's typed closure. + // Malformed entries are dropped (not placeholder-filled), matching the + // FeedAggregator::dedupe() convention of logging and skipping rather than + // silently substituting values that could look like real data. $rawOptions = $poll['options'] ?? []; - $options = is_array($rawOptions) - ? array_values(array_filter(array_map( - fn ($opt) => is_array($opt) ? [ + + if (! is_array($rawOptions)) { + Log::warning('Mastodon poll payload has non-array options', [ + 'poll_id' => $poll['id'] ?? 'unknown', + 'options_type' => get_debug_type($rawOptions), + ]); + $rawOptions = []; + } + + $options = array_values(array_filter(array_map( + function ($opt) use ($poll) { + if (! is_array($opt)) { + Log::warning('Mastodon poll option entry is not an array', [ + 'poll_id' => $poll['id'] ?? 'unknown', + 'entry_type' => get_debug_type($opt), + ]); + + return null; + } + + return [ 'title' => $opt['title'] ?? '', 'votes_count' => array_key_exists('votes_count', $opt) ? $opt['votes_count'] : 0, - ] : null, - $rawOptions, - ))) - : []; + ]; + }, + $rawOptions, + ))); return [ // 'id' is typed as a required string on the frontend Poll type — default to diff --git a/resources/js/components/feed/Attribution.test.tsx b/resources/js/components/feed/Attribution.test.tsx new file mode 100644 index 0000000..779029b --- /dev/null +++ b/resources/js/components/feed/Attribution.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { timeSince } from './Attribution'; + +const NOW = new Date('2026-01-15T12:00:00.000Z'); + +describe('timeSince', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "just now" for a timestamp a few seconds in the past', () => { + const dateStr = new Date(NOW.getTime() - 5_000).toISOString(); + expect(timeSince(dateStr)).toBe('just now'); + }); + + it('returns "just now" at exactly 0 seconds', () => { + expect(timeSince(NOW.toISOString())).toBe('just now'); + }); + + it('returns minutes-ago for a past timestamp under an hour old', () => { + const dateStr = new Date(NOW.getTime() - 5 * 60_000).toISOString(); + expect(timeSince(dateStr)).toBe('5m ago'); + }); + + it('returns hours-ago for a past timestamp under a day old', () => { + const dateStr = new Date(NOW.getTime() - 3 * 3_600_000).toISOString(); + expect(timeSince(dateStr)).toBe('3h ago'); + }); + + it('returns days-ago for a past timestamp a day or more old', () => { + const dateStr = new Date( + NOW.getTime() - 2 * 24 * 3_600_000, + ).toISOString(); + expect(timeSince(dateStr)).toBe('2d ago'); + }); + + it('returns "just now" for a timestamp a few seconds in the future', () => { + const dateStr = new Date(NOW.getTime() + 5_000).toISOString(); + expect(timeSince(dateStr)).toBe('just now'); + }); + + it('returns future minutes for a future timestamp under an hour away', () => { + const dateStr = new Date(NOW.getTime() + 5 * 60_000).toISOString(); + expect(timeSince(dateStr)).toBe('in 5m'); + }); + + it('returns future hours for a future timestamp under a day away', () => { + const dateStr = new Date(NOW.getTime() + 3 * 3_600_000).toISOString(); + expect(timeSince(dateStr)).toBe('in 3h'); + }); + + it('returns future days for a future timestamp a day or more away', () => { + const dateStr = new Date( + NOW.getTime() + 2 * 24 * 3_600_000, + ).toISOString(); + expect(timeSince(dateStr)).toBe('in 2d'); + }); +}); diff --git a/resources/js/components/feed/PollResults.test.tsx b/resources/js/components/feed/PollResults.test.tsx index 3d91238..ba22c80 100644 --- a/resources/js/components/feed/PollResults.test.tsx +++ b/resources/js/components/feed/PollResults.test.tsx @@ -146,4 +146,25 @@ describe('PollResults', () => { expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); + + it('renders a fresh poll with zero votes without NaN or a crash', () => { + const fresh: Poll = { + ...basePoll, + votes_count: 0, + options: [ + { title: 'Vim', votes_count: 0 }, + { title: 'Emacs', votes_count: 0 }, + ], + }; + render( + , + ); + + expect(screen.getByText(/0 votes total/)).toBeInTheDocument(); + expect(screen.getAllByText(/0 votes \(0%\)/)).toHaveLength(2); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); + }); }); diff --git a/resources/js/components/feed/PostAnimator.poll.test.tsx b/resources/js/components/feed/PostAnimator.poll.test.tsx index b7c6ad8..3b3d259 100644 --- a/resources/js/components/feed/PostAnimator.poll.test.tsx +++ b/resources/js/components/feed/PostAnimator.poll.test.tsx @@ -144,4 +144,33 @@ describe('PostAnimator — poll rendering', () => { expect(onReady).toHaveBeenCalled(); }); + + it('renders poll results alongside a reply context panel and a link card in the same panel stack', () => { + render( + , + ); + + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('No')).toBeInTheDocument(); + expect(screen.getByText('This is the parent post')).toBeInTheDocument(); + expect(screen.getByText('An article')).toBeInTheDocument(); + }); }); diff --git a/resources/js/types/post.ts b/resources/js/types/post.ts index 1ee8a29..caf3e55 100644 --- a/resources/js/types/post.ts +++ b/resources/js/types/post.ts @@ -33,20 +33,20 @@ export interface QuotedPost { } export interface PollOption { - title: string; + readonly title: string; /** Null if per-option vote counts are hidden until the poll closes. */ - votes_count: number | null; + readonly votes_count: number | null; } export interface Poll { - id: string; - expires_at: string | null; - expired: boolean; - multiple: boolean; - votes_count: number; - options: PollOption[]; - voted: boolean; - own_votes: number[]; + readonly id: string; + readonly expires_at: string | null; + readonly expired: boolean; + readonly multiple: boolean; + readonly votes_count: number; + readonly options: readonly PollOption[]; + readonly voted: boolean; + readonly own_votes: readonly number[]; } export interface Post { diff --git a/tests/Unit/Feed/PostNormalizerTest.php b/tests/Unit/Feed/PostNormalizerTest.php index d53a127..750d1db 100644 --- a/tests/Unit/Feed/PostNormalizerTest.php +++ b/tests/Unit/Feed/PostNormalizerTest.php @@ -1,6 +1,7 @@ 'Valid', 'votes_count' => 2], ]); }); + +it('logs a warning when a mastodon poll payload has non-array options', function () { + Log::shouldReceive('warning') + ->once() + ->with('Mastodon poll payload has non-array options', Mockery::on(function (array $context) { + return $context['poll_id'] === '444' && $context['options_type'] === 'string'; + })); + + $status = [ + 'id' => '564', + 'content' => '

Options is a string

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/564', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '444', + 'options' => 'not-an-array', + ], + ]; + + (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); +}); + +it('logs a warning for each non-array entry in a mastodon poll options list', function () { + Log::shouldReceive('warning') + ->twice() + ->with('Mastodon poll option entry is not an array', Mockery::on(function (array $context) { + return $context['poll_id'] === '555' && in_array($context['entry_type'], ['string', 'null'], true); + })); + + $status = [ + 'id' => '565', + 'content' => '

Options has non-array entries

', + 'created_at' => '2024-01-15T10:00:00.000Z', + 'url' => 'https://mastodon.example/@user/565', + 'account' => [ + 'display_name' => 'Test User', + 'acct' => 'user', + 'avatar' => 'https://mastodon.example/avatars/original/user.jpg', + ], + 'poll' => [ + 'id' => '555', + 'options' => [ + ['title' => 'Valid', 'votes_count' => 2], + 'not-an-array-entry', + null, + ], + ], + ]; + + (new PostNormalizer)->fromMastodon($status, 'mastodon.example'); +});