From 3be4c8d46fb257f08771a90dbfbcbe5ddbe2714a Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 22:05:27 -0400 Subject: [PATCH 01/83] docs: add Giphy GIF picker design spec --- .../specs/2026-10-16-giphy-picker-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-16-giphy-picker-design.md diff --git a/docs/superpowers/specs/2026-10-16-giphy-picker-design.md b/docs/superpowers/specs/2026-10-16-giphy-picker-design.md new file mode 100644 index 000000000..62e8d6b41 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-giphy-picker-design.md @@ -0,0 +1,183 @@ +# Giphy GIF picker — design + +**Date:** 2026-10-16 +**Status:** Approved design, pending implementation plan + +## Summary + +Let a commit carry one Giphy GIF. The composer (commit, reply, and quote) gains a GIF picker +that searches Giphy directly from the browser; the chosen GIF is stored as URL references on +the commit and rendered by hotlinking Giphy's CDN. The server never calls Giphy. + +A commit body is always required (3–280 chars); the GIF is an attachment next to text, never +instead of it. A commit carries **at most one GIF, with no uploaded images alongside it** +(one solo GIF). GIF + link card is allowed, as it is for images today. + +## Approach (decided) + +**Direct client-side integration.** Giphy's API terms prohibit caching or storing copies of +their media and prohibit proxying API calls or media loads — media must be loaded directly +from the URLs their API returns, on the client, with "Powered By GIPHY" attribution. So: + +- The browser calls `api.giphy.com` for search/trending with the API key (shared via Inertia). +- Commits store the GIF's media URL (verbatim), page URL, title, and creator username. +- Rendering hotlinks `media.giphy.com` directly, like GitHub avatars already are. +- No server-side Giphy request, storage, or proxy, anywhere. + +Rejected alternatives: a server-side search proxy (violates "do not proxy API calls", hides +the key for no functional gain) and downloading/storing GIFs like uploads (explicitly +prohibited by the ToS, and GD cannot re-encode animations). + +Accepted costs: the API key is visible in the browser (how Giphy keys are designed to work), +viewers' IPs reach Giphy, and a GIF breaks if Giphy removes it. + +## Data model + +Migration `add_giphy_to_commits_table` — nullable columns on `commits` (not a shared table: +the metadata never refetches server-side, max one per commit, and it is tiny): + +| Column | Type | Purpose | +|---|---|---| +| `giphy_id` | string(64) | GIF id, `[A-Za-z0-9]+` | +| `giphy_url` | text | media URL **exactly as returned by the API** — query string included, never rewritten (ToS) | +| `giphy_page_url` | text | giphy.com page URL, for the attribution link | +| `giphy_title` | string(300) | creator title → alt text | +| `giphy_username` | string(100) | creator username, for attribution where available | + +**Rendition:** the stored `giphy_url` is always a **`.gif` rendition** (prefer +`downsized_medium`, fall back to `downsized`, then `original`), so the `image/gif` MIME type +declared in Atom enclosures and OG tags is honest. Picker thumbnails use a small fixed-height +rendition and are never stored. + +**Serialization:** `Commit::toArray()` strips the raw `giphy_*` keys and appends a derived +`gif` object — `{id, url, page_url, title, username} | null`. The existing tombstone branch +already whitelists only `id/parent_id/deleted_at/replies_count`, so deleted commits drop the +GIF for free. Pending/rejected commits only ship to their author and admins (existing +`isVisibleTo` gating), which covers GIF visibility — the URL is public on Giphy regardless. + +**Deletion:** nothing to clean up — Giphy hosts the bytes. Soft-delete, restore window, and +tombstone semantics are unchanged. + +## Validation + +In `CreateCommitRequest` and `CreateCommitReplyRequest` (these cover commits, quotes, and +replies; the CLI commit path does not accept attachments and is untouched): + +- `giphy_url`: `https` scheme, host matching `media\d*.giphy.com`, path starting + `/media/{giphy_id}/`, **query string allowed and preserved verbatim**, no fragment. +- `giphy_id`: `[A-Za-z0-9]+`, max 64, and must match the id segment in `giphy_url`. +- `giphy_page_url`: `https`, host `giphy.com` or `www.giphy.com`. +- `giphy_title` max 300, `giphy_username` max 100, both nullable. +- The `giphy_*` fields arrive as a set — partial submissions are rejected. +- Exclusivity, mirroring the existing images/poll rules: GIF ⟂ uploaded images, GIF ⟂ poll, + GIF ⟂ scheduling. GIF + link card is allowed. +- Server validation accepts well-formed GIF fields even when `GIPHY_KEY` is unset — the key + is a client-side search credential, not a server dependency. + +`CreateCommit` takes the validated fields via a small readonly DTO in `App\Dto` (like the +existing `Embed`). `CreateCommitImages` is untouched. + +## Composer UX and frontend structure + +All in `resources/js`, following the existing `commit-image-picker.tsx` patterns (ghost +toolbar buttons, `data-test` attributes, Radix/shadcn `ui/*` primitives): + +- **`lib/giphy.ts`** — pure, node-testable: the `GiphyAttachment` type + (`{id, url, page_url, title, username}`), direct fetches to `api.giphy.com` + (`/v1/gifs/search`, `/v1/gifs/trending`, always `rating=g`), and + `attachmentFromResult(item)` which picks the `.gif` rendition verbatim. +- **`components/giphy-picker-dialog.tsx`** — Radix Dialog: debounced (~300 ms) search field, + trending on empty query, thumbnail grid, "Load more" (offset pagination, no infinite + scroll), loading/empty/error states, click to select. Carries the required + **"Powered By GIPHY"** mark (official logo asset bundled under `public/`). +- **`components/giphy-picker-button.tsx`** — ghost toolbar button next to the image picker, + hidden entirely when no key is configured. +- **`components/giphy-attachment-preview.tsx`** — selected-GIF thumbnail with a remove + button in the composer, mirroring `ImagePickerPreviews`. +- **Stash:** `use-stash` entries gain a persisted `gif` field. localStorage is enough — + there is no `File`, so drafts survive reloads like bodies do. +- **Mutual exclusion in the composer:** picking a GIF clears uploads (and poll); attaching + images or starting a poll clears the GIF. No confirmation dialog — the preview + disappearing is feedback enough. +- **Wiring:** `commit-composer.tsx`, `commit-thread.tsx` (`ReplyComposer`), and + `commit-quote-button.tsx` (`QuoteComposer`) all get the button; submissions send hidden + `giphy_*` fields exactly like `images`/`image_descriptions` today. +- **Key delivery:** `HandleInertiaRequests` shares `giphy` from `config/services.php` + (`GIPHY_KEY` env), typed in `types/global.d.ts`. No key ⇒ the feature is invisible. +- **Composer preview:** `CommitPreviewController` only highlights the body server-side, and + draft attachments already render client-side — the draft GIF renders through the same + commit component with no endpoint change. + +Out of scope by design: photo-viewer/lightbox integration, GIF downloads (ToS), any server +proxy, and GIFs in quoted-commit embeds (`quoted-commit.tsx` renders body + tombstones only, +never attachments — GIFs keep that parity). + +## Rendering + +A new `components/commit-giphy.tsx`, used by `commit-card.tsx` in the same single-image frame +as `CommitImages`: + +- `{gif.title` + — URL verbatim, direct from Giphy, with the same one-shot `/favicon.svg` broken-image + fallback as uploaded images. `no-referrer` trims viewer data sent to Giphy without touching + the URL (their analytics ride the query params). +- Attribution overlay linking to `gif.page_url`: `GIF by @{username}` when known, else + "via GIPHY" — satisfying Giphy's content-attribution clause. The "Powered By GIPHY" mark + lives in the picker, where the API is utilized. + +## Server-side touchpoints (no Giphy calls, ever) + +- **Public API:** `CommitResource` gains `gif` (`{id, url, page_url, title, username} | null`), + mirroring how `images` are surfaced. Consumers load media directly from Giphy. +- **OG metadata:** `PageMetadata::commitImages()` emits `giphy_url` as the OG image for GIF + commits. Crawlers fetch Giphy directly — the permitted client-side pattern. +- **Atom feeds:** `CreateUserFeed` adds the GIF as an `image/gif` enclosure. +- **Moderation:** `UpdateCommitAiDecision` appends `giphy_url` to the image array it passes + to `OpenAI::moderate()`/`showsMinors()` — both already wrap entries as `image_url`, and + OpenAI accepts remote HTTPS URLs there, fetching Giphy itself (a third-party fetch, not us + proxying). Rename the `$imageDataUrls` parameters to `$imageUrls`, and extend the + `showsMinors` gate from "has images" to "has images or a GIF". If OpenAI's fetch fails, + moderation degrades exactly as it does today. +- Photo viewer, downloads, and `commit-image.show` are untouched — a GIF is not a stored + `CommitImage`. + +## Edge cases and error handling + +- **Giphy removes a GIF:** the `` breaks → favicon fallback. The post and its text + survive; nothing to clean server-side. +- **Malicious client submits a non-`g`-rated or junk URL:** format validation guarantees a + real `media*.giphy.com` reference; content safety is covered by moderation fetching the + URL. A non-existent GIF renders broken; moderation's fetch degrades gracefully. +- **Picker errors:** search failure → inline retry message in the dialog (not a toast); + Giphy 429 → "GIPHY is rate limiting searches, try again shortly."; no results → dry + empty-state copy in the app's voice. +- **Stashed drafts** whose GIF is later removed from Giphy show a broken preview; remove and + re-pick. No special handling. +- **Beta keys** are throttled hard (~42 searches/hour) and prohibited in production — the + README notes that a production key (with Giphy's "Powered By GIPHY" screenshot approval) + is needed for real deployments. + +## Testing + +Keeping the repo's 100% coverage and type-coverage bars; no real Giphy calls anywhere in CI +(`Http::preventStrayRequests()` already enforces this server-side; frontend fetches mocked): + +- **Pest, Feature:** valid GIF accepted on commit/reply/quote; URL scheme/host/path + rejections; query strings accepted and stored verbatim; partial `giphy_*` set rejected; + GIF ⟂ images, GIF ⟂ poll, GIF ⟂ scheduling; GIF + link card accepted; payload exposes the + `gif` object and never the raw columns; tombstones drop it. API v1 `gif` field, + `PageMetadata` OG image, Atom enclosure. +- **Pest, Unit:** `CreateCommit` persists the DTO; `UpdateCommitAiDecision` includes the + Giphy URL in `moderate`/`showsMinors` payloads (`Http::fake` on OpenAI, as existing tests + do). +- **Frontend:** `tests/Unit/Giphy.test.ts` (node:test) for `lib/giphy.ts` — request URL + construction, `rating=g` always present, rendition picking, result normalization. A Vite+ + `.test.tsx` for composer exclusivity and stash round-trip, following + `ComposerDraft.test.tsx` patterns. + +## Documentation + +- `.env.example` gains `GIPHY_KEY` with a comment. +- README gets a short "GIFs" section next to Embeds (key setup, production-key caveat). +- `PRODUCT.md` is updated so its "deliberately excluded" list (which still names + images/media and is already stale) does not contradict the GIF capability. From 42c09079a28168c52b59b110b28d8f0eab13be6d Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 22:33:38 -0400 Subject: [PATCH 02/83] docs: add giphy picker implementation plan --- .../plans/2026-10-16-giphy-picker.md | 2198 +++++++++++++++++ .../specs/2026-10-16-giphy-picker-design.md | 17 +- 2 files changed, 2209 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-16-giphy-picker.md diff --git a/docs/superpowers/plans/2026-10-16-giphy-picker.md b/docs/superpowers/plans/2026-10-16-giphy-picker.md new file mode 100644 index 000000000..99eca8e58 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-giphy-picker.md @@ -0,0 +1,2198 @@ +# Giphy GIF Picker 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:** Let a commit (or reply, or quote) carry one Giphy GIF, picked in the composer via Giphy's client-side API and rendered by hotlinking Giphy's CDN — the server never calls Giphy. + +**Architecture:** Seven nullable `giphy_*` columns on `commits`, exposed as one derived `gif` object; validation in the two commit form requests with a host-allowlist URL rule; the browser searches `api.giphy.com` directly with a key shared via Inertia; a Radix-dialog picker in all three composers; read surfaces (public API, OG tags, Atom feed, AI moderation) reference the Giphy URL directly. + +**Tech Stack:** Laravel 13 (PHP 8.5, strict types), Inertia 3 + React 19 + TypeScript, Pest 5, node:test for frontend lib tests. + +**Spec:** `docs/superpowers/specs/2026-10-16-giphy-picker-design.md` — read it before starting. + +## Global Constraints + +- PHP: `declare(strict_types=1);` in every file; classes `final`; actions `final readonly` with a single `handle()`; validation/authorization only in Form Requests (enforced by `tests/Unit/ArchTest.php`). +- Tests: Pest with **exactly 100.0% line coverage** and 100% type coverage; PHPStan at max level. Every new PHP branch needs a test. +- No real HTTP in tests: `Http::preventStrayRequests()` is global; frontend tests stub `fetch`. +- GIPHY ToS: media URLs are stored and rendered **verbatim** (query strings included); **no server-side Giphy request anywhere**; the picker displays "Powered By GIPHY"; all API calls send `rating=g`. +- One GIF per commit, never alongside uploaded images or a poll; GIFs cannot be scheduled (the scheduled-commit request simply doesn't accept the fields); the CLI commit path is untouched. +- Copy voice: dry, terminal-literate; a post is a "commit". +- House rule: `git add` new files immediately after creating them. +- No new routes or controllers → no `wayfinder:generate` run needed. +- Formatting: if Pint/oxfmt complain at the end, run `composer lint` and include the fixes in the final task's commit. + +--- + +### Task 1: Migration, model serialization, factory state + +**Files:** +- Create: `database/migrations/_add_giphy_to_commits_table.php` (via artisan) +- Modify: `app/Models/Commit.php` (docblock, `#[Hidden]`, `casts()`, `toArray()`, new `gif()`) +- Modify: `database/factories/CommitFactory.php` +- Test: `tests/Unit/Models/CommitTest.php` (create the file if it does not exist — check with `ls tests/Unit/Models/`) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `Commit::gif(): ?array` returning `{id, url, page_url, title, username, width, height}`; `Commit::toArray()` emitting `gif` and never raw `giphy_*` keys; `CommitFactory::withGiphy(): self`. Fixture values (used verbatim by later tasks' tests): id `xT9IgzoKnwFNmISR8I`, url `https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123`, page_url `https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I`, title `Deal With It GIF`, username `giphy`, width `480`, height `270`. + +- [ ] **Step 1: Write the failing model tests** + +Append to `tests/Unit/Models/CommitTest.php` (if the file does not exist, create it with just the opening `withGiphy()->create(); + + $payload = $commit->toArray(); + + expect($payload['gif'])->toBe([ + 'id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ])->and(array_keys($payload))->each->not->toStartWith('giphy_'); +}); + +it('serializes the gif of a plain commit as null', function (): void { + expect(Commit::factory()->create()->toArray()['gif'])->toBeNull(); +}); + +it('leaves the gif out of a tombstone', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + $commit->delete(); + + expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); +}); +``` + +Note: if `expect(...)->each->not->toStartWith('giphy_')` is unsupported by the installed Pest, use `expect(collect($payload)->keys()->filter(fn (string $key): bool => str_starts_with($key, 'giphy_'))->all())->toBe([]);` instead. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./vendor/bin/pest tests/Unit/Models/CommitTest.php --filter="gif"` +Expected: FAIL — `withGiphy` undefined / column `giphy_id` does not exist. + +- [ ] **Step 3: Create the migration** + +Run: `php artisan make:migration add_giphy_to_commits_table --table=commits`, then replace its content: + +```php +string('giphy_id', 64)->nullable(); + $table->text('giphy_url')->nullable(); + $table->text('giphy_page_url')->nullable(); + $table->string('giphy_title', 300)->nullable(); + $table->string('giphy_username', 100)->nullable(); + $table->unsignedSmallInteger('giphy_width')->nullable(); + $table->unsignedSmallInteger('giphy_height')->nullable(); + }); + } +}; +``` + +(Existing migrations in this repo have no `down()` — keep that.) `git add` the file. + +- [ ] **Step 4: Extend the Commit model** + +In `app/Models/Commit.php`: + +a) Add to the class docblock, next to the other `@property-read` lines: + +```php + * @property-read string|null $giphy_id + * @property-read string|null $giphy_url + * @property-read string|null $giphy_page_url + * @property-read string|null $giphy_title + * @property-read string|null $giphy_username + * @property-read int|null $giphy_width + * @property-read int|null $giphy_height +``` + +b) Extend the `#[Hidden]` attribute (raw columns never serialize; the derived `gif` replaces them): + +```php +#[Hidden([ + 'ai_decision', + 'ai_categories', + 'embedding', + 'giphy_id', + 'giphy_url', + 'giphy_page_url', + 'giphy_title', + 'giphy_username', + 'giphy_width', + 'giphy_height', +])] +``` + +c) Add to `casts()`: + +```php + 'giphy_id' => 'string', + 'giphy_url' => 'string', + 'giphy_page_url' => 'string', + 'giphy_title' => 'string', + 'giphy_username' => 'string', + 'giphy_width' => 'integer', + 'giphy_height' => 'integer', +``` + +d) Change the non-tombstone return of `toArray()` from + +```php + return [...parent::toArray(), ...resolve(CommitBodies::class)->segments($this->body)]; +``` + +to + +```php + return [...parent::toArray(), 'gif' => $this->gif(), ...resolve(CommitBodies::class)->segments($this->body)]; +``` + +e) Add the method (near `imagesAreVisibleTo()`): + +```php + /** + * The attached GIPHY GIF, hotlinked by its media URL exactly as GIPHY's API returned it. + * + * @return array{id: string, url: string, page_url: string, title: string|null, username: string|null, width: int|null, height: int|null}|null + */ + public function gif(): ?array + { + if ($this->giphy_id === null || $this->giphy_url === null || $this->giphy_page_url === null) { + return null; + } + + return [ + 'id' => $this->giphy_id, + 'url' => $this->giphy_url, + 'page_url' => $this->giphy_page_url, + 'title' => $this->giphy_title, + 'username' => $this->giphy_username, + 'width' => $this->giphy_width, + 'height' => $this->giphy_height, + ]; + } +``` + +- [ ] **Step 5: Add the factory state** + +In `database/factories/CommitFactory.php`, after `rejected()`: + +```php + public function withGiphy(): self + { + return $this->state(fn (array $attributes): array => [ + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_title' => 'Deal With It GIF', + 'giphy_username' => 'giphy', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Unit/Models/CommitTest.php --filter="gif"` +Expected: PASS (3 tests). + +- [ ] **Step 7: Run the whole model + arch suite for regressions** + +Run: `./vendor/bin/pest tests/Unit/Models tests/Unit/ArchTest.php` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add database/migrations app/Models/Commit.php database/factories/CommitFactory.php tests/Unit/Models/CommitTest.php +git commit -m "feat: store an attached giphy gif on commits" +``` + +--- + +### Task 2: DTO, URL rule, commit validation, action wiring + +**Files:** +- Create: `app/Dto/Giphy.php`, `app/Rules/GiphyMediaUrl.php` +- Modify: `app/Http/Requests/CreateCommitRequest.php`, `app/Actions/CreateCommit.php`, `app/Http/Controllers/CommitController.php` +- Test: `tests/Feature/Controllers/CommitControllerTest.php` + +**Interfaces:** +- Consumes: `CommitFactory::withGiphy()` fixture values (Task 1). +- Produces: `App\Dto\Giphy` (`new Giphy(string $id, string $url, string $pageUrl, ?string $title, ?string $username, int $width, int $height)`, `toColumns(): array`); `CreateCommitRequest::giphy(): ?Giphy`; `CreateCommit::handle(..., ?Giphy $giphy = null)`; `App\Rules\GiphyMediaUrl`. Task 3 reuses the rule and DTO; Task 14's composers submit fields named `giphy_id`, `giphy_url`, `giphy_page_url`, `giphy_title`, `giphy_username`, `giphy_width`, `giphy_height`. + +- [ ] **Step 1: Write the failing feature tests** + +Append to `tests/Feature/Controllers/CommitControllerTest.php` (its imports already include `App\Models\User`; the tests below use only existing imports plus `route()`/`actingAs` helpers already used in the file): + +```php +it('may attach a giphy gif to a commit', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Shipping it', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_title' => 'Deal With It GIF', + 'giphy_username' => 'giphy', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + $commit = $user->commits()->sole(); + expect($commit->giphy_id)->toBe('xT9IgzoKnwFNmISR8I') + ->and($commit->giphy_url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') + ->and($commit->giphy_width)->toBe(480); +}); + +it('rejects a gif media url that is not on giphy', function (string $url): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Nice try', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => $url, + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_url'); + + expect($user->commits()->count())->toBe(0); +})->with([ + 'another host' => ['https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'lookalike host' => ['https://media2.giphy.com.evil.example/media/x/giphy.gif'], + 'plain http' => ['http://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'not a gif rendition' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.webp'], + 'outside the media path' => ['https://media2.giphy.com/avatars/giphy.gif'], + 'with a fragment' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif#frag'], + 'with userinfo' => ['https://user@media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'with a port' => ['https://media2.giphy.com:8443/media/xT9IgzoKnwFNmISR8I/giphy.gif'], +]); + +it('rejects a partial gif submission', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Half a gif', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['giphy_url', 'giphy_page_url', 'giphy_width', 'giphy_height']); +}); + +it('rejects a gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('rejects a gif alongside a poll', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Gif or poll', + 'poll' => ['options' => ['Yes', 'No'], 'duration' => 1440], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('may quote a commit with a gif attached', function (): void { + $user = User::factory()->create(); + $quoted = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Quoting with style', + 'quoted_commit_id' => $quoted->id, + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->quoted_commit_id)->toBe($quoted->id); +}); + +it('shows the gif in the commit payload', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + $this->actingAs($commit->user) + ->get(route('commit.show', [$commit->user, $commit])) + ->assertInertia(fn ($page) => $page + ->where('commit.gif.id', 'xT9IgzoKnwFNmISR8I') + ->where('commit.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->missing('commit.giphy_url')); +}); +``` + +Adjust the poll `duration` value to a valid `PollDuration` case — check with `rg -n "case" app/Enums/PollDuration.php` and use the first case's value. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./vendor/bin/pest tests/Feature/Controllers/CommitControllerTest.php --filter="gif"` +Expected: FAIL — unknown `giphy_*` columns persisted as null / no validation errors raised (fields unvalidated). + +- [ ] **Step 3: Create the DTO** + +`app/Dto/Giphy.php`: + +```php + + */ + public function toColumns(): array + { + return [ + 'giphy_id' => $this->id, + 'giphy_url' => $this->url, + 'giphy_page_url' => $this->pageUrl, + 'giphy_title' => $this->title, + 'giphy_username' => $this->username, + 'giphy_width' => $this->width, + 'giphy_height' => $this->height, + ]; + } +} +``` + +- [ ] **Step 4: Create the URL rule** + +`app/Rules/GiphyMediaUrl.php`: + +```php + ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images,poll', 'required_with:giphy_url'], + 'giphy_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', new GiphyMediaUrl], + 'giphy_page_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', 'url:https', 'regex:#^https://(www\.)?giphy\.com/#'], + 'giphy_title' => ['nullable', 'string', 'max:300'], + 'giphy_username' => ['nullable', 'string', 'max:100'], + 'giphy_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], + 'giphy_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], +``` + +c) Add the interpretation helper after `quotedCommit()`: + +```php + public function giphy(): ?Giphy + { + if (! $this->filled('giphy_id')) { + return null; + } + + return new Giphy( + $this->string('giphy_id')->value(), + $this->string('giphy_url')->value(), + $this->string('giphy_page_url')->value(), + $this->filled('giphy_title') ? $this->string('giphy_title')->value() : null, + $this->filled('giphy_username') ? $this->string('giphy_username')->value() : null, + $this->integer('giphy_width'), + $this->integer('giphy_height'), + ); + } +``` + +d) If PHPStan later complains about the `rules()` return type union, widen the method's `@return` docblock to include `GiphyMediaUrl` (mirror how `VisibleCommit` is accommodated — check the existing docblock union first). + +- [ ] **Step 6: Thread the DTO through the action and controller** + +a) `app/Actions/CreateCommit.php` — add `use App\Dto\Giphy;`, extend the signature: + +```php + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?Giphy $giphy = null): Commit +``` + +add `$giphy` to the transaction closure's `use (...)` list, and extend the `$user->commits()->create([...])` array with: + +```php + ...$giphy?->toColumns() ?? [], +``` + +b) `app/Http/Controllers/CommitController.php` — in `store()`, add the named argument to the `$action->handle(...)` call: + +```php +giphy: $request->giphy(), +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Feature/Controllers/CommitControllerTest.php --filter="gif"` +Expected: PASS. + +- [ ] **Step 8: Run the full commit controller + arch suites** + +Run: `./vendor/bin/pest tests/Feature/Controllers/CommitControllerTest.php tests/Unit/ArchTest.php` +Expected: PASS (no regressions; ArchTest accepts the new Rule/Dto). + +- [ ] **Step 9: Commit** + +```bash +git add app/Dto/Giphy.php app/Rules/GiphyMediaUrl.php app/Http/Requests/CreateCommitRequest.php app/Actions/CreateCommit.php app/Http/Controllers/CommitController.php tests/Feature/Controllers/CommitControllerTest.php +git commit -m "feat: accept a giphy gif on new commits and quotes" +``` + +--- + +### Task 3: Reply validation and wiring + +**Files:** +- Modify: `app/Http/Requests/CreateCommitReplyRequest.php`, `app/Http/Controllers/CommitReplyController.php` +- Test: `tests/Feature/Controllers/CommitReplyControllerTest.php` (confirm the filename with `ls tests/Feature/Controllers/ | grep -i reply`) + +**Interfaces:** +- Consumes: `App\Dto\Giphy`, `App\Rules\GiphyMediaUrl`, `CreateCommit::handle(..., giphy:)` (Task 2). +- Produces: replies accept the same seven `giphy_*` fields. + +- [ ] **Step 1: Write the failing feature tests** + +Append to the reply controller test file (replies are posted as JSON via `useHttp`, so mirror the file's existing posting style — check one existing image test there first with `rg -n "images" | head -5` and copy its request helper, e.g. `postJson` vs `post`): + +```php +it('may attach a giphy gif to a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Replying with a gif', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->giphy_id)->toBe('xT9IgzoKnwFNmISR8I'); +}); + +it('rejects a reply gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('rejects a bad gif media url on a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Nice try', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_url'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./vendor/bin/pest tests/Feature/Controllers/CommitReplyControllerTest.php --filter="gif"` +Expected: FAIL. + +- [ ] **Step 3: Add rules and helper to CreateCommitReplyRequest** + +In `app/Http/Requests/CreateCommitReplyRequest.php`: add the same imports (`App\Dto\Giphy`, `App\Rules\GiphyMediaUrl`), the same seven rule entries as Task 2 Step 5b **except** `giphy_id` drops `poll` from `prohibits` (replies have no polls): + +```php + 'giphy_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images', 'required_with:giphy_url'], +``` + +and the identical `giphy(): ?Giphy` helper from Task 2 Step 5c. + +- [ ] **Step 4: Wire the controller** + +In `app/Http/Controllers/CommitReplyController.php`, add `giphy: $request->giphy(),` to the `CreateCommit` `handle()` call (find it with `rg -n "handle\(" app/Http/Controllers/CommitReplyController.php`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Feature/Controllers/CommitReplyControllerTest.php` +Expected: PASS, including pre-existing tests. + +- [ ] **Step 6: Commit** + +```bash +git add app/Http/Requests/CreateCommitReplyRequest.php app/Http/Controllers/CommitReplyController.php tests/Feature/Controllers/CommitReplyControllerTest.php +git commit -m "feat: accept a giphy gif on replies" +``` + +--- + +### Task 4: Config key and Inertia shared prop + +**Files:** +- Modify: `config/services.php`, `app/Http/Middleware/HandleInertiaRequests.php`, `resources/js/types/global.d.ts`, `.env.example` +- Test: Create `tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `config('services.giphy.key')`; Inertia shared prop `giphy: { key: string } | null`; TS `sharedPageProps.giphy`. + +- [ ] **Step 1: Write the failing feature tests** + +`tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php`: + +```php + 'giphy-test']); + + $this->actingAs(User::factory()->create()) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('giphy.key', 'giphy-test')); +}); + +it('shares no giphy config without a key', function (): void { + config(['services.giphy.key' => null]); + + $this->actingAs(User::factory()->create()) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); +}); +``` + +`git add` the file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./vendor/bin/pest tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` +Expected: FAIL — `giphy` prop missing. + +- [ ] **Step 3: Add the config and shared prop** + +a) `config/services.php` — after the `'openai'` entry: + +```php + 'giphy' => [ + 'key' => env('GIPHY_KEY'), + ], +``` + +b) `app/Http/Middleware/HandleInertiaRequests.php` — in the array returned by `share()`, after `'name' => config('app.name'),`: + +```php + 'giphy' => filled(config('services.giphy.key')) ? ['key' => config()->string('services.giphy.key')] : null, +``` + +c) `resources/js/types/global.d.ts` — inside `sharedPageProps`, after `showsWhoToFollow: boolean;`: + +```ts + giphy: { key: string } | null; +``` + +d) `.env.example` — after the `OPENAI_API_KEY=` line: + +``` +# GIF picker (GIPHY). The key is shipped to the browser, as GIPHY requires client-side API calls, +# and the picker is hidden without one. Beta keys are heavily rate limited; production use needs +# an approved key with "Powered By GIPHY" attribution (https://developers.giphy.com). +GIPHY_KEY= +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add config/services.php app/Http/Middleware/HandleInertiaRequests.php resources/js/types/global.d.ts .env.example tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php +git commit -m "feat: share the giphy key with the composer" +``` + +--- + +### Task 5: Moderation covers the GIF + +**Files:** +- Modify: `app/Actions/UpdateCommitAiDecision.php`, `app/Services/OpenAI.php` +- Test: `tests/Unit/Jobs/ModerateCommitTest.php` + +**Interfaces:** +- Consumes: `giphy_url` column (Task 1). +- Produces: nothing new — behavior change only. OpenAI's moderation and responses APIs accept remote https URLs in the same `image_url` fields as data URLs, so the Giphy URL is appended to the existing image array; OpenAI fetches it itself (our server never touches Giphy). + +- [ ] **Step 1: Study the existing test harness** + +Run: `rg -n "data:image|CommitImage::factory|new ModerateCommit" tests/Unit/Jobs/ModerateCommitTest.php | head -20` and read one existing image-moderation test to learn how the job is invoked and how `Http::fake` + `moderationResponse()`/`reviewResponses()` are set up in that file. + +- [ ] **Step 2: Write the failing test** + +Append to `tests/Unit/Jobs/ModerateCommitTest.php`, invoking the job exactly as the existing image test does (adapt the two marked lines to the file's established invocation): + +```php +it('moderates an attached gif by its giphy url', function (): void { + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->withGiphy()->create(); + + // Invoke the job the same way the existing image-moderation test in this file does: + (new ModerateCommit($commit))->handle(resolve(App\Actions\UpdateCommitAiDecision::class)); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') + && str_contains($request->body(), 'media2.giphy.com')); + + // The minors check runs because the commit has an image URL (the gif): + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'responses') + && str_contains($request->body(), 'media2.giphy.com')); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `./vendor/bin/pest tests/Unit/Jobs/ModerateCommitTest.php --filter="giphy"` +Expected: FAIL — no request body contains the Giphy host. + +- [ ] **Step 4: Implement** + +a) `app/Actions/UpdateCommitAiDecision.php` — replace the `$images` construction at the top of `handle()` with: + +```php + $images = array_values($commit->images->map( + fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), + )->all()); + + if ($commit->giphy_url !== null) { + $images[] = $commit->giphy_url; + } +``` + +(The existing `$images !== []` gate before `showsMinors()` now covers GIFs automatically.) Update the method docblock's first line to ", images and GIF included,". + +b) `app/Services/OpenAI.php` — rename the `$imageDataUrls` parameter (and every local use) to `$imageUrls` in both `moderate()` and `showsMinors()`, and update their docblocks to say the entries are "data URLs or remote https URLs". No logic change. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./vendor/bin/pest tests/Unit/Jobs/ModerateCommitTest.php` +Expected: PASS (new + existing). + +- [ ] **Step 6: Commit** + +```bash +git add app/Actions/UpdateCommitAiDecision.php app/Services/OpenAI.php tests/Unit/Jobs/ModerateCommitTest.php +git commit -m "feat: moderate attached gifs by their giphy url" +``` + +--- + +### Task 6: Public API exposes the GIF + +**Files:** +- Modify: `app/Http/Resources/CommitResource.php` +- Test: locate with `rg -l "api/v1/commits" tests/Feature/` (expected: a file like `tests/Feature/Api/CommitControllerTest.php`) + +**Interfaces:** +- Consumes: `Commit::gif()` (Task 1). +- Produces: `gif` field in `/api/v1` commit JSON. + +- [ ] **Step 1: Write the failing test** + +Append to the located API test file: + +```php +it('includes the gif of a commit', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif.id', 'xT9IgzoKnwFNmISR8I') + ->assertJsonPath('data.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->assertJsonPath('data.gif.width', 480); +}); + +it('includes a null gif for a plain commit', function (): void { + $commit = Commit::factory()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif', null); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./vendor/bin/pest --filter="gif"` +Expected: FAIL — `gif` key missing. + +- [ ] **Step 3: Implement** + +In `app/Http/Resources/CommitResource.php`, after `'images' => $this->images,`: + +```php + 'gif' => $this->gif(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./vendor/bin/pest ` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/Http/Resources/CommitResource.php tests/Feature/Api +git commit -m "feat: expose attached gifs in the public api" +``` + +--- + +### Task 7: OG metadata uses the GIF + +**Files:** +- Modify: `app/Services/PageMetadata.php` (`commitImages()`, lines ~235–260) +- Test: locate with `rg -l "PageMetadata|og:image" tests/ | head -5` (expected: a Unit test for the service or the commit-show feature test asserting meta tags — follow whichever file already asserts `image/webp` commit images, found via `rg -ln "image/webp" tests/`) + +**Interfaces:** +- Consumes: `giphy_url`, `giphy_width`, `giphy_height`, `giphy_title` columns (Task 1). +- Produces: an `image/gif` OG image entry for GIF commits. + +- [ ] **Step 1: Write the failing test** + +Mirror the located file's existing uploaded-image OG test (same setup and assertion style), with a GIF commit: + +```php +it('uses the attached gif as an open graph image', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + // Mirror the existing commit-image OG test's invocation/assertions in this file. The assertions must cover: + // - an image entry with url 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123' + // - type 'image/gif', width 480, height 270 + // - alt 'Deal With It GIF' +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./vendor/bin/pest --filter="open graph image"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `app/Services/PageMetadata.php::commitImages()`, after the `foreach ($commit->images->values() ...)` loop and before `return $images;`: + +```php + if ($commit->giphy_url !== null) { + $images[] = [ + 'url' => $commit->giphy_url, + 'width' => (int) $commit->giphy_width, + 'height' => (int) $commit->giphy_height, + 'type' => 'image/gif', + 'alt' => $commit->giphy_title ?? "GIF attached to the commit of {$user->nameWithHandle()}", + ]; + } +``` + +Note the method's early return requires `imagesAreVisibleTo(null)` — the GIF correctly inherits that guard. If the early return also requires `relationLoaded('images')` and the commit in your test has no loaded relation, call `$commit->load('images')` in the test (mirroring the neighboring tests). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./vendor/bin/pest ` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/PageMetadata.php tests/ +git commit -m "feat: use the attached gif as the commit og image" +``` + +--- + +### Task 8: Atom feed enclosure + +**Files:** +- Modify: `app/Actions/CreateUserFeed.php` (`entry()`) +- Test: locate with `rg -l "feed.atom|user-feed" tests/Feature/` (expected: the user feed controller test) + +**Interfaces:** +- Consumes: `giphy_url` (Task 1); `CreateUserFeed::link()` private helper (exists). +- Produces: `image/gif` enclosure for GIF commits. + +- [ ] **Step 1: Write the failing test** + +Append to the located feed test file: + +```php +it('lists an attached gif as an enclosure', function (): void { + $user = User::factory()->create(); + Commit::factory()->for($user)->withGiphy()->create(); + + $response = $this->get(route('user-feed.show', $user)); + + $response->assertOk(); + expect($response->getContent()) + ->toContain('rel="enclosure"') + ->toContain('type="image/gif"') + ->toContain('https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'); +}); +``` + +If the feed is cached (README: five minutes), check how neighboring tests bust or avoid the cache and do the same. The enclosure URL lands XML-escaped (`&` for `&`); if the `toContain` on the full URL fails for that reason, assert on `'giphy-downsized-medium.gif'` instead. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./vendor/bin/pest --filter="enclosure"` +Expected: FAIL — no `image/gif` enclosure. + +- [ ] **Step 3: Implement** + +In `app/Actions/CreateUserFeed.php::entry()`, after the images `foreach` loop: + +```php + if ($commit->giphy_url !== null) { + $this->link($xml, 'enclosure', $commit->giphy_url, 'image/gif'); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./vendor/bin/pest ` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/Actions/CreateUserFeed.php tests/ +git commit -m "feat: include attached gifs as atom enclosures" +``` + +--- + +### Task 9: Frontend Giphy library + +**Files:** +- Create: `resources/js/lib/giphy.ts` +- Test: Create `tests/Unit/Giphy.test.ts` (node:test style, like `tests/Unit/SensitiveText.test.ts`) + +**Interfaces:** +- Consumes: nothing. +- Produces (used by Tasks 10–13): types `GiphyAttachment = { id: string; url: string; page_url: string; title: string | null; username: string | null; width: number; height: number }` and `GiphyResult`; `giphySearchUrl(key, query, offset?)`, `giphyTrendingUrl(key, offset?)`, `fetchGifs(url, signal?)`, `previewUrlFor(result)`, `attachmentFromResult(result)`, `giphyFormFields(gif)`, class `GiphyRateLimited`. + +- [ ] **Step 1: Write the failing tests** + +`tests/Unit/Giphy.test.ts`: + +```ts +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + attachmentFromResult, + giphyFormFields, + giphySearchUrl, + giphyTrendingUrl, + previewUrlFor, +} from '../../resources/js/lib/giphy.ts'; +import type { GiphyResult } from '../../resources/js/lib/giphy.ts'; + +const result: GiphyResult = { + id: 'xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + images: { + fixed_height_small: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', + width: '178', + height: '100', + }, + downsized_medium: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + width: '480', + height: '270', + }, + downsized: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + width: '400', + height: '225', + }, + }, +}; + +await test('builds a search url with the g rating always on', () => { + const url = new URL(giphySearchUrl('key-123', 'deal with it', 24)); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); + assert.equal(url.searchParams.get('api_key'), 'key-123'); + assert.equal(url.searchParams.get('q'), 'deal with it'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '24'); +}); + +await test('builds a trending url with the g rating always on', () => { + const url = new URL(giphyTrendingUrl('key-123')); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/trending'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '0'); +}); + +await test('picks the downsized_medium gif rendition verbatim', () => { + const gif = attachmentFromResult(result); + assert.deepEqual(gif, { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, + }); +}); + +await test('falls back through downsized to original and bails without any', () => { + const { downsized_medium: _dropped, ...rest } = result.images; + const fallback = attachmentFromResult({ ...result, images: rest }); + assert.equal( + fallback?.url, + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + ); + assert.equal(attachmentFromResult({ ...result, images: {} }), null); +}); + +await test('turns empty title and username into null', () => { + const gif = attachmentFromResult({ ...result, title: '', username: '' }); + assert.equal(gif?.title, null); + assert.equal(gif?.username, null); +}); + +await test('uses the small fixed-height rendition for previews', () => { + assert.equal( + previewUrlFor(result), + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', + ); + assert.equal( + previewUrlFor({ ...result, images: { downsized: result.images.downsized } }), + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + ); + assert.equal(previewUrlFor({ ...result, images: {} }), null); +}); + +await test('shapes the form fields the server expects', () => { + const gif = attachmentFromResult(result); + assert.ok(gif); + assert.deepEqual(giphyFormFields(gif), { + giphy_id: 'xT9IgzoKnwFNmISR8I', + giphy_url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + giphy_page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + giphy_title: 'Deal With It GIF', + giphy_username: 'giphy', + giphy_width: 480, + giphy_height: 270, + }); +}); +``` + +`git add` the file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --experimental-strip-types --test tests/Unit/Giphy.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +`resources/js/lib/giphy.ts`: + +```ts +export type GiphyAttachment = { + id: string; + url: string; + page_url: string; + title: string | null; + username: string | null; + width: number; + height: number; +}; + +type GiphyRendition = { url: string; width: string; height: string }; + +export type GiphyResult = { + id: string; + title: string; + username: string; + url: string; + images: Partial< + Record< + 'downsized_medium' | 'downsized' | 'original' | 'fixed_height_small', + GiphyRendition + > + >; +}; + +export class GiphyRateLimited extends Error { + constructor() { + super('GIPHY is rate limiting searches.'); + } +} + +export const pageSize = 24; + +const baseUrl = 'https://api.giphy.com/v1/gifs'; + +const params = (key: string, offset: number): URLSearchParams => + new URLSearchParams({ + api_key: key, + limit: String(pageSize), + offset: String(offset), + rating: 'g', + }); + +export function giphySearchUrl(key: string, query: string, offset = 0): string { + const search = params(key, offset); + search.set('q', query); + + return `${baseUrl}/search?${search}`; +} + +export function giphyTrendingUrl(key: string, offset = 0): string { + return `${baseUrl}/trending?${params(key, offset)}`; +} + +/** + * GIPHY requires client-side API calls, so this runs in the browser with the shared key. + */ +export async function fetchGifs( + url: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(url, { signal }); + + if (response.status === 429) { + throw new GiphyRateLimited(); + } + + if (!response.ok) { + throw new Error(`GIPHY request failed: ${response.status}`); + } + + const data = (await response.json()) as { data: GiphyResult[] }; + + return data.data; +} + +/** + * The small fixed-height rendition keeps the picker grid light; bigger renditions stand in when it is missing. + */ +export function previewUrlFor(result: GiphyResult): string | null { + const rendition = + result.images.fixed_height_small ?? + result.images.downsized ?? + result.images.downsized_medium ?? + result.images.original; + + return rendition?.url ?? null; +} + +/** + * The stored rendition is always a .gif around feed size, and its URL is kept verbatim as GIPHY's terms require. + */ +export function attachmentFromResult( + result: GiphyResult, +): GiphyAttachment | null { + const rendition = + result.images.downsized_medium ?? + result.images.downsized ?? + result.images.original; + + if (rendition === undefined) { + return null; + } + + return { + id: result.id, + url: rendition.url, + page_url: result.url, + title: result.title === '' ? null : result.title, + username: result.username === '' ? null : result.username, + width: Number.parseInt(rendition.width, 10), + height: Number.parseInt(rendition.height, 10), + }; +} + +/** + * The hidden fields every composer submits for an attached GIF, named exactly as the server validates them. + */ +export function giphyFormFields( + gif: GiphyAttachment, +): Record { + return { + giphy_id: gif.id, + giphy_url: gif.url, + giphy_page_url: gif.page_url, + giphy_title: gif.title ?? '', + giphy_username: gif.username ?? '', + giphy_width: gif.width, + giphy_height: gif.height, + }; +} +``` + +`git add` the file. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --experimental-strip-types --test tests/Unit/Giphy.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add resources/js/lib/giphy.ts tests/Unit/Giphy.test.ts +git commit -m "feat: add the client-side giphy api module" +``` + +--- + +### Task 10: Commit rendering + +**Files:** +- Modify: `resources/js/types/commit.ts` (add `CommitGif`, `gif` on `Commit`) +- Create: `resources/js/components/commit-giphy.tsx` +- Modify: `resources/js/components/commit-card.tsx` (one line, next to `CommitImages`) +- Test: Create `tests/Unit/CommitGiphy.test.tsx` (node:test style like `tests/Unit/CommitImages.test.tsx` — read that file first to copy its element-walking helpers) + +**Interfaces:** +- Consumes: the server `gif` payload shape (Task 1). +- Produces: `CommitGif` type; `CommitGiphy({ gif })` component used by Task 13's preview-mode rendering. + +- [ ] **Step 1: Add the type** + +In `resources/js/types/commit.ts`, after the `CommitImage` type: + +```ts +export type CommitGif = { + id: string; + url: string; + page_url: string; + title: string | null; + username: string | null; + width: number | null; + height: number | null; +}; +``` + +and on the `Commit` type, after `images?: CommitImage[];`: + +```ts + gif?: CommitGif | null; +``` + +Check whether `commit.ts` types are re-exported from `resources/js/types/index.ts` (`rg -n "CommitImage" resources/js/types/index.ts`) and mirror the export for `CommitGif` if so. + +- [ ] **Step 2: Write the failing component test** + +Read `tests/Unit/CommitImages.test.tsx` first and reuse its approach (components invoked as plain functions, assertions on the returned element tree). Create `tests/Unit/CommitGiphy.test.tsx` covering, with that file's helpers: + +```tsx +// Using the same imports/helpers as tests/Unit/CommitImages.test.tsx, assert that CommitGiphy: +// 1. renders an with src equal to the gif url verbatim (query string intact), +// alt from the title, loading="lazy", and referrerPolicy="no-referrer"; +// 2. falls back to alt 'Attached GIF' when title is null; +// 3. renders an to gif.page_url reading 'GIF by @giphy on GIPHY' when username is set, +// and 'via GIPHY' when username is null. + +const gif = { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, +}; +``` + +Run: `node --experimental-strip-types --test tests/Unit/CommitGiphy.test.tsx` +Expected: FAIL — module not found. (If `.tsx` cannot run under `--experimental-strip-types` in this repo — check how `CommitImages.test.tsx` is executed by `rg -n "CommitImages" package.json tests/Unit/SeriesStash.test.ts tests/draft-images.config.ts` — register the new test exactly the way `CommitImages.test.tsx` is registered.) + +- [ ] **Step 3: Implement the component** + +`resources/js/components/commit-giphy.tsx`: + +```tsx +import { cn } from '@/lib/utils'; +import type { CommitGif } from '@/types/commit'; + +/** + * An attached GIPHY GIF, hotlinked exactly as GIPHY's API returned it, with the attribution its terms require. + */ +export function CommitGiphy({ + gif, + className, +}: { + gif: CommitGif; + className?: string; +}) { + return ( +
+ {gif.title { + const image = event.currentTarget; + if (image.getAttribute('src') !== '/favicon.svg') { + image.src = '/favicon.svg'; + image.classList.replace( + 'object-cover', + 'object-contain', + ); + } + }} + className="max-h-[32rem] w-full bg-muted object-cover" + /> + event.stopPropagation()} + className="absolute right-1.5 bottom-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[11px] font-medium text-foreground backdrop-blur transition hover:bg-background" + > + {gif.username ? `GIF by @${gif.username} on GIPHY` : 'via GIPHY'} + +
+ ); +} +``` + +`git add` the new files. + +- [ ] **Step 4: Wire the commit card** + +In `resources/js/components/commit-card.tsx`, import `CommitGiphy` next to the `CommitImages` import, and after the line `{showImages && }` add: + +```tsx + {showImages && commit.gif && ( + + )} +``` + +- [ ] **Step 5: Run tests and the type check** + +Run: `node --experimental-strip-types --test tests/Unit/CommitGiphy.test.tsx` → PASS. +Run: `composer test:types` → PASS (PHPStan + tsc). + +- [ ] **Step 6: Commit** + +```bash +git add resources/js/types/commit.ts resources/js/components/commit-giphy.tsx resources/js/components/commit-card.tsx tests/Unit/CommitGiphy.test.tsx +git commit -m "feat: render attached gifs on commit cards" +``` + +--- + +### Task 11: Draft stash remembers the GIF + +**Files:** +- Create: `resources/js/lib/gif-storage.ts` +- Modify: `resources/js/hooks/use-stash.ts` +- Test: Create `tests/Unit/GifStorage.test.ts` (node:test) + +**Interfaces:** +- Consumes: `GiphyAttachment` (Task 9); `readStorage`/`writeStorage` from `resources/js/lib/storage.ts` (existing). +- Produces: `readStoredGif(key): GiphyAttachment | null`, `writeStoredGif(key, gif | null)`; `UseStashReturn` gains `gif: GiphyAttachment | null` and `updateGif(gif: GiphyAttachment | null): void`; `drop()` clears the GIF; `isRestored`/`useHasStash` account for it. + +- [ ] **Step 1: Read the storage helper** + +Run: `cat resources/js/lib/storage.ts` — confirm `readStorage(key): string` and `writeStorage(key, value)` wrap `localStorage` with graceful failure, and see whether tests stub `localStorage` globally (check `rg -n "localStorage" tests/Unit/*.test.ts` for the established stub pattern). + +- [ ] **Step 2: Write the failing tests** + +`tests/Unit/GifStorage.test.ts` (adapt the `localStorage` stub to the pattern found in Step 1; the fallback below works when none exists): + +```ts +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const store = new Map(); +(globalThis as Record).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; + +const { readStoredGif, writeStoredGif } = await import( + '../../resources/js/lib/gif-storage.ts' +); + +const gif = { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, +}; + +await test('round-trips a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + assert.deepEqual(readStoredGif('stash:u1:commit'), gif); +}); + +await test('clears a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + writeStoredGif('stash:u1:commit', null); + assert.equal(readStoredGif('stash:u1:commit'), null); +}); + +await test('ignores unreadable stashed values', () => { + store.set('stash:u1:reply-1:gif', 'not json'); + assert.equal(readStoredGif('stash:u1:reply-1'), null); + store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); + assert.equal(readStoredGif('stash:u1:reply-2'), null); +}); +``` + +Run: `node --experimental-strip-types --test tests/Unit/GifStorage.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement the storage module** + +`resources/js/lib/gif-storage.ts`: + +```ts +import type { GiphyAttachment } from '@/lib/giphy'; +import { readStorage, writeStorage } from '@/lib/storage'; + +const gifKey = (key: string): string => `${key}:gif`; + +/** + * A stashed GIF is tiny JSON in localStorage, unlike stashed images, which are Files in IndexedDB. + */ +export function readStoredGif(key: string): GiphyAttachment | null { + const raw = readStorage(gifKey(key)); + + if (raw === '') { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + + return typeof parsed.id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.page_url === 'string' + ? (parsed as GiphyAttachment) + : null; + } catch { + return null; + } +} + +export function writeStoredGif(key: string, gif: GiphyAttachment | null): void { + writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); +} +``` + +Note: the tests import with a relative path while the module imports with `@/` aliases — if node cannot resolve `@/lib/storage` when stripping types, check how existing node tests handle alias imports (`rg -n "@/" tests/Unit/SensitiveText.test.ts resources/js/lib/sensitive-text.ts`); if source modules under test only ever use relative imports, use `./giphy` and `./storage` here instead of the alias. + +Run the tests again → PASS. `git add` the new files. + +- [ ] **Step 4: Extend use-stash** + +In `resources/js/hooks/use-stash.ts`: + +a) Imports: + +```ts +import { readStoredGif, writeStoredGif } from '@/lib/gif-storage'; +import type { GiphyAttachment } from '@/lib/giphy'; +``` + +b) `UseStashReturn` gains: + +```ts + readonly gif: GiphyAttachment | null; + readonly updateGif: (gif: GiphyAttachment | null) => void; +``` + +c) Module level, next to the `images` map: + +```ts +const gifs = new Map(); + +const gifFor = (key: string): GiphyAttachment | null => { + if (!gifs.has(key)) { + gifs.set(key, readStoredGif(key)); + } + + return gifs.get(key) ?? null; +}; + +const storeGif = (key: string, gif: GiphyAttachment | null): void => { + gifs.set(key, gif); + writeStoredGif(key, gif); + notify(); +}; +``` + +d) In `useStash()`: + +```ts + const gif = useSyncExternalStore( + subscribe, + () => gifFor(key), + () => null, + ); +``` + +initial restored state becomes: + +```ts + const [isDraftRestored, setIsDraftRestored] = useState( + () => body !== '' || draftImages.length > 0 || gifFor(key) !== null, + ); +``` + +add the updater: + +```ts + const updateGif = (next: GiphyAttachment | null): void => { + if (next === null && body === '' && draftImages.length === 0) { + setIsDraftRestored(false); + } + + storeGif(key, next); + }; +``` + +`drop()` additionally calls `storeGif(key, null);`, and the returned object adds `gif, updateGif` and widens `isRestored` to: + +```ts + isRestored: + (isDraftRestored || isImagesRestored) && + (body !== '' || draftImages.length > 0 || gif !== null), +``` + +e) `useHasStash()`'s snapshot adds `|| gifFor(key) !== null`, and `clearStashes()` adds, after the images cleanup loop: + +```ts + [...gifs.keys()] + .filter((key) => key.startsWith(prefix)) + .forEach((key) => gifs.delete(key)); +``` + +(The persisted `:gif` localStorage entries already start with the prefix, so the existing `storedKeys()` loop clears them.) + +- [ ] **Step 5: Verify types and existing suites** + +Run: `composer test:types` → PASS. +Run: `bun run test:unit` → PASS (existing stash/draft suites unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add resources/js/lib/gif-storage.ts resources/js/hooks/use-stash.ts tests/Unit/GifStorage.test.ts +git commit -m "feat: stash a drafted gif across reloads" +``` + +--- + +### Task 12: Picker UI components + +**Files:** +- Create: `resources/js/components/giphy-picker-button.tsx`, `resources/js/components/giphy-picker-dialog.tsx`, `resources/js/components/giphy-attachment-preview.tsx` +- Create: `public/images/powered-by-giphy.png` (official mark) + +**Interfaces:** +- Consumes: `lib/giphy.ts` (Task 9), shared `giphy` prop (Task 4), `ui/dialog.tsx`, `ui/button.tsx`, `ui/input.tsx` (existing). +- Produces (used by Task 13): `GiphyPickerButton({ disabled, hasGif, onPick, className })` — renders nothing without a configured key; `GiphyAttachmentPreview({ gif, onRemove })`. + +- [ ] **Step 1: Fetch the official attribution mark** + +```bash +mkdir -p public/images +curl -fL -o /tmp/giphy-marks.zip https://giphymedia.s3.amazonaws.com/giphy-attribution-marks.zip && unzip -o /tmp/giphy-marks.zip -d /tmp/giphy-marks && find /tmp/giphy-marks -iname "*horizontal*light*badge*.png" -o -iname "*PoweredBy*200px*.png" | head -5 +``` + +Copy the smallest horizontal "Powered By GIPHY" PNG found to `public/images/powered-by-giphy.png` and `git add` it. **If the download fails** (URL retired), render the attribution as styled text instead — in the dialog footer below, replace the `` with `Powered By GIPHY` — and state in the PR description that the official mark still needs to be dropped in before applying for a production key. + +- [ ] **Step 2: Implement the dialog** + +`resources/js/components/giphy-picker-dialog.tsx`: + +```tsx +import { useEffect, useState } from 'react'; +import type { GiphyResult } from '@/lib/giphy'; +import { + attachmentFromResult, + fetchGifs, + giphySearchUrl, + giphyTrendingUrl, + GiphyRateLimited, + pageSize, + previewUrlFor, +} from '@/lib/giphy'; +import type { GiphyAttachment } from '@/lib/giphy'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; + +type Status = 'loading' | 'ready' | 'error' | 'rate-limited'; + +export function GiphyPickerDialog({ + apiKey, + open, + onOpenChange, + onPick, +}: { + apiKey: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onPick: (gif: GiphyAttachment) => void; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [status, setStatus] = useState('loading'); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [reloads, setReloads] = useState(0); + + const urlFor = (offset: number) => + query.trim() === '' + ? giphyTrendingUrl(apiKey, offset) + : giphySearchUrl(apiKey, query.trim(), offset); + + useEffect(() => { + if (!open) { + return; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => { + setStatus('loading'); + fetchGifs(urlFor(0), controller.signal) + .then((gifs) => { + setResults(gifs); + setStatus('ready'); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + + setStatus( + error instanceof GiphyRateLimited + ? 'rate-limited' + : 'error', + ); + }); + }, + query === '' ? 0 : 300, + ); + + return () => { + clearTimeout(timeout); + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- urlFor derives from query/apiKey + }, [open, query, apiKey, reloads]); + + const loadMore = () => { + setIsLoadingMore(true); + fetchGifs(urlFor(results.length)) + .then((gifs) => setResults((current) => [...current, ...gifs])) + .catch(() => undefined) + .finally(() => setIsLoadingMore(false)); + }; + + const pick = (result: GiphyResult) => { + const gif = attachmentFromResult(result); + + if (gif !== null) { + onPick(gif); + onOpenChange(false); + setQuery(''); + } + }; + + return ( + + + + Attach a GIF + + Search GIPHY and pick a GIF to attach. + + + setQuery(event.target.value)} + placeholder="grep giphy…" + aria-label="Search GIPHY" + data-test="giphy-search-input" + /> + {status === 'loading' && ( +

+ Loading… +

+ )} + {status === 'rate-limited' && ( +

+ GIPHY is rate limiting searches. Try again shortly. +

+ )} + {status === 'error' && ( +
+

+ GIPHY did not answer. +

+ +
+ )} + {status === 'ready' && results.length === 0 && ( +

+ No GIFs matched. /dev/null remains empty. +

+ )} + {status === 'ready' && results.length > 0 && ( + <> +
    + {results.map((result) => { + const preview = previewUrlFor(result); + + return preview === null ? null : ( +
  • + +
  • + ); + })} +
+ {results.length % pageSize === 0 && ( + + )} + + )} +
+ + Powered By GIPHY + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Implement the button** + +`resources/js/components/giphy-picker-button.tsx`: + +```tsx +import { usePage } from '@inertiajs/react'; +import { ImagePlay } from 'lucide-react'; +import { useState } from 'react'; +import { GiphyPickerDialog } from '@/components/giphy-picker-dialog'; +import { Button } from '@/components/ui/button'; +import type { GiphyAttachment } from '@/lib/giphy'; +import { cn } from '@/lib/utils'; + +/** + * Invisible without a configured GIPHY key, so self-hosters without one never see the feature. + */ +export function GiphyPickerButton({ + disabled, + hasGif, + onPick, + className, +}: { + disabled?: boolean; + hasGif: boolean; + onPick: (gif: GiphyAttachment) => void; + className?: string; +}) { + const { giphy } = usePage().props; + const [open, setOpen] = useState(false); + + if (giphy === null) { + return null; + } + + return ( + <> + + + + ); +} +``` + +(If `ImagePlay` is missing from the installed lucide-react, use `Clapperboard` — verify with `rg -n "ImagePlay|Clapperboard" node_modules/lucide-react/dist/lucide-react.d.ts | head -2`.) + +- [ ] **Step 4: Implement the composer preview thumb** + +`resources/js/components/giphy-attachment-preview.tsx`: + +```tsx +import { X } from 'lucide-react'; +import type { GiphyAttachment } from '@/lib/giphy'; + +export function GiphyAttachmentPreview({ + gif, + onRemove, +}: { + gif: GiphyAttachment | null; + onRemove: () => void; +}) { + if (gif === null) { + return null; + } + + return ( +
+
+ {gif.title + + + GIF + +
+
+ ); +} +``` + +- [ ] **Step 5: Verify types and commit** + +Run: `composer test:types` → PASS. + +```bash +git add resources/js/components/giphy-picker-button.tsx resources/js/components/giphy-picker-dialog.tsx resources/js/components/giphy-attachment-preview.tsx public/images/powered-by-giphy.png +git commit -m "feat: add the giphy picker dialog and button" +``` + +--- + +### Task 13: Wire the three composers + +**Files:** +- Modify: `resources/js/components/commit-composer.tsx`, `resources/js/components/commit-thread.tsx` (ReplyComposer), `resources/js/components/commit-quote-button.tsx` (QuoteComposer) + +**Interfaces:** +- Consumes: `GiphyPickerButton`, `GiphyAttachmentPreview` (Task 12), `giphyFormFields` (Task 9), `useStash().gif/updateGif` (Task 11), `CommitGiphy` (Task 10). +- Produces: all three composers submit the `giphy_*` fields; exclusivity per spec — picking a GIF clears uploads and poll; attaching images or starting a poll clears the GIF. + +- [ ] **Step 1: Wire the main composer** + +In `resources/js/components/commit-composer.tsx`: + +a) Imports: `GiphyPickerButton`, `GiphyAttachmentPreview`, `giphyFormFields` and `type GiphyAttachment`. + +b) In `CommitComposer`, destructure nothing new (keep using `stash.gif`); extend the `transform` chain — the current ternary becomes: + +```tsx + transform={(data) => + isScheduling + ? { + ...data, + scheduled_at: new Date(scheduledAt) + .toISOString() + .replace(/\.\d{3}Z$/, '+00:00'), + } + : stash.gif !== null + ? { ...data, ...giphyFormFields(stash.gif) } + : hasImages + ? { + ...data, + images: attachments.images, + image_descriptions: attachments.descriptions, + } + : data + } +``` + +(`drop()` inside `onSuccess` already clears the stashed GIF after Task 11.) + +c) In `CommitComposerBody` (which receives `stash` and `attachments` as props), define the handlers near the top: + +```tsx + const pickGif = (gif: GiphyAttachment) => { + attachments.clear(); + setPoll(null); + stash.updateGif(gif); + }; +``` + +d) Toolbar: change the `ImagePickerButton`'s `onPick` from `attachments.attach` to: + +```tsx + onPick={(files) => { + stash.updateGif(null); + attachments.attach(files); + }} +``` + +add, right after the `ImagePickerButton` block: + +```tsx + {!isScheduling && ( + + )} +``` + +and extend the poll button's `onClick` with `stash.updateGif(null);` as its first statement, plus its `disabled` with `|| stash.gif !== null` and a matching `title` branch (`'A commit can have a GIF or a poll, not both'`). + +e) Render the preview thumb next to `ImagePickerPreviews` (same conditional context): + +```tsx + stash.updateGif(null)} + /> +``` + +f) Preview mode: find where the composer's preview branch renders draft images (`rg -n "CommitImages|ImagePickerPreviews" resources/js/components/composer-preview.tsx resources/js/components/commit-composer.tsx`). If the preview branch renders a draft ``, render next to it: + +```tsx + {stash.gif !== null && ( + + )} +``` + +(`GiphyAttachment` is structurally assignable to `CommitGif`.) If drafts only show `ImagePickerPreviews` outside the preview, skip this — the thumb from (e) already covers it. + +g) Server error surfacing: in the spot where `imageErrorFrom(errors)` feeds `InputError`, also surface GIF errors — extend `imageErrorFrom` in `resources/js/components/commit-image-picker.tsx` so its key scan adds `|| name.startsWith('giphy_')`. + +- [ ] **Step 2: Wire the reply composer** + +In `resources/js/components/commit-thread.tsx` (`ReplyComposer`): + +a) Destructure `gif, updateGif` from the `useStash(...)` call. Extend the `useHttp` data generic with the optional fields: + +```tsx + giphy_id?: string; + giphy_url?: string; + giphy_page_url?: string; + giphy_title?: string; + giphy_username?: string; + giphy_width?: number; + giphy_height?: number; +``` + +b) In `submit()`, extend the `request.setData(() => ({ ... }))` object: + +```tsx + ...(gif !== null ? giphyFormFields(gif) : {}), +``` + +(and drop `images`/`image_descriptions` from the payload when `gif !== null`: `images: gif === null ? images : [],` and `image_descriptions: gif === null ? descriptions : [],`). + +c) Toolbar: after the `ImagePickerButton` (whose `onPick` becomes the clear-gif wrapper as in Step 1d): + +```tsx + { + attachments.clear(); + updateGif(picked); + }} + className="-ml-2 size-8" + /> +``` + +d) Render ` updateGif(null)} />` next to the reply's `ImagePickerPreviews`, and add `drop()`-adjacent cleanup: the existing `onSuccess` already calls `drop()`, which clears the GIF. + +- [ ] **Step 3: Wire the quote composer** + +In `resources/js/components/commit-quote-button.tsx` (`QuoteComposer`): same pattern — destructure `gif, updateGif` from `useStash`, extend the `
`: + +```tsx + transform={(data) => + gif !== null + ? { ...data, ...giphyFormFields(gif) } + : images.length > 0 + ? { + ...data, + images, + image_descriptions: descriptions, + } + : data + } +``` + +add the `GiphyPickerButton` after the `ImagePickerButton` (wrapping `onPick` to clear the GIF, as in Step 1d), and render the `GiphyAttachmentPreview` next to the quote's image previews. + +- [ ] **Step 4: Verify** + +Run: `composer test:types` → PASS. +Run: `bun run test:unit` → PASS (existing composer suites must not break; if `ComposerDraft.test.tsx` or `QuoteDraft.test.tsx` fail because `useStash` now returns extra keys or `usePage().props.giphy` is read, extend their mocks minimally — add `giphy: null` to the mocked page props and `gif: null, updateGif: () => {}` to any stubbed stash objects). +Manual QA (run `composer dev` with `GIPHY_KEY` set and `AUTO_LOGIN=true`): pick a GIF in each composer; confirm picking a GIF removes attached images and vice versa; post and confirm the card renders the GIF with attribution; reload mid-draft and confirm the GIF is restored; confirm the button is absent with `GIPHY_KEY` empty. + +- [ ] **Step 5: Commit** + +```bash +git add resources/js/components/commit-composer.tsx resources/js/components/commit-thread.tsx resources/js/components/commit-quote-button.tsx resources/js/components/commit-image-picker.tsx +git commit -m "feat: attach giphy gifs from every composer" +``` + +--- + +### Task 14: Docs and full verification + +**Files:** +- Modify: `README.md`, `PRODUCT.md` + +- [ ] **Step 1: README** + +After the Embeds section, add: + +```markdown +### GIFs + +With a [GIPHY API key](https://developers.giphy.com) in `GIPHY_KEY`, every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. The browser talks to GIPHY directly (its terms require client-side calls, so the key ships to the client) and commits store only the GIF's URLs; nothing is downloaded or rehosted. Without a key the picker is hidden. Beta keys allow about 42 searches an hour; production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. +``` + +- [ ] **Step 2: PRODUCT.md** + +a) In "Capabilities and Constraints", add a bullet after the link-cards bullet: + +```markdown +- GIFs: with a GIPHY key configured, a commit, reply, or quote can carry one GIF from GIPHY's catalog — never alongside uploaded images or a poll, and never on a scheduled commit. The GIF is hotlinked from GIPHY with creator attribution; /dev/null stores only its URLs, as GIPHY's API terms require. +``` + +b) In the "Deliberately excluded for now" bullet, remove `images/media,` (stale — commits already carry images, and now GIFs). + +- [ ] **Step 3: Full verification** + +Run: `composer test` +Expected: PASS — lint (Rector/Pint/Oxfmt dry-run), PHPStan + tsc, 100% type coverage, Pest at exactly 100.0% coverage, then `bun run test:unit`. If Pint/oxfmt flag formatting, run `composer lint` and re-run `composer test`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md PRODUCT.md +git commit -m "docs: document the giphy gif picker" +``` + +--- + +## Self-Review Notes + +- Spec coverage: data model + validation (Tasks 1–3), key delivery (4), moderation (5), public API (6), OG (7), Atom (8), client library + `rating=g` + renditions (9), rendering + attribution + fallback (10), stash (11), picker + Powered By GIPHY (12), composer wiring + exclusivity + error surfacing (13), docs incl. PRODUCT.md (14). Quote embeds and the photo viewer intentionally untouched (spec: out of scope). +- Type consistency: `Giphy` DTO fields ↔ `giphy()` helper ↔ `toColumns()` ↔ migration columns ↔ `gif()` array ↔ `CommitGif` TS type ↔ `giphyFormFields` keys all use the same seven names. +- Known locate-steps (API/OG/feed test files, reply controller test name, ComposerDraft mocks) exist because those files were not read during planning; each has an exact `rg` command and the content to produce. diff --git a/docs/superpowers/specs/2026-10-16-giphy-picker-design.md b/docs/superpowers/specs/2026-10-16-giphy-picker-design.md index 62e8d6b41..f425cea48 100644 --- a/docs/superpowers/specs/2026-10-16-giphy-picker-design.md +++ b/docs/superpowers/specs/2026-10-16-giphy-picker-design.md @@ -43,6 +43,8 @@ the metadata never refetches server-side, max one per commit, and it is tiny): | `giphy_page_url` | text | giphy.com page URL, for the attribution link | | `giphy_title` | string(300) | creator title → alt text | | `giphy_username` | string(100) | creator username, for attribution where available | +| `giphy_width` | unsignedSmallInteger | rendition width, for layout stability and OG tags | +| `giphy_height` | unsignedSmallInteger | rendition height, for layout stability and OG tags | **Rendition:** the stored `giphy_url` is always a **`.gif` rendition** (prefer `downsized_medium`, fall back to `downsized`, then `original`), so the `image/gif` MIME type @@ -50,7 +52,7 @@ declared in Atom enclosures and OG tags is honest. Picker thumbnails use a small rendition and are never stored. **Serialization:** `Commit::toArray()` strips the raw `giphy_*` keys and appends a derived -`gif` object — `{id, url, page_url, title, username} | null`. The existing tombstone branch +`gif` object — `{id, url, page_url, title, username, width, height} | null`. The existing tombstone branch already whitelists only `id/parent_id/deleted_at/replies_count`, so deleted commits drop the GIF for free. Pending/rejected commits only ship to their author and admins (existing `isVisibleTo` gating), which covers GIF visibility — the URL is public on Giphy regardless. @@ -63,9 +65,12 @@ tombstone semantics are unchanged. In `CreateCommitRequest` and `CreateCommitReplyRequest` (these cover commits, quotes, and replies; the CLI commit path does not accept attachments and is untouched): -- `giphy_url`: `https` scheme, host matching `media\d*.giphy.com`, path starting - `/media/{giphy_id}/`, **query string allowed and preserved verbatim**, no fragment. -- `giphy_id`: `[A-Za-z0-9]+`, max 64, and must match the id segment in `giphy_url`. +- `giphy_url`: `https` scheme, host matching `media\d*.giphy.com`, path under `/media/` + ending in `.gif`, **query string allowed and preserved verbatim**, no fragment. (The path + is not required to contain `giphy_id`: modern Giphy media URLs use opaque `v1.…` path + tokens instead of the plain id. The host allowlist is the security boundary.) +- `giphy_id`: `[A-Za-z0-9]+`, max 64. +- `giphy_width`/`giphy_height`: positive integers, required with `giphy_id`. - `giphy_page_url`: `https`, host `giphy.com` or `www.giphy.com`. - `giphy_title` max 300, `giphy_username` max 100, both nullable. - The `giphy_*` fields arrive as a set — partial submissions are rejected. @@ -83,7 +88,7 @@ All in `resources/js`, following the existing `commit-image-picker.tsx` patterns toolbar buttons, `data-test` attributes, Radix/shadcn `ui/*` primitives): - **`lib/giphy.ts`** — pure, node-testable: the `GiphyAttachment` type - (`{id, url, page_url, title, username}`), direct fetches to `api.giphy.com` + (`{id, url, page_url, title, username, width, height}`), direct fetches to `api.giphy.com` (`/v1/gifs/search`, `/v1/gifs/trending`, always `rating=g`), and `attachmentFromResult(item)` which picks the `.gif` rendition verbatim. - **`components/giphy-picker-dialog.tsx`** — Radix Dialog: debounced (~300 ms) search field, @@ -127,7 +132,7 @@ as `CommitImages`: ## Server-side touchpoints (no Giphy calls, ever) -- **Public API:** `CommitResource` gains `gif` (`{id, url, page_url, title, username} | null`), +- **Public API:** `CommitResource` gains `gif` (`{id, url, page_url, title, username, width, height} | null`), mirroring how `images` are surfaced. Consumers load media directly from Giphy. - **OG metadata:** `PageMetadata::commitImages()` emits `giphy_url` as the OG image for GIF commits. Crawlers fetch Giphy directly — the permitted client-side pattern. From ebf77cf0d34b8d794463796d16db27f5d855ce2d Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 22:46:53 -0400 Subject: [PATCH 03/83] feat: store an attached giphy gif on commits --- app/Models/Commit.php | 45 ++++++++++++++++++- database/factories/CommitFactory.php | 13 ++++++ ...0_17_024509_add_giphy_to_commits_table.php | 23 ++++++++++ tests/Unit/Models/CommitTest.php | 28 ++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 17c53ac77..8e2c5c92c 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -35,6 +35,13 @@ * @property-read CommitStatus|null $ai_decision * @property-read list|null $ai_categories * @property-read list|null $embedding + * @property-read string|null $giphy_id + * @property-read string|null $giphy_url + * @property-read string|null $giphy_page_url + * @property-read string|null $giphy_title + * @property-read string|null $giphy_username + * @property-read int|null $giphy_width + * @property-read int|null $giphy_height * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read CarbonInterface|null $deleted_at @@ -58,6 +65,13 @@ 'ai_decision', 'ai_categories', 'embedding', + 'giphy_id', + 'giphy_url', + 'giphy_page_url', + 'giphy_title', + 'giphy_username', + 'giphy_width', + 'giphy_height', ])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model @@ -107,6 +121,13 @@ public function casts(): array 'ai_decision' => CommitStatus::class, 'ai_categories' => 'array', 'embedding' => AsVector::class, + 'giphy_id' => 'string', + 'giphy_url' => 'string', + 'giphy_page_url' => 'string', + 'giphy_title' => 'string', + 'giphy_username' => 'string', + 'giphy_width' => 'integer', + 'giphy_height' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -127,7 +148,7 @@ public function toArray(): array return array_intersect_key($this->attributesToArray(), array_flip($keys)); } - return [...parent::toArray(), ...resolve(CommitBodies::class)->segments($this->body)]; + return [...parent::toArray(), 'gif' => $this->gif(), ...resolve(CommitBodies::class)->segments($this->body)]; } /** @@ -252,6 +273,28 @@ public function imagesAreVisibleTo(?User $user): bool return $user instanceof User && ($this->user_id === $user->id || $user->isAdmin()); } + /** + * The attached GIPHY GIF, hotlinked by its media URL exactly as GIPHY's API returned it. + * + * @return array{id: string, url: string, page_url: string, title: string|null, username: string|null, width: int|null, height: int|null}|null + */ + public function gif(): ?array + { + if ($this->giphy_id === null || $this->giphy_url === null || $this->giphy_page_url === null) { + return null; + } + + return [ + 'id' => $this->giphy_id, + 'url' => $this->giphy_url, + 'page_url' => $this->giphy_page_url, + 'title' => $this->giphy_title, + 'username' => $this->giphy_username, + 'width' => $this->giphy_width, + 'height' => $this->giphy_height, + ]; + } + /** * Loads what anyone may see around the commit: its author, its approved replies, and its parent only when approved. */ diff --git a/database/factories/CommitFactory.php b/database/factories/CommitFactory.php index da49b426f..390800926 100644 --- a/database/factories/CommitFactory.php +++ b/database/factories/CommitFactory.php @@ -56,4 +56,17 @@ public function rejected(): self 'status' => CommitStatus::Rejected, ]); } + + public function withGiphy(): self + { + return $this->state(fn (array $attributes): array => [ + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_title' => 'Deal With It GIF', + 'giphy_username' => 'giphy', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + } } diff --git a/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php b/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php new file mode 100644 index 000000000..48598ea47 --- /dev/null +++ b/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php @@ -0,0 +1,23 @@ +string('giphy_id', 64)->nullable(); + $table->text('giphy_url')->nullable(); + $table->text('giphy_page_url')->nullable(); + $table->string('giphy_title', 300)->nullable(); + $table->string('giphy_username', 100)->nullable(); + $table->unsignedSmallInteger('giphy_width')->nullable(); + $table->unsignedSmallInteger('giphy_height')->nullable(); + }); + } +}; diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index d86b3d99e..6322c0c52 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -28,6 +28,7 @@ 'root_id', 'hot_score', 'pasteis_count', + 'gif', 'body_segments', 'preview_segments', ]); @@ -403,3 +404,30 @@ expect($tombstone->toArray())->not->toHaveKey('images'); }); + +it('serializes an attached gif as one object and hides the raw columns', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + $payload = $commit->toArray(); + + expect($payload['gif'])->toBe([ + 'id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ])->and(array_keys($payload))->each->not->toStartWith('giphy_'); +}); + +it('serializes the gif of a plain commit as null', function (): void { + expect(Commit::factory()->create()->toArray()['gif'])->toBeNull(); +}); + +it('leaves the gif out of a tombstone', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + $commit->delete(); + + expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); +}); From 4bdf44cfb738fe50c10c5be11b6fe3a54de31938 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 22:52:27 -0400 Subject: [PATCH 04/83] feat: accept a giphy gif on new commits and quotes --- app/Actions/CreateCommit.php | 6 +- app/Dto/Giphy.php | 39 ++++++ app/Http/Controllers/CommitController.php | 2 +- app/Http/Requests/CreateCommitRequest.php | 26 ++++ app/Rules/GiphyMediaUrl.php | 34 ++++++ .../Controllers/CommitControllerTest.php | 113 ++++++++++++++++++ 6 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 app/Dto/Giphy.php create mode 100644 app/Rules/GiphyMediaUrl.php diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 327387e87..7e712ce4c 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -4,6 +4,7 @@ namespace App\Actions; +use App\Dto\Giphy; use App\Dto\NewPoll; use App\Enums\CommitStatus; use App\Jobs\EmbedCommit; @@ -40,7 +41,7 @@ public function __construct( * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?Giphy $giphy = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -48,7 +49,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $giphy): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); @@ -61,6 +62,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'quoted_commit_id' => $quotedCommit?->id, 'hot_score' => $this->theAlgorithm->score($createdAt, 0, 0), 'created_at' => $createdAt, + ...$giphy?->toColumns() ?? [], ]); $this->createCommitImages->handle($commit, $images, $imageDescriptions); diff --git a/app/Dto/Giphy.php b/app/Dto/Giphy.php new file mode 100644 index 000000000..002d6b8d2 --- /dev/null +++ b/app/Dto/Giphy.php @@ -0,0 +1,39 @@ + + */ + public function toColumns(): array + { + return [ + 'giphy_id' => $this->id, + 'giphy_url' => $this->url, + 'giphy_page_url' => $this->pageUrl, + 'giphy_title' => $this->title, + 'giphy_username' => $this->username, + 'giphy_width' => $this->width, + 'giphy_height' => $this->height, + ]; + } +} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index fcac4cefa..4061a0072 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -49,7 +49,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): RedirectResponse { - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll()); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), giphy: $request->giphy()); Inertia::flash('committed', [ 'id' => $commit->id, diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 6b3658900..d999a4927 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -5,10 +5,12 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; +use App\Dto\Giphy; use App\Dto\NewPoll; use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; +use App\Rules\GiphyMediaUrl; use App\Rules\VisibleCommit; use App\Services\CommitBodies; use App\Services\Urls; @@ -69,6 +71,13 @@ public function rules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], + 'giphy_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images,poll', 'required_with:giphy_url'], + 'giphy_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', new GiphyMediaUrl], + 'giphy_page_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', 'url:https', 'regex:#^https://(www\.)?giphy\.com/#'], + 'giphy_title' => ['nullable', 'string', 'max:300'], + 'giphy_username' => ['nullable', 'string', 'max:100'], + 'giphy_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], + 'giphy_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], ]; } @@ -147,6 +156,23 @@ public function quotedCommit(): ?Commit : null; } + public function giphy(): ?Giphy + { + if (! $this->filled('giphy_id')) { + return null; + } + + return new Giphy( + $this->string('giphy_id')->value(), + $this->string('giphy_url')->value(), + $this->string('giphy_page_url')->value(), + $this->filled('giphy_title') ? $this->string('giphy_title')->value() : null, + $this->filled('giphy_username') ? $this->string('giphy_username')->value() : null, + $this->integer('giphy_width'), + $this->integer('giphy_height'), + ); + } + public function prepareForValidation(): void { self::normalizeBody($this); diff --git a/app/Rules/GiphyMediaUrl.php b/app/Rules/GiphyMediaUrl.php new file mode 100644 index 000000000..30e01df86 --- /dev/null +++ b/app/Rules/GiphyMediaUrl.php @@ -0,0 +1,34 @@ +has('replies.data.0.images', 1) ->missing('commit.images.0.path')); }); + +it('may attach a giphy gif to a commit', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Shipping it', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_title' => 'Deal With It GIF', + 'giphy_username' => 'giphy', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + $commit = $user->commits()->sole(); + expect($commit->giphy_id)->toBe('xT9IgzoKnwFNmISR8I') + ->and($commit->giphy_url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') + ->and($commit->giphy_width)->toBe(480); +}); + +it('rejects a gif media url that is not on giphy', function (string $url): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Nice try', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => $url, + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_url'); + + expect($user->commits()->count())->toBe(0); +})->with([ + 'another host' => ['https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'lookalike host' => ['https://media2.giphy.com.evil.example/media/x/giphy.gif'], + 'plain http' => ['http://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'not a gif rendition' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.webp'], + 'outside the media path' => ['https://media2.giphy.com/avatars/giphy.gif'], + 'with a fragment' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif#frag'], + 'with userinfo' => ['https://user@media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'with a port' => ['https://media2.giphy.com:8443/media/xT9IgzoKnwFNmISR8I/giphy.gif'], +]); + +it('rejects a partial gif submission', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Half a gif', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['giphy_url', 'giphy_page_url', 'giphy_width', 'giphy_height']); +}); + +it('rejects a gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('rejects a gif alongside a poll', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Gif or poll', + 'poll' => ['options' => ['Yes', 'No'], 'duration' => 1440], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('may quote a commit with a gif attached', function (): void { + $user = User::factory()->create(); + $quoted = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Quoting with style', + 'quoted_commit_id' => $quoted->id, + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->quoted_commit_id)->toBe($quoted->id); +}); + +it('shows the gif in the commit payload', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + $this->actingAs($commit->user) + ->get(route('commit.show', [$commit->user, $commit])) + ->assertInertia(fn ($page) => $page + ->where('commit.gif.id', 'xT9IgzoKnwFNmISR8I') + ->where('commit.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->missing('commit.giphy_url')); +}); From 11051f03d7a6243fca9f06b82f86496ad12dfc7f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 22:58:10 -0400 Subject: [PATCH 05/83] fix: reject dot-segment traversal in giphy media urls --- app/Rules/GiphyMediaUrl.php | 6 ++++-- tests/Feature/Controllers/CommitControllerTest.php | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Rules/GiphyMediaUrl.php b/app/Rules/GiphyMediaUrl.php index 30e01df86..fa6ed2bf3 100644 --- a/app/Rules/GiphyMediaUrl.php +++ b/app/Rules/GiphyMediaUrl.php @@ -10,7 +10,8 @@ /** * GIPHY media URLs are stored and rendered verbatim, so only https URLs on GIPHY's media hosts pointing at a .gif * rendition are accepted. Query strings stay, as GIPHY's terms forbid stripping them; the path is not required to - * carry the GIF id, since modern media URLs use opaque path tokens instead. + * carry the GIF id, since modern media URLs use opaque path tokens instead. Dot-segments (`.` and `..`) are rejected + * so the `/media/` boundary still holds after browsers normalize the URL. */ final readonly class GiphyMediaUrl implements ValidationRule { @@ -25,7 +26,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void && ! isset($parts['port']) && ! isset($parts['user']) && ! isset($parts['pass']) - && preg_match('#^/media/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1; + && preg_match('#^/media/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 + && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1; if (! $isValid) { $fail(__('The GIF could not be attached.')); diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index c0cf82fd5..ed60b3bab 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1282,6 +1282,8 @@ 'with a fragment' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif#frag'], 'with userinfo' => ['https://user@media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], 'with a port' => ['https://media2.giphy.com:8443/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'dot-segment traversal' => ['https://media2.giphy.com/media/../avatars/evil.gif'], + 'single-dot segment' => ['https://media2.giphy.com/media/./xT9IgzoKnwFNmISR8I/giphy.gif'], ]); it('rejects a partial gif submission', function (): void { From 9d05d4d4003a240b7a5df3bb9343f2a69d2ab680 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:06:44 -0400 Subject: [PATCH 06/83] feat: accept a giphy gif on replies --- .../Controllers/CommitReplyController.php | 2 +- .../Requests/CreateCommitReplyRequest.php | 26 ++++++++++ .../Controllers/CommitReplyControllerTest.php | 49 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index 35232f49b..14864f305 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -25,7 +25,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action): JsonResponse { - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview()); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), giphy: $request->giphy()); return response()->json([ 'committed' => [ diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index 57f4faf85..599faa062 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -5,8 +5,10 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; +use App\Dto\Giphy; use App\Models\Commit; use App\Models\User; +use App\Rules\GiphyMediaUrl; use App\Services\Urls; use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\ValidationRule; @@ -56,6 +58,13 @@ public function rules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], + 'giphy_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images', 'required_with:giphy_url'], + 'giphy_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', new GiphyMediaUrl], + 'giphy_page_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', 'url:https', 'regex:#^https://(www\.)?giphy\.com/#'], + 'giphy_title' => ['nullable', 'string', 'max:300'], + 'giphy_username' => ['nullable', 'string', 'max:100'], + 'giphy_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], + 'giphy_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], ]; } @@ -111,4 +120,21 @@ public function imageDescriptions(): array return $descriptions; } + + public function giphy(): ?Giphy + { + if (! $this->filled('giphy_id')) { + return null; + } + + return new Giphy( + $this->string('giphy_id')->value(), + $this->string('giphy_url')->value(), + $this->string('giphy_page_url')->value(), + $this->filled('giphy_title') ? $this->string('giphy_title')->value() : null, + $this->filled('giphy_username') ? $this->string('giphy_username')->value() : null, + $this->integer('giphy_width'), + $this->integer('giphy_height'), + ); + } } diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 41296a2df..fa1c39a14 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -832,3 +832,52 @@ $second->assertOk()->assertJsonCount(3, 'data')->assertJsonPath('next_cursor', null); expect($second->json('data.*.id'))->toBe(array_slice($expected, 1)); }); + +it('may attach a giphy gif to a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Replying with a gif', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->giphy_id)->toBe('xT9IgzoKnwFNmISR8I'); +}); + +it('rejects a reply gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_id'); +}); + +it('rejects a bad gif media url on a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Nice try', + 'giphy_id' => 'xT9IgzoKnwFNmISR8I', + 'giphy_url' => 'https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'giphy_width' => 480, + 'giphy_height' => 270, + ])->assertSessionHasErrors('giphy_url'); +}); From c98cc7e0baa755682a1c2c253bb11b4fd790a5c5 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:09:45 -0400 Subject: [PATCH 07/83] feat: share the giphy key with the composer --- .env.example | 5 ++++ app/Http/Middleware/HandleInertiaRequests.php | 1 + config/services.php | 4 ++++ resources/js/types/global.d.ts | 1 + .../HandleInertiaRequestsGiphyTest.php | 23 +++++++++++++++++++ 5 files changed, 34 insertions(+) create mode 100644 tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php diff --git a/.env.example b/.env.example index 6dc78ff55..70969585e 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,11 @@ GITHUB_REDIRECT_URI="${APP_URL}/auth/github/callback" OPENAI_API_KEY= +# GIF picker (GIPHY). The key is shipped to the browser, as GIPHY requires client-side API calls, +# and the picker is hidden without one. Beta keys are heavily rate limited; production use needs +# an approved key with "Powered By GIPHY" attribution (https://developers.giphy.com). +GIPHY_KEY= + # Players for links to YouTube, Vimeo, Loom, Twitch, Suno, Spotify, SoundCloud, Apple Music, CodePen and asciinema. # None of them need an API key. EMBEDS_PROVIDERS limits embeds to a comma-separated list of keys from # config/embeds.php (e.g. "youtube,suno,spotify"); leave it empty for all of them. diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f614da279..3e332fcb9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -45,6 +45,7 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), + 'giphy' => filled(config('services.giphy.key')) ? ['key' => config()->string('services.giphy.key')] : null, 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), 'isAdmin' => $isAdmin, diff --git a/config/services.php b/config/services.php index 92086b6b4..4dfbb43b3 100644 --- a/config/services.php +++ b/config/services.php @@ -26,6 +26,10 @@ 'key' => env('OPENAI_API_KEY'), ], + 'giphy' => [ + 'key' => env('GIPHY_KEY'), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index f5c3b5362..524c3a7e1 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -17,6 +17,7 @@ declare module '@inertiajs/core' { hasUnmoderatedCommits: boolean; sidebarOpen: boolean; showsWhoToFollow: boolean; + giphy: { key: string } | null; suggestedUsers?: SuggestedUser[]; [key: string]: unknown; }; diff --git a/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php b/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php new file mode 100644 index 000000000..4de23d966 --- /dev/null +++ b/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php @@ -0,0 +1,23 @@ + 'giphy-test']); + + $this->actingAs(User::factory()->create()) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('giphy.key', 'giphy-test')); +}); + +it('shares no giphy config without a key', function (): void { + config(['services.giphy.key' => null]); + + $this->actingAs(User::factory()->create()) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); +}); From d4a4b86f47207fbb9285afb222eddb282284078c Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:13:44 -0400 Subject: [PATCH 08/83] feat: moderate attached gifs by their giphy url --- app/Actions/UpdateCommitAiDecision.php | 6 +++++- app/Services/OpenAI.php | 16 ++++++++-------- tests/Unit/Jobs/ModerateCommitTest.php | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 68c584f37..987bc9ddc 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -25,7 +25,7 @@ public function __construct( } /** - * Asks the AI whether the commit, images included, should be approved. The decision is mostly a hint for + * Asks the AI whether the commit, images and GIF included, should be approved. The decision is mostly a hint for * moderators: a clean commit is approved when its author already has an approved commit, and it is rejected, * without a reason, when the AI is all but certain. Failures, such as timeouts or rate limits, are thrown, so the * caller can try again later. @@ -36,6 +36,10 @@ public function handle(Commit $commit): void fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), )->all()); + if ($commit->giphy_url !== null) { + $images[] = $commit->giphy_url; + } + $scores = $this->openAI->moderate($commit->body, $images); $categories = array_keys($scores); diff --git a/app/Services/OpenAI.php b/app/Services/OpenAI.php index d2547e5a0..0577a4195 100644 --- a/app/Services/OpenAI.php +++ b/app/Services/OpenAI.php @@ -156,15 +156,15 @@ public function decideRelevance(string $query, array $bodies): array * scoring above our own, stricter thresholds. The text goes along with the first image, and every other image is * checked on its own, as a single request is only documented to take one image. * - * @param list $imageDataUrls + * @param list $imageUrls data URLs or remote https URLs * @return array */ - public function moderate(string $text, array $imageDataUrls = []): array + public function moderate(string $text, array $imageUrls = []): array { $inputs = [[['type' => 'text', 'text' => $text]]]; - foreach ($imageDataUrls as $index => $imageDataUrl) { - $image = ['type' => 'image_url', 'image_url' => ['url' => $imageDataUrl]]; + foreach ($imageUrls as $index => $imageUrl) { + $image = ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]]; if ($index === 0) { $inputs[0][] = $image; @@ -200,13 +200,13 @@ public function moderate(string $text, array $imageDataUrls = []): array * Whether a minor may appear in any of the images. The moderation model can't tell, so a reasoning model is asked * instead. Anything but a clear no, such as a refusal or an answer that can't be read, counts as a yes. * - * @param list $imageDataUrls + * @param list $imageUrls data URLs or remote https URLs */ - public function showsMinors(array $imageDataUrls): bool + public function showsMinors(array $imageUrls): bool { return $this->ask('minors', self::MINORS_INSTRUCTIONS, array_map( - fn (string $imageDataUrl): array => ['type' => 'input_image', 'image_url' => $imageDataUrl], - $imageDataUrls, + fn (string $imageUrl): array => ['type' => 'input_image', 'image_url' => $imageUrl], + $imageUrls, )); } diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index c2384f2c0..4ba476b3f 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -364,3 +364,21 @@ function reviewResponses(array $responses = []): Closure expect(fn () => dispatch_sync(new ModerateCommit($commit)))->toThrow(RequestException::class) ->and($commit->refresh()->ai_decision)->toBeNull(); }); + +it('moderates an attached gif by its giphy url', function (): void { + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->withGiphy()->create(); + + dispatch_sync(new ModerateCommit($commit)); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') + && str_contains($request->body(), 'media2.giphy.com')); + + // The minors check runs because the commit has an image URL (the gif): + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'responses') + && str_contains($request->body(), 'media2.giphy.com')); +}); From 27811731647d2678712289e6556eae2843123478 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:19:09 -0400 Subject: [PATCH 09/83] feat: expose attached gifs in the public api --- app/Http/Resources/CommitResource.php | 1 + .../Controllers/Api/CommitControllerTest.php | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index de7df67ab..3cc3ab3c5 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -29,6 +29,7 @@ public function toArray(Request $request): array 'replies_count' => $this->replies_count, 'link_preview' => $this->linkPreview, 'images' => $this->images, + 'gif' => $this->gif(), 'author' => UserResource::make($this->user), ]; } diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index bfc2093ba..516c46ef5 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -33,6 +33,7 @@ 'replies_count' => 0, 'link_preview' => null, 'images' => [], + 'gif' => null, 'author' => [ 'id' => $user->id, 'username' => 'octocat', @@ -189,3 +190,21 @@ $this->getJson(route('api-commit.show', $commit), ['REMOTE_ADDR' => '10.0.0.2'])->assertOk(); }); + +it('includes the gif of a commit', function (): void { + $commit = Commit::factory()->withGiphy()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif.id', 'xT9IgzoKnwFNmISR8I') + ->assertJsonPath('data.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->assertJsonPath('data.gif.width', 480); +}); + +it('includes a null gif for a plain commit', function (): void { + $commit = Commit::factory()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif', null); +}); From 9f2f13215d7db221f8f080cb9d61a6bb60eca476 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:23:53 -0400 Subject: [PATCH 10/83] feat: use the attached gif as the commit og image --- app/Services/PageMetadata.php | 10 ++++++++ .../Controllers/CommitControllerTest.php | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 451bb059a..ea873c233 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -256,6 +256,16 @@ private function commitImages(Commit $commit, User $user): array ]; } + if ($commit->giphy_url !== null) { + $images[] = [ + 'url' => $commit->giphy_url, + 'width' => (int) $commit->giphy_width, + 'height' => (int) $commit->giphy_height, + 'type' => 'image/gif', + 'alt' => $commit->giphy_title ?? "GIF attached to the commit of {$user->nameWithHandle()}", + ]; + } + return $images; } diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index ed60b3bab..82f27ff0f 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -828,6 +828,31 @@ ->and(mb_strpos($content, $second->url()))->toBeLessThan(mb_strpos($content, route('commit-open-graph-image.show', [$user, $commit]))); }); +it('uses the attached gif as an open graph image', function (): void { + $user = User::factory()->create(['username' => 'octocat', 'name' => 'The Octocat']); + $commit = Commit::factory()->for($user)->withGiphy()->create(['body' => 'Deal with it']); + + $content = (string) $this->get(route('commit.show', [$user, $commit]))->assertOk()->getContent(); + + expect($content) + ->toContain(' + + + + ') + ->toContain('') + ->toContain('"image":["https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123","'.route('commit-open-graph-image.show', [$user, $commit]).'?v=') + ->and(mb_strpos($content, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'))->toBeLessThan(mb_strpos($content, route('commit-open-graph-image.show', [$user, $commit]))); +}); + +it('does not use a gif as an open graph image without an attached gif', function (): void { + $commit = Commit::factory()->create(); + + $content = (string) $this->get(route('commit.show', [$commit->user, $commit]))->assertOk()->getContent(); + + expect($content)->not->toContain('image/gif'); +}); + it('previews the drawn card of a commit without images', function (): void { $commit = Commit::factory()->create(); From 106d03f128c67b7595097c4466072817764ba2d5 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:27:57 -0400 Subject: [PATCH 11/83] feat: include attached gifs as atom enclosures --- app/Actions/CreateUserFeed.php | 4 ++++ .../Feature/Controllers/UserFeedControllerTest.php | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index 51a6aae7b..d34404362 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -70,6 +70,10 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'enclosure', $image->url(), 'image/webp'); } + if ($commit->giphy_url !== null) { + $this->link($xml, 'enclosure', $commit->giphy_url, 'image/gif'); + } + $xml->writeElement('published', $commit->created_at->toAtomString()); $xml->writeElement('updated', $commit->created_at->toAtomString()); diff --git a/tests/Feature/Controllers/UserFeedControllerTest.php b/tests/Feature/Controllers/UserFeedControllerTest.php index c01b57490..b08cd5182 100644 --- a/tests/Feature/Controllers/UserFeedControllerTest.php +++ b/tests/Feature/Controllers/UserFeedControllerTest.php @@ -187,3 +187,16 @@ function atomFeed(TestResponse $response): SimpleXMLElement it('returns not found for unknown usernames', function (): void { $this->get('/@missing/feed.atom')->assertNotFound(); }); + +it('lists an attached gif as an enclosure', function (): void { + $user = User::factory()->create(); + Commit::factory()->for($user)->withGiphy()->create(); + + $response = $this->get(route('user-feed.show', $user)); + + $response->assertOk(); + expect($response->getContent()) + ->toContain('rel="enclosure"') + ->toContain('type="image/gif"') + ->toContain('https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'); +}); From 96d7a7e53e1ce50bbac3c653d8d988f8a3051c8e Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Fri, 16 Oct 2026 23:30:37 -0400 Subject: [PATCH 12/83] feat: add the client-side giphy api module --- resources/js/lib/giphy.ts | 131 ++++++++++++++++++++++++++++++++++++++ tests/Unit/Giphy.test.ts | 105 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 resources/js/lib/giphy.ts create mode 100644 tests/Unit/Giphy.test.ts diff --git a/resources/js/lib/giphy.ts b/resources/js/lib/giphy.ts new file mode 100644 index 000000000..896b2d9e2 --- /dev/null +++ b/resources/js/lib/giphy.ts @@ -0,0 +1,131 @@ +export type GiphyAttachment = { + id: string; + url: string; + page_url: string; + title: string | null; + username: string | null; + width: number; + height: number; +}; + +type GiphyRendition = { url: string; width: string; height: string }; + +export type GiphyResult = { + id: string; + title: string; + username: string; + url: string; + images: Partial< + Record< + 'downsized_medium' | 'downsized' | 'original' | 'fixed_height_small', + GiphyRendition + > + >; +}; + +export class GiphyRateLimited extends Error { + constructor() { + super('GIPHY is rate limiting searches.'); + } +} + +export const pageSize = 24; + +const baseUrl = 'https://api.giphy.com/v1/gifs'; + +const params = (key: string, offset: number): URLSearchParams => + new URLSearchParams({ + api_key: key, + limit: String(pageSize), + offset: String(offset), + rating: 'g', + }); + +export function giphySearchUrl(key: string, query: string, offset = 0): string { + const search = params(key, offset); + search.set('q', query); + + return `${baseUrl}/search?${search}`; +} + +export function giphyTrendingUrl(key: string, offset = 0): string { + return `${baseUrl}/trending?${params(key, offset)}`; +} + +/** + * GIPHY requires client-side API calls, so this runs in the browser with the shared key. + */ +export async function fetchGifs( + url: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(url, { signal }); + + if (response.status === 429) { + throw new GiphyRateLimited(); + } + + if (!response.ok) { + throw new Error(`GIPHY request failed: ${response.status}`); + } + + const data = (await response.json()) as { data: GiphyResult[] }; + + return data.data; +} + +/** + * The small fixed-height rendition keeps the picker grid light; bigger renditions stand in when it is missing. + */ +export function previewUrlFor(result: GiphyResult): string | null { + const rendition = + result.images.fixed_height_small ?? + result.images.downsized ?? + result.images.downsized_medium ?? + result.images.original; + + return rendition?.url ?? null; +} + +/** + * The stored rendition is always a .gif around feed size, and its URL is kept verbatim as GIPHY's terms require. + */ +export function attachmentFromResult( + result: GiphyResult, +): GiphyAttachment | null { + const rendition = + result.images.downsized_medium ?? + result.images.downsized ?? + result.images.original; + + if (rendition === undefined) { + return null; + } + + return { + id: result.id, + url: rendition.url, + page_url: result.url, + title: result.title === '' ? null : result.title, + username: result.username === '' ? null : result.username, + width: Number.parseInt(rendition.width, 10), + height: Number.parseInt(rendition.height, 10), + }; +} + +/** + * The hidden fields every composer submits for an attached GIF, named exactly as the server validates them. + */ +export function giphyFormFields( + gif: GiphyAttachment, +): Record { + return { + giphy_id: gif.id, + giphy_url: gif.url, + giphy_page_url: gif.page_url, + giphy_title: gif.title ?? '', + giphy_username: gif.username ?? '', + giphy_width: gif.width, + giphy_height: gif.height, + }; +} diff --git a/tests/Unit/Giphy.test.ts b/tests/Unit/Giphy.test.ts new file mode 100644 index 000000000..062644529 --- /dev/null +++ b/tests/Unit/Giphy.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + attachmentFromResult, + giphyFormFields, + giphySearchUrl, + giphyTrendingUrl, + previewUrlFor, +} from '../../resources/js/lib/giphy.ts'; +import type { GiphyResult } from '../../resources/js/lib/giphy.ts'; + +const result: GiphyResult = { + id: 'xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + images: { + fixed_height_small: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', + width: '178', + height: '100', + }, + downsized_medium: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + width: '480', + height: '270', + }, + downsized: { + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + width: '400', + height: '225', + }, + }, +}; + +await test('builds a search url with the g rating always on', () => { + const url = new URL(giphySearchUrl('key-123', 'deal with it', 24)); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); + assert.equal(url.searchParams.get('api_key'), 'key-123'); + assert.equal(url.searchParams.get('q'), 'deal with it'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '24'); +}); + +await test('builds a trending url with the g rating always on', () => { + const url = new URL(giphyTrendingUrl('key-123')); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/trending'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '0'); +}); + +await test('picks the downsized_medium gif rendition verbatim', () => { + const gif = attachmentFromResult(result); + assert.deepEqual(gif, { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, + }); +}); + +await test('falls back through downsized to original and bails without any', () => { + const { downsized_medium: _dropped, ...rest } = result.images; + const fallback = attachmentFromResult({ ...result, images: rest }); + assert.equal( + fallback?.url, + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + ); + assert.equal(attachmentFromResult({ ...result, images: {} }), null); +}); + +await test('turns empty title and username into null', () => { + const gif = attachmentFromResult({ ...result, title: '', username: '' }); + assert.equal(gif?.title, null); + assert.equal(gif?.username, null); +}); + +await test('uses the small fixed-height rendition for previews', () => { + assert.equal( + previewUrlFor(result), + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', + ); + assert.equal( + previewUrlFor({ ...result, images: { downsized: result.images.downsized } }), + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', + ); + assert.equal(previewUrlFor({ ...result, images: {} }), null); +}); + +await test('shapes the form fields the server expects', () => { + const gif = attachmentFromResult(result); + assert.ok(gif); + assert.deepEqual(giphyFormFields(gif), { + giphy_id: 'xT9IgzoKnwFNmISR8I', + giphy_url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + giphy_page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + giphy_title: 'Deal With It GIF', + giphy_username: 'giphy', + giphy_width: 480, + giphy_height: 270, + }); +}); From ee40cff55fb07fa84533d4184877268d699b58ce Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 07:11:04 -0400 Subject: [PATCH 13/83] feat: render attached gifs on commit cards --- resources/js/components/commit-card.tsx | 4 + resources/js/components/commit-giphy.tsx | 54 ++++++++++++++ resources/js/types/commit.ts | 11 +++ tests/Unit/CommitGiphy.test.tsx | 93 ++++++++++++++++++++++++ tests/Unit/CommitGiphyRender.test.ts | 18 +++++ tests/commit-giphy.config.ts | 13 ++++ tests/tsconfig.json | 4 +- 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/commit-giphy.tsx create mode 100644 tests/Unit/CommitGiphy.test.tsx create mode 100644 tests/Unit/CommitGiphyRender.test.ts create mode 100644 tests/commit-giphy.config.ts diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index dfd34d679..6e282cba7 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -5,6 +5,7 @@ import { useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import { BookmarkButton } from '@/components/bookmark-button'; import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitGiphy } from '@/components/commit-giphy'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; @@ -305,6 +306,9 @@ export function CommitCard({ )} /> {showImages && } + {showImages && commit.gif && ( + + )} {commit.poll && ( + {gif.title { + const image = event.currentTarget; + if (image.getAttribute('src') !== '/favicon.svg') { + image.src = '/favicon.svg'; + image.classList.replace( + 'object-cover', + 'object-contain', + ); + } + }} + className="max-h-[32rem] w-full bg-muted object-cover" + /> + event.stopPropagation()} + className="absolute right-1.5 bottom-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[11px] font-medium text-foreground backdrop-blur transition hover:bg-background" + > + {gif.username + ? `GIF by @${gif.username} on GIPHY` + : 'via GIPHY'} + + + ); +} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index a695c5c7c..796b6aa24 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -45,6 +45,16 @@ export type CommitImage = { description: string | null; }; +export type CommitGif = { + id: string; + url: string; + page_url: string; + title: string | null; + username: string | null; + width: number | null; + height: number | null; +}; + export type BodySegment = | { type: 'text'; text: string } | { type: 'code'; code: string } @@ -77,6 +87,7 @@ export type Commit = { quoted_commit_id: string | null; quoted_commit?: QuotedCommit | DeletedCommit | null; images?: CommitImage[]; + gif?: CommitGif | null; ai_decision?: Exclude | null; ai_categories?: string[] | null; }; diff --git a/tests/Unit/CommitGiphy.test.tsx b/tests/Unit/CommitGiphy.test.tsx new file mode 100644 index 000000000..6a0814bc8 --- /dev/null +++ b/tests/Unit/CommitGiphy.test.tsx @@ -0,0 +1,93 @@ +/// +import assert from 'node:assert/strict'; +import type { ReactElement, SyntheticEvent } from 'react'; +import { test } from 'vite-plus/test'; +import { CommitGiphy } from '../../resources/js/components/commit-giphy'; +import type { CommitGif } from '../../resources/js/types/commit'; + +const gif: CommitGif = { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, +}; + +function renderedGiphy(overrides: Partial = {}) { + const figure = CommitGiphy({ gif: { ...gif, ...overrides } }); + const [image, attribution] = figure.props.children as [ + ReactElement<{ + src: string; + alt: string; + loading: string; + referrerPolicy: string; + onError: (event: SyntheticEvent) => void; + }>, + ReactElement<{ href: string; target: string; rel: string; children: string }>, + ]; + + return { figure, image, attribution }; +} + +test('hotlinks the giphy url verbatim with its query string intact', () => { + const { image } = renderedGiphy(); + + assert.equal( + image.props.src, + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + ); + assert.equal(image.props.alt, 'Deal With It GIF'); + assert.equal(image.props.loading, 'lazy'); + assert.equal(image.props.referrerPolicy, 'no-referrer'); +}); + +test('falls back to a generic alt when giphy has no title', () => { + const { image } = renderedGiphy({ title: null }); + + assert.equal(image.props.alt, 'Attached GIF'); +}); + +test('credits the giphy uploader with a link to the gif page', () => { + const { attribution } = renderedGiphy(); + + assert.equal(attribution.props.href, gif.page_url); + assert.equal(attribution.props.target, '_blank'); + assert.equal(attribution.props.rel, 'noopener noreferrer'); + assert.equal(attribution.props.children, 'GIF by @giphy on GIPHY'); +}); + +test('falls back to the plain giphy credit when the uploader is unknown', () => { + const { attribution } = renderedGiphy({ username: null }); + + assert.equal(attribution.props.children, 'via GIPHY'); +}); + +test('replaces a failed gif with the site logo without retrying the fallback', () => { + let replacements = 0; + let source = gif.url; + const target = { + getAttribute: () => source, + set src(value: string) { + source = value; + replacements++; + }, + classList: { + replace: (from: string, to: string) => { + assert.equal(from, 'object-cover'); + assert.equal(to, 'object-contain'); + }, + }, + }; + const event = { + currentTarget: target, + } as unknown as SyntheticEvent; + const { onError } = renderedGiphy().image.props; + + onError(event); + assert.equal(source, '/favicon.svg'); + + onError(event); + assert.equal(replacements, 1); +}); diff --git a/tests/Unit/CommitGiphyRender.test.ts b/tests/Unit/CommitGiphyRender.test.ts new file mode 100644 index 000000000..26c7d350a --- /dev/null +++ b/tests/Unit/CommitGiphyRender.test.ts @@ -0,0 +1,18 @@ +import { execFileSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +await test('renders attached gifs with giphy attribution on commit cards', () => { + const cli = fileURLToPath( + new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), + ); + + execFileSync( + process.execPath, + [cli, 'test', 'run', '--config', 'tests/commit-giphy.config.ts'], + { + cwd: fileURLToPath(new URL('../..', import.meta.url)), + stdio: 'inherit', + }, + ); +}); diff --git a/tests/commit-giphy.config.ts b/tests/commit-giphy.config.ts new file mode 100644 index 000000000..1e8801dd6 --- /dev/null +++ b/tests/commit-giphy.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('../resources/js', import.meta.url)), + }, + }, + test: { + include: ['tests/Unit/CommitGiphy.test.tsx'], + }, +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 17221dd09..eaf32428e 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -7,6 +7,8 @@ "include": [ "Unit/**/*.test.ts", "Unit/DraftStash.test.tsx", - "draft-images.config.ts" + "Unit/CommitGiphy.test.tsx", + "draft-images.config.ts", + "commit-giphy.config.ts" ] } From 92a6696906a332bf7b2a61a3d86bc9ca3b211879 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 07:12:57 -0400 Subject: [PATCH 14/83] style: format giphy client module --- resources/js/lib/giphy.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/js/lib/giphy.ts b/resources/js/lib/giphy.ts index 896b2d9e2..a21df3c23 100644 --- a/resources/js/lib/giphy.ts +++ b/resources/js/lib/giphy.ts @@ -17,7 +17,10 @@ export type GiphyResult = { url: string; images: Partial< Record< - 'downsized_medium' | 'downsized' | 'original' | 'fixed_height_small', + | 'downsized_medium' + | 'downsized' + | 'original' + | 'fixed_height_small', GiphyRendition > >; From f9687a265ad867749bc1220a6ae6cb75ca7e095a Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 07:21:13 -0400 Subject: [PATCH 15/83] feat: stash a drafted gif across reloads --- resources/js/hooks/use-stash.ts | 47 ++++++++++++++++++++++++++++++--- resources/js/lib/gif-storage.ts | 31 ++++++++++++++++++++++ tests/Unit/GifStorage.test.ts | 43 ++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 resources/js/lib/gif-storage.ts create mode 100644 tests/Unit/GifStorage.test.ts diff --git a/resources/js/hooks/use-stash.ts b/resources/js/hooks/use-stash.ts index 37a11b429..90dcc2307 100644 --- a/resources/js/hooks/use-stash.ts +++ b/resources/js/hooks/use-stash.ts @@ -5,6 +5,8 @@ import { pickImages, revokeObjectUrls, } from '@/components/commit-image-picker'; +import { readStoredGif, writeStoredGif } from '@/lib/gif-storage'; +import type { GiphyAttachment } from '@/lib/giphy'; import type { StoredImages } from '@/lib/image-storage'; import { deleteStoredImages, @@ -18,15 +20,18 @@ export type UseStashReturn = { readonly body: string; readonly images: File[]; readonly descriptions: string[]; + readonly gif: GiphyAttachment | null; readonly isRestored: boolean; readonly updateBody: (body: string) => void; readonly updateImages: (images: File[], descriptions?: string[]) => void; + readonly updateGif: (gif: GiphyAttachment | null) => void; readonly drop: () => void; }; const listeners = new Set<() => void>(); const pending = new Map(); const images = new Map(); +const gifs = new Map(); const restoredImageKeys = new Set(); const noImages: StoredImages = { images: [], descriptions: [] }; let timeout: ReturnType | undefined; @@ -43,6 +48,20 @@ const read = (key: string): string => const imagesFor = (key: string): StoredImages => images.get(key) ?? noImages; +const gifFor = (key: string): GiphyAttachment | null => { + if (!gifs.has(key)) { + gifs.set(key, readStoredGif(key)); + } + + return gifs.get(key) ?? null; +}; + +const storeGif = (key: string, gif: GiphyAttachment | null): void => { + gifs.set(key, gif); + writeStoredGif(key, gif); + notify(); +}; + const useDraftImages = (key: string) => { const draftImages = useSyncExternalStore( subscribe, @@ -184,6 +203,10 @@ export function clearStashes(userId: string): void { restoredImageKeys.delete(key); }); + [...gifs.keys()] + .filter((key) => key.startsWith(prefix)) + .forEach((key) => gifs.delete(key)); + deleteStoredImages(prefix); notify(); } @@ -198,7 +221,9 @@ export function useHasStash(userId: string | null, name: string): boolean { subscribe, () => key !== null && - (read(key) !== '' || imagesFor(key).images.length > 0), + (read(key) !== '' || + imagesFor(key).images.length > 0 || + gifFor(key) !== null), () => false, ); } @@ -207,8 +232,13 @@ export function useStash(userId: string, name: string): UseStashReturn { const key = keyFor(userId, name); const [body, setBody] = useState(() => read(key)); const { draftImages, descriptions, isImagesRestored } = useDraftImages(key); + const gif = useSyncExternalStore( + subscribe, + () => gifFor(key), + () => null, + ); const [isDraftRestored, setIsDraftRestored] = useState( - () => body !== '' || draftImages.length > 0, + () => body !== '' || draftImages.length > 0 || gifFor(key) !== null, ); useEffect(() => { @@ -245,22 +275,33 @@ export function useStash(userId: string, name: string): UseStashReturn { storeImages(key, next, descriptions); }; + const updateGif = (next: GiphyAttachment | null): void => { + if (next === null && body === '' && draftImages.length === 0) { + setIsDraftRestored(false); + } + + storeGif(key, next); + }; + const drop = (): void => { setBody(''); setIsDraftRestored(false); discardBody(key); storeImages(key, []); + storeGif(key, null); }; return { body, images: draftImages, descriptions, + gif, isRestored: (isDraftRestored || isImagesRestored) && - (body !== '' || draftImages.length > 0), + (body !== '' || draftImages.length > 0 || gif !== null), updateBody, updateImages, + updateGif, drop, } as const; } diff --git a/resources/js/lib/gif-storage.ts b/resources/js/lib/gif-storage.ts new file mode 100644 index 000000000..8ed192aab --- /dev/null +++ b/resources/js/lib/gif-storage.ts @@ -0,0 +1,31 @@ +import type { GiphyAttachment } from './giphy.ts'; +import { readStorage, writeStorage } from './storage.ts'; + +const gifKey = (key: string): string => `${key}:gif`; + +/** + * A stashed GIF is tiny JSON in localStorage, unlike stashed images, which are Files in IndexedDB. + */ +export function readStoredGif(key: string): GiphyAttachment | null { + const raw = readStorage(gifKey(key)); + + if (raw === '') { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + + return typeof parsed.id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.page_url === 'string' + ? (parsed as GiphyAttachment) + : null; + } catch { + return null; + } +} + +export function writeStoredGif(key: string, gif: GiphyAttachment | null): void { + writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); +} diff --git a/tests/Unit/GifStorage.test.ts b/tests/Unit/GifStorage.test.ts new file mode 100644 index 000000000..0bfd1a5fd --- /dev/null +++ b/tests/Unit/GifStorage.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const store = new Map(); +(globalThis as Record).window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }, +}; + +const { readStoredGif, writeStoredGif } = await import( + '../../resources/js/lib/gif-storage.ts' +); + +const gif = { + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, +}; + +await test('round-trips a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + assert.deepEqual(readStoredGif('stash:u1:commit'), gif); +}); + +await test('clears a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + writeStoredGif('stash:u1:commit', null); + assert.equal(readStoredGif('stash:u1:commit'), null); +}); + +await test('ignores unreadable stashed values', () => { + store.set('stash:u1:reply-1:gif', 'not json'); + assert.equal(readStoredGif('stash:u1:reply-1'), null); + store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); + assert.equal(readStoredGif('stash:u1:reply-2'), null); +}); diff --git a/tsconfig.json b/tsconfig.json index e6397376c..df4c4d645 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,7 @@ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + "allowImportingTsExtensions": true /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */, // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ From 1a15f58637eae5c3bdb7ea22f43f91b47ba42cd8 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 07:26:55 -0400 Subject: [PATCH 16/83] feat: add the giphy picker dialog and button --- .../components/giphy-attachment-preview.tsx | 41 ++++ .../js/components/giphy-picker-button.tsx | 53 +++++ .../js/components/giphy-picker-dialog.tsx | 209 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 resources/js/components/giphy-attachment-preview.tsx create mode 100644 resources/js/components/giphy-picker-button.tsx create mode 100644 resources/js/components/giphy-picker-dialog.tsx diff --git a/resources/js/components/giphy-attachment-preview.tsx b/resources/js/components/giphy-attachment-preview.tsx new file mode 100644 index 000000000..f1155ecb1 --- /dev/null +++ b/resources/js/components/giphy-attachment-preview.tsx @@ -0,0 +1,41 @@ +import { X } from 'lucide-react'; +import type { GiphyAttachment } from '@/lib/giphy'; + +export function GiphyAttachmentPreview({ + gif, + onRemove, +}: { + gif: GiphyAttachment | null; + onRemove: () => void; +}) { + if (gif === null) { + return null; + } + + return ( +
+
+ {gif.title + + + GIF + +
+
+ ); +} diff --git a/resources/js/components/giphy-picker-button.tsx b/resources/js/components/giphy-picker-button.tsx new file mode 100644 index 000000000..bf77a0481 --- /dev/null +++ b/resources/js/components/giphy-picker-button.tsx @@ -0,0 +1,53 @@ +import { usePage } from '@inertiajs/react'; +import { ImagePlay } from 'lucide-react'; +import { useState } from 'react'; +import { GiphyPickerDialog } from '@/components/giphy-picker-dialog'; +import { Button } from '@/components/ui/button'; +import type { GiphyAttachment } from '@/lib/giphy'; +import { cn } from '@/lib/utils'; + +/** + * Invisible without a configured GIPHY key, so self-hosters without one never see the feature. + */ +export function GiphyPickerButton({ + disabled, + hasGif, + onPick, + className, +}: { + disabled?: boolean; + hasGif: boolean; + onPick: (gif: GiphyAttachment) => void; + className?: string; +}) { + const { giphy } = usePage().props; + const [open, setOpen] = useState(false); + + if (giphy === null) { + return null; + } + + return ( + <> + + + + ); +} diff --git a/resources/js/components/giphy-picker-dialog.tsx b/resources/js/components/giphy-picker-dialog.tsx new file mode 100644 index 000000000..b89b7e10e --- /dev/null +++ b/resources/js/components/giphy-picker-dialog.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import type { GiphyResult } from '@/lib/giphy'; +import { + attachmentFromResult, + fetchGifs, + giphySearchUrl, + giphyTrendingUrl, + GiphyRateLimited, + pageSize, + previewUrlFor, +} from '@/lib/giphy'; +import type { GiphyAttachment } from '@/lib/giphy'; + +type Status = 'loading' | 'ready' | 'error' | 'rate-limited'; + +export function GiphyPickerDialog({ + apiKey, + open, + onOpenChange, + onPick, +}: { + apiKey: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onPick: (gif: GiphyAttachment) => void; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [status, setStatus] = useState('loading'); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [reloads, setReloads] = useState(0); + + const urlFor = (offset: number) => + query.trim() === '' + ? giphyTrendingUrl(apiKey, offset) + : giphySearchUrl(apiKey, query.trim(), offset); + + useEffect(() => { + if (!open) { + return; + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => { + setStatus('loading'); + fetchGifs(urlFor(0), controller.signal) + .then((gifs) => { + setResults(gifs); + setStatus('ready'); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + + setStatus( + error instanceof GiphyRateLimited + ? 'rate-limited' + : 'error', + ); + }); + }, + query === '' ? 0 : 300, + ); + + return () => { + clearTimeout(timeout); + controller.abort(); + }; + }, [open, query, apiKey, reloads]); + + const loadMore = () => { + setIsLoadingMore(true); + fetchGifs(urlFor(results.length)) + .then((gifs) => setResults((current) => [...current, ...gifs])) + .catch(() => undefined) + .finally(() => setIsLoadingMore(false)); + }; + + const pick = (result: GiphyResult) => { + const gif = attachmentFromResult(result); + + if (gif !== null) { + onPick(gif); + onOpenChange(false); + setQuery(''); + } + }; + + return ( + + + + Attach a GIF + + Search GIPHY and pick a GIF to attach. + + + setQuery(event.target.value)} + placeholder="grep giphy…" + aria-label="Search GIPHY" + data-test="giphy-search-input" + /> + {status === 'loading' && ( +

+ Loading… +

+ )} + {status === 'rate-limited' && ( +

+ GIPHY is rate limiting searches. Try again shortly. +

+ )} + {status === 'error' && ( +
+

+ GIPHY did not answer. +

+ +
+ )} + {status === 'ready' && results.length === 0 && ( +

+ No GIFs matched. /dev/null remains empty. +

+ )} + {status === 'ready' && results.length > 0 && ( + <> +
    + {results.map((result) => { + const preview = previewUrlFor(result); + + return preview === null ? null : ( +
  • + +
  • + ); + })} +
+ {results.length % pageSize === 0 && ( + + )} + + )} + +
+
+ ); +} From 3db009cd31be75b57d6bb56b1d25cd06887792b0 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 07:36:04 -0400 Subject: [PATCH 17/83] feat: attach giphy gifs from every composer --- resources/js/components/commit-composer.tsx | 57 +++++++++++++++---- .../js/components/commit-image-picker.tsx | 3 +- .../js/components/commit-quote-button.tsx | 39 ++++++++++--- resources/js/components/commit-thread.tsx | 37 +++++++++++- tests/Unit/ComposerDraft.test.tsx | 2 + tests/Unit/QuoteDraft.test.tsx | 2 + 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 4d5577ac8..650d0fcdb 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; import { BodyCounter } from '@/components/body-counter'; +import { CommitGiphy } from '@/components/commit-giphy'; import { ImagePickerButton, ImagePickerPreviews, @@ -16,6 +17,8 @@ import { useComposerPreview, } from '@/components/composer-preview'; import { EmojiSuggestionHint } from '@/components/emoji-suggestion'; +import { GiphyAttachmentPreview } from '@/components/giphy-attachment-preview'; +import { GiphyPickerButton } from '@/components/giphy-picker-button'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; @@ -39,6 +42,8 @@ import { import { useUser } from '@/hooks/use-user'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; import { completeEmoji, suggestEmoji } from '@/lib/emoji'; +import { giphyFormFields } from '@/lib/giphy'; +import type { GiphyAttachment } from '@/lib/giphy'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, cn, laneColor } from '@/lib/utils'; import type { User } from '@/types'; @@ -109,6 +114,11 @@ function CommitComposerBody({ const error = errors.body ?? errors.link_preview_url; const textareaRef = useRef(null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); + const pickGif = (gif: GiphyAttachment) => { + attachments.clear(); + setPoll(null); + stash.updateGif(gif); + }; return (
@@ -175,6 +185,10 @@ function CommitComposerBody({ )}
+ {preview.isPreviewing && stash.gif !== null && ( + + )} + {!preview.isPreviewing && ( )} + {!preview.isPreviewing && ( + stash.updateGif(null)} + /> + )} { + stash.updateGif(null); + attachments.attach(files); + }} className="-ml-2" /> )} + {!isScheduling && ( + + )} + + + ) : ( +
+ +
+ setKey(event.target.value)} + placeholder="Paste your GIPHY API key" + autoComplete="off" + data-test="giphy-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} + + ); +} +``` + +- [ ] **Step 7: Wire the page** + +In `resources/js/pages/user-profile/edit.tsx`: + +1. Import: `import { GiphyKeySetting } from '@/components/giphy-key-setting';` (alphabetically among component imports). +2. Add `giphyConnected` to the destructured props and the inline type: + +```tsx +export default function Edit({ + preferredLane, + automaticLane, + githubSyncedAt, + giphyConnected, + activityGraphUrl, + profileUrl, +}: { + preferredLane: number | null; + automaticLane: number; + githubSyncedAt: string | null; + giphyConnected: boolean; + activityGraphUrl: string; + profileUrl: string; +}) { +``` + +3. Render the section right after ``: + +```tsx + +``` + +- [ ] **Step 8: Register the test files for type checking** + +In `tests/tsconfig.json`, extend `include` with: + +```json + "Unit/GiphyKeySetting.test.tsx", + "giphy-key.config.ts" +``` + +- [ ] **Step 9: Run everything** + +Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php` +Expected: PASS. + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. If lint/format complains, run `bun run lint` and include the fixes. + +- [ ] **Step 10: Commit** + +```bash +git add resources/js/components/giphy-key-setting.tsx resources/js/pages/user-profile/edit.tsx app/Http/Controllers/UserProfileController.php tests/Feature/Controllers/UserProfileControllerTest.php tests/Unit/GiphyKeySetting.test.tsx tests/giphy-key.config.ts tests/Unit/GiphyKeySettingRender.test.ts tests/tsconfig.json +git commit -m "feat: add the giphy key section to profile settings" +``` + +--- + +### Task 6: Full gates + +**Files:** +- Modify: only if gates surface fixes. + +**Interfaces:** +- Consumes: everything above. +- Produces: a green branch ready for review. + +- [ ] **Step 1: Run the full PHP gate** + +Run: `composer test` +Expected: PASS — Pint, PHPStan max, 100% line/type coverage, full Pest suite. If Pint flags formatting, run `composer lint` and commit the fixes with the task. + +- [ ] **Step 2: Run the full JS gates** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS — lib tests, component tests, both tsconfigs, fmt + lint. + +- [ ] **Step 3: Sanity-check the feature end to end (manual, optional)** + +Run `bun run dev` (or use the worktree's built assets), sign in, open Settings → Profile → Giphy, paste any junk string: the browser-side check must show "That key was rejected by GIPHY." without saving. With a real key the toast "GIPHY key connected." appears and the composer's GIF button turns visible. + +- [ ] **Step 4: Commit any residual fixes** + +```bash +git add -A +git commit -m "chore: satisfy the gates for the byok giphy key" +``` + +(Only if Steps 1–2 produced changes; otherwise skip.) From 5ee0a250e17ecb31d5091dc2dc5918060444f85f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 09:35:35 -0400 Subject: [PATCH 23/83] feat: store a per-user giphy key --- app/Models/User.php | 3 +++ database/factories/UserFactory.php | 7 +++++++ ...17_133426_add_giphy_key_to_users_table.php | 17 +++++++++++++++ tests/Unit/Models/UserTest.php | 21 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php diff --git a/app/Models/User.php b/app/Models/User.php index 5264635d8..5a2e7e82e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -40,6 +40,7 @@ * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at * @property-read string|null $remember_token + * @property-read string|null $giphy_key * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -62,6 +63,7 @@ 'github_id', 'email', 'remember_token', + 'giphy_key', 'preferred_lane', 'pasteis_mode', 'pasteis_received', @@ -120,6 +122,7 @@ public function casts(): array 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', 'remember_token' => 'string', + 'giphy_key' => 'encrypted', 'github_synced_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 64062ed91..69a8693ed 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -57,4 +57,11 @@ public function withoutProfile(): self 'company' => null, ]); } + + public function withGiphyKey(string $key = 'giphy-test-key'): self + { + return $this->state(fn (array $attributes): array => [ + 'giphy_key' => $key, + ]); + } } diff --git a/database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php b/database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php new file mode 100644 index 000000000..4e9143272 --- /dev/null +++ b/database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php @@ -0,0 +1,17 @@ +text('giphy_key')->nullable(); + }); + } +}; diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 279797756..616b43142 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -223,3 +223,24 @@ ->and($user->blocking->modelKeys())->toBe([$other->id]) ->and($other->blockers->modelKeys())->toBe([$user->id]); }); + +it('stores the giphy key encrypted and reads it back', function (): void { + $user = User::factory()->withGiphyKey('giphy-test-key')->create(); + + expect($user->refresh()->giphy_key)->toBe('giphy-test-key') + ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) + ->toBeString() + ->not->toBe('giphy-test-key') + ->not->toContain('giphy-test-key'); +}); + +it('defaults the giphy key to null', function (): void { + expect(User::factory()->create()->refresh()->giphy_key)->toBeNull(); +}); + +it('serializes no giphy key', function (): void { + $user = User::factory()->withGiphyKey()->create(); + + expect($user->toArray())->not->toHaveKey('giphy_key') + ->and($user->jsonSerialize())->not->toHaveKey('giphy_key'); +}); From 82a7ce3b89fd8775033acf027b33da5c5444b6c4 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 11:35:28 -0400 Subject: [PATCH 24/83] feat: share the giphy key from the user account --- .env.example | 5 ----- README.md | 2 +- app/Http/Middleware/HandleInertiaRequests.php | 4 ++-- app/Models/User.php | 1 + config/services.php | 4 ---- .../Middleware/HandleInertiaRequestsGiphyTest.php | 15 +++++---------- 6 files changed, 9 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 70969585e..6dc78ff55 100644 --- a/.env.example +++ b/.env.example @@ -70,11 +70,6 @@ GITHUB_REDIRECT_URI="${APP_URL}/auth/github/callback" OPENAI_API_KEY= -# GIF picker (GIPHY). The key is shipped to the browser, as GIPHY requires client-side API calls, -# and the picker is hidden without one. Beta keys are heavily rate limited; production use needs -# an approved key with "Powered By GIPHY" attribution (https://developers.giphy.com). -GIPHY_KEY= - # Players for links to YouTube, Vimeo, Loom, Twitch, Suno, Spotify, SoundCloud, Apple Music, CodePen and asciinema. # None of them need an API key. EMBEDS_PROVIDERS limits embeds to a comma-separated list of keys from # config/embeds.php (e.g. "youtube,suno,spotify"); leave it empty for all of them. diff --git a/README.md b/README.md index 63cdeb591..34f6768c9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -With a [GIPHY API key](https://developers.giphy.com) in `GIPHY_KEY`, every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. The browser talks to GIPHY directly (its terms require client-side calls, so the key ships to the client) and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. Without a key the picker is hidden. Beta keys allow about 42 searches an hour; production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. +GIF search is bring-your-own-key: connect your own free [GIPHY API key](https://developers.giphy.com) under Settings → Profile → Giphy and every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. Searches run on the connected key and its quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to GIPHY directly and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. Production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. ### Verify installation diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3156bb81c..b59731928 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -45,8 +45,8 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), - 'giphy' => fn (): ?array => (function (): ?array { - $key = config('services.giphy.key'); + 'giphy' => fn (): ?array => (function () use ($request): ?array { + $key = $request->user()?->giphy_key; return is_string($key) && $key !== '' ? ['key' => $key] : null; })(), diff --git a/app/Models/User.php b/app/Models/User.php index 5a2e7e82e..872c533bb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -93,6 +93,7 @@ final class User extends Authenticatable 'pasteis_received' => 0, 'pasteis_given_today' => 0, 'pasteis_given_on' => null, + 'giphy_key' => null, ]; /** diff --git a/config/services.php b/config/services.php index 4dfbb43b3..92086b6b4 100644 --- a/config/services.php +++ b/config/services.php @@ -26,10 +26,6 @@ 'key' => env('OPENAI_API_KEY'), ], - 'giphy' => [ - 'key' => env('GIPHY_KEY'), - ], - 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php b/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php index a51abc9ad..b45319be6 100644 --- a/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php +++ b/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php @@ -6,26 +6,21 @@ use App\Models\User; use Inertia\Testing\AssertableInertia as Assert; -it('shares the giphy key when configured', function (): void { - config(['services.giphy.key' => 'giphy-test']); +it('shares the giphy key of the signed-in user', function (): void { + $user = User::factory()->withGiphyKey('giphy-test')->create(); - $this->actingAs(User::factory()->create()) + $this->actingAs($user) ->get(route('log.show', Log::Following)) ->assertInertia(fn (Assert $page): Assert => $page->where('giphy.key', 'giphy-test')); }); it('shares no giphy config without a key', function (): void { - config(['services.giphy.key' => null]); - $this->actingAs(User::factory()->create()) ->get(route('log.show', Log::Following)) ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); }); -it('shares no giphy config for a non-string key', function (): void { - config(['services.giphy.key' => true]); - - $this->actingAs(User::factory()->create()) - ->get(route('log.show', Log::Following)) +it('shares no giphy config for guests', function (): void { + $this->get(route('log.show', Log::Trending)) ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); }); From 9cbd0c4a02d85e4e3a69786c6c899fe1d453908e Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 11:49:25 -0400 Subject: [PATCH 25/83] feat: connect and remove the giphy key --- app/Http/Controllers/GiphyKeyController.php | 38 ++++++++++ app/Http/Requests/UpdateGiphyKeyRequest.php | 34 +++++++++ routes/web.php | 5 ++ .../Controllers/ExportControllerTest.php | 12 ++++ .../Controllers/GiphyKeyControllerTest.php | 69 +++++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 app/Http/Controllers/GiphyKeyController.php create mode 100644 app/Http/Requests/UpdateGiphyKeyRequest.php create mode 100644 tests/Feature/Controllers/GiphyKeyControllerTest.php diff --git a/app/Http/Controllers/GiphyKeyController.php b/app/Http/Controllers/GiphyKeyController.php new file mode 100644 index 000000000..01ba611ba --- /dev/null +++ b/app/Http/Controllers/GiphyKeyController.php @@ -0,0 +1,38 @@ +update(['giphy_key' => $request->string('key')->toString()]); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'GIPHY key connected.', + ]); + + return back(); + } + + public function destroy(#[CurrentUser] User $user): RedirectResponse + { + $user->update(['giphy_key' => null]); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'GIPHY key removed.', + ]); + + return back(); + } +} diff --git a/app/Http/Requests/UpdateGiphyKeyRequest.php b/app/Http/Requests/UpdateGiphyKeyRequest.php new file mode 100644 index 000000000..78eb66482 --- /dev/null +++ b/app/Http/Requests/UpdateGiphyKeyRequest.php @@ -0,0 +1,34 @@ +input('key'); + + if (is_string($key)) { + $this->merge(['key' => trim($key)]); + } + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'key' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/routes/web.php b/routes/web.php index 83b61e860..43f69201a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,6 +25,7 @@ use App\Http\Controllers\ExportController; use App\Http\Controllers\FailedJobController; use App\Http\Controllers\FailedJobRetryController; +use App\Http\Controllers\GiphyKeyController; use App\Http\Controllers\LogController; use App\Http\Controllers\MentionSuggestionController; use App\Http\Controllers\ModerationController; @@ -255,6 +256,10 @@ Route::put('settings/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('user-pasteis-mode.update'); + // Giphy key... + Route::put('settings/giphy', [GiphyKeyController::class, 'update'])->name('giphy-key.update'); + Route::delete('settings/giphy', [GiphyKeyController::class, 'destroy'])->name('giphy-key.destroy'); + // Appearance... Route::get('settings/appearance', fn () => Inertia::render('appearance/update'))->name('appearance.edit'); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 6cb1492f9..ea1de4763 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -391,3 +391,15 @@ $this->actingAs($user)->get(route('export'))->assertOk(); }); + +it('never exports the giphy key', function (): void { + $user = User::factory()->withGiphyKey('giphy-test-key')->create(); + + $response = $this->actingAs($user)->get(route('export')); + + $response->assertOk(); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['profile'])->not->toHaveKey('giphy_key') + ->and($response->streamedContent())->not->toContain('giphy-test-key'); +}); diff --git a/tests/Feature/Controllers/GiphyKeyControllerTest.php b/tests/Feature/Controllers/GiphyKeyControllerTest.php new file mode 100644 index 000000000..94402923b --- /dev/null +++ b/tests/Feature/Controllers/GiphyKeyControllerTest.php @@ -0,0 +1,69 @@ +create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('giphy-key.update'), ['key' => ' giphy-test-key ']) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => 'GIPHY key connected.', + ]); + + expect($user->refresh()->giphy_key)->toBe('giphy-test-key') + ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) + ->not->toContain('giphy-test-key'); +}); + +it('replaces an existing key', function (): void { + $user = User::factory()->withGiphyKey('first-key')->create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('giphy-key.update'), ['key' => 'second-key']) + ->assertRedirectToRoute('user-profile.edit'); + + expect($user->refresh()->giphy_key)->toBe('second-key'); +}); + +it('removes the key', function (): void { + $user = User::factory()->withGiphyKey()->create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->delete(route('giphy-key.destroy')) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => 'GIPHY key removed.', + ]); + + expect($user->refresh()->giphy_key)->toBeNull(); +}); + +it('rejects invalid keys without touching the stored one', function (array $payload): void { + $user = User::factory()->withGiphyKey('stored-key')->create(); + + $this->actingAs($user)->putJson(route('giphy-key.update'), $payload) + ->assertUnprocessable()->assertJsonValidationErrors('key'); + + expect($user->refresh()->giphy_key)->toBe('stored-key'); +})->with([ + 'missing' => [fn (): array => []], + 'empty' => [fn (): array => ['key' => '']], + 'whitespace' => [fn (): array => ['key' => ' ']], + 'not a string' => [fn (): array => ['key' => ['nested']]], + 'too long' => [fn (): array => ['key' => str_repeat('a', 256)]], +]); + +it('requires authentication to connect a key', function (): void { + $this->putJson(route('giphy-key.update'), ['key' => 'giphy-test-key'])->assertUnauthorized(); +}); + +it('requires authentication to remove a key', function (): void { + $this->deleteJson(route('giphy-key.destroy'))->assertUnauthorized(); +}); From f239a13aeba42e18bca26c6e0628d37445a71881 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 12:23:15 -0400 Subject: [PATCH 26/83] feat: verify the giphy key in the browser --- .../js/components/giphy-picker-button.tsx | 2 +- .../js/components/giphy-picker-dialog.tsx | 3 +- resources/js/lib/giphy.ts | 34 ++++++++- tests/Unit/Giphy.test.ts | 75 +++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/resources/js/components/giphy-picker-button.tsx b/resources/js/components/giphy-picker-button.tsx index bf77a0481..ba1e997c6 100644 --- a/resources/js/components/giphy-picker-button.tsx +++ b/resources/js/components/giphy-picker-button.tsx @@ -7,7 +7,7 @@ import type { GiphyAttachment } from '@/lib/giphy'; import { cn } from '@/lib/utils'; /** - * Invisible without a configured GIPHY key, so self-hosters without one never see the feature. + * Invisible until the signed-in user connects their own GIPHY key in settings. */ export function GiphyPickerButton({ disabled, diff --git a/resources/js/components/giphy-picker-dialog.tsx b/resources/js/components/giphy-picker-dialog.tsx index b89b7e10e..94ff0242f 100644 --- a/resources/js/components/giphy-picker-dialog.tsx +++ b/resources/js/components/giphy-picker-dialog.tsx @@ -121,7 +121,8 @@ export function GiphyPickerDialog({ )} {status === 'rate-limited' && (

- GIPHY is rate limiting searches. Try again shortly. + You’ve hit your GIPHY key’s search rate limit. Try again + shortly.

)} {status === 'error' && ( diff --git a/resources/js/lib/giphy.ts b/resources/js/lib/giphy.ts index a21df3c23..91fa516a3 100644 --- a/resources/js/lib/giphy.ts +++ b/resources/js/lib/giphy.ts @@ -28,7 +28,7 @@ export type GiphyResult = { export class GiphyRateLimited extends Error { constructor() { - super('GIPHY is rate limiting searches.'); + super('Your GIPHY key is rate limiting searches.'); } } @@ -77,6 +77,38 @@ export async function fetchGifs( return data.data; } +export type GiphyKeyValidation = + | 'valid' + | 'invalid' + | 'rate-limited' + | 'unreachable'; + +/** + * Verifies a pasted key against GIPHY from the browser, so no key ever + * reaches the server for validation. A throttled key is still a real key. + */ +export async function validateGiphyKey( + key: string, +): Promise { + let response: Response; + + try { + response = await fetch(giphyTrendingUrl(key)); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + /** * The small fixed-height rendition keeps the picker grid light; bigger renditions stand in when it is missing. */ diff --git a/tests/Unit/Giphy.test.ts b/tests/Unit/Giphy.test.ts index 062644529..c4b0e0743 100644 --- a/tests/Unit/Giphy.test.ts +++ b/tests/Unit/Giphy.test.ts @@ -6,6 +6,7 @@ import { giphySearchUrl, giphyTrendingUrl, previewUrlFor, + validateGiphyKey, } from '../../resources/js/lib/giphy.ts'; import type { GiphyResult } from '../../resources/js/lib/giphy.ts'; @@ -103,3 +104,77 @@ await test('shapes the form fields the server expects', () => { giphy_height: 270, }); }); + +const originalFetch = globalThis.fetch; + +function stubFetch(result: number | 'network'): string[] { + const calls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + + if (result === 'network') { + throw new TypeError('fetch failed'); + } + + return new Response('{}', { status: result }); + }) as typeof fetch; + + return calls; +} + +function restoreFetch(): void { + globalThis.fetch = originalFetch; +} + +await test('validates a key against the trending endpoint', async () => { + const calls = stubFetch(200); + + try { + assert.equal(await validateGiphyKey('key-123'), 'valid'); + assert.ok(calls[0]?.includes('api_key=key-123')); + assert.ok(calls[0]?.includes('rating=g')); + } finally { + restoreFetch(); + } +}); + +await test('treats a refused key as invalid', async () => { + for (const status of [401, 403]) { + stubFetch(status); + + try { + assert.equal(await validateGiphyKey('key-123'), 'invalid'); + } finally { + restoreFetch(); + } + } +}); + +await test('treats a throttled key as rate limited but real', async () => { + stubFetch(429); + + try { + assert.equal(await validateGiphyKey('key-123'), 'rate-limited'); + } finally { + restoreFetch(); + } +}); + +await test('treats network failures and unexpected statuses as unreachable', async () => { + stubFetch('network'); + + try { + assert.equal(await validateGiphyKey('key-123'), 'unreachable'); + } finally { + restoreFetch(); + } + + stubFetch(500); + + try { + assert.equal(await validateGiphyKey('key-123'), 'unreachable'); + } finally { + restoreFetch(); + } +}); From 2ecb76e9fb7a7f1d201dc033bc92e804fef4d2ea Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 12:30:12 -0400 Subject: [PATCH 27/83] fix: declare prepareForValidation publicly per the arch rules --- app/Http/Requests/UpdateGiphyKeyRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Requests/UpdateGiphyKeyRequest.php b/app/Http/Requests/UpdateGiphyKeyRequest.php index 78eb66482..d6efa000b 100644 --- a/app/Http/Requests/UpdateGiphyKeyRequest.php +++ b/app/Http/Requests/UpdateGiphyKeyRequest.php @@ -13,7 +13,7 @@ public function authorize(): bool return true; } - protected function prepareForValidation(): void + public function prepareForValidation(): void { $key = $this->input('key'); From f26161be01ef721f55a4c02573a418b660377795 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 13:44:51 -0400 Subject: [PATCH 28/83] feat: add the giphy key section to profile settings --- .../Controllers/UserProfileController.php | 1 + resources/js/components/giphy-key-setting.tsx | 168 +++++++++++++++ resources/js/pages/user-profile/edit.tsx | 5 + .../Controllers/UserProfileControllerTest.php | 14 ++ tests/Unit/GiphyKeySetting.test.tsx | 202 ++++++++++++++++++ tests/Unit/GiphyKeySettingRender.test.ts | 18 ++ tests/giphy-key.config.ts | 13 ++ tests/tsconfig.json | 4 +- 8 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/giphy-key-setting.tsx create mode 100644 tests/Unit/GiphyKeySetting.test.tsx create mode 100644 tests/Unit/GiphyKeySettingRender.test.ts create mode 100644 tests/giphy-key.config.ts diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index cfd4b8f09..7939a768c 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -19,6 +19,7 @@ public function edit(#[CurrentUser] User $user): Response 'preferredLane' => $user->preferred_lane, 'automaticLane' => $user->automaticLane(), 'githubSyncedAt' => $user->github_synced_at?->toIso8601String(), + 'giphyConnected' => $user->giphy_key !== null, 'activityGraphUrl' => route('user-activity-graph.show', $user), 'profileUrl' => route('user.show', $user), ]); diff --git a/resources/js/components/giphy-key-setting.tsx b/resources/js/components/giphy-key-setting.tsx new file mode 100644 index 000000000..2a59b24e9 --- /dev/null +++ b/resources/js/components/giphy-key-setting.tsx @@ -0,0 +1,168 @@ +import { router } from '@inertiajs/react'; +import { useState } from 'react'; +import { + destroy, + update, +} from '@/actions/App/Http/Controllers/GiphyKeyController'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { validateGiphyKey } from '@/lib/giphy'; + +export function GiphyKeySetting({ + giphyConnected, +}: { + giphyConnected: boolean; +}) { + const [key, setKey] = useState(''); + const [editing, setEditing] = useState(!giphyConnected); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + + const connect = async () => { + if (processing) { + return; + } + + const candidate = key.trim(); + + if (candidate === '') { + return; + } + + setProcessing(true); + setError(null); + + const verdict = await validateGiphyKey(candidate); + + if (verdict === 'invalid') { + setError('That key was rejected by GIPHY.'); + setProcessing(false); + + return; + } + + if (verdict === 'unreachable') { + setError("Couldn't reach GIPHY to verify the key."); + setProcessing(false); + + return; + } + + router.put( + update.url(), + { key: candidate }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onError: (errors) => + setError(errors.key ?? 'Could not save the key.'), + onSuccess: () => { + setKey(''); + setError(null); + setEditing(false); + }, + }, + ); + }; + + const remove = () => { + if (processing) { + return; + } + + setError(null); + + router.delete(destroy.url(), { + preserveScroll: true, + onStart: () => setProcessing(true), + onFinish: () => setProcessing(false), + onError: () => setError('Could not remove the key.'), + }); + }; + + return ( +
+

+ Giphy +

+

+ GIF search runs on your own GIPHY API key, under your own quota. + Get a free key at{' '} + + developers.giphy.com + + , connect it here, and every composer gains a GIF picker. + Without a key, the picker stays hidden. +

+ {giphyConnected && !editing ? ( +
+

+ GIPHY is connected. +

+ + +
+ ) : ( +
+ +
+ setKey(event.target.value)} + placeholder="Paste your GIPHY API key" + autoComplete="off" + data-test="giphy-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index d2e5d79b8..174d7673f 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -4,6 +4,7 @@ import ExportController from '@/actions/App/Http/Controllers/ExportController'; import UserProfileController from '@/actions/App/Http/Controllers/UserProfileController'; import ActivityGraphEmbed from '@/components/activity-graph-embed'; import DeleteUser from '@/components/delete-user'; +import { GiphyKeySetting } from '@/components/giphy-key-setting'; import { GitHubProfile } from '@/components/github-profile'; import Heading from '@/components/heading'; import LanePicker from '@/components/lane-picker'; @@ -43,12 +44,14 @@ export default function Edit({ preferredLane, automaticLane, githubSyncedAt, + giphyConnected, activityGraphUrl, profileUrl, }: { preferredLane: number | null; automaticLane: number; githubSyncedAt: string | null; + giphyConnected: boolean; activityGraphUrl: string; profileUrl: string; }) { @@ -127,6 +130,8 @@ export default function Edit({ + + withGiphyKey()->create() + : User::factory()->create(); + + $this->actingAs($user) + ->get(route('user-profile.edit')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user-profile/edit') + ->where('giphyConnected', $connected) + ->missing('auth.user.giphy_key')); +})->with([true, false]); diff --git a/tests/Unit/GiphyKeySetting.test.tsx b/tests/Unit/GiphyKeySetting.test.tsx new file mode 100644 index 000000000..5cf4e0886 --- /dev/null +++ b/tests/Unit/GiphyKeySetting.test.tsx @@ -0,0 +1,202 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +const put = vi.fn(); +const destroy = vi.fn(); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + router: { put, delete: destroy }, +})); + +const { GiphyKeySetting } = await import( + '../../resources/js/components/giphy-key-setting' +); + +type Element = ReactElement>; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(giphyConnected: boolean): Element[] { + cursor = 0; + + return elements(GiphyKeySetting({ giphyConnected })); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +function stubFetch(result: number | 'network'): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (result === 'network') { + throw new TypeError('fetch failed'); + } + + return new Response('{}', { status: result }); + }), + ); +} + +function typeKey(fields: Element[], value: string): Element[] { + (field(fields, 'giphy-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ + target: { value }, + }); + + return render(false); +} + +beforeEach(() => { + state = []; + cursor = 0; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +test('saves a valid key after giphy accepts it', async () => { + stubFetch(200); + + let fields = typeKey(render(false), ' key-123 '); + await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 1); + assert.equal(put.mock.calls[0]?.[0], '/settings/giphy'); + assert.deepEqual(put.mock.calls[0]?.[1], { key: 'key-123' }); + + const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; + options.onSuccess(); + + fields = render(true); + assert.ok(field(fields, 'giphy-connected')); +}); + +test('saves a rate-limited key because the key itself is real', async () => { + stubFetch(429); + + const fields = typeKey(render(false), 'key-123'); + await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 1); +}); + +test('rejects a refused key without saving', async () => { + stubFetch(401); + + const fields = typeKey(render(false), 'key-123'); + await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(false), 'giphy-key-error').props.children, + 'That key was rejected by GIPHY.', + ); +}); + +test('does not save when giphy cannot be reached', async () => { + stubFetch('network'); + + const fields = typeKey(render(false), 'key-123'); + await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(false), 'giphy-key-error').props.children, + "Couldn't reach GIPHY to verify the key.", + ); +}); + +test('disables connect until a key is typed', () => { + const fields = render(false); + + assert.equal(field(fields, 'giphy-connect-button').props.disabled, true); +}); + +test('surfaces server validation errors', async () => { + stubFetch(200); + + const fields = typeKey(render(false), 'key-123'); + await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); + + const options = put.mock.calls[0]?.[2] as { + onError: (errors: Record) => void; + }; + options.onError({ key: 'The key field is required.' }); + + assert.equal( + field(render(false), 'giphy-key-error').props.children, + 'The key field is required.', + ); +}); + +test('removes a connected key', () => { + const fields = render(true); + + (field(fields, 'giphy-remove-button').props.onClick as () => void)(); + + assert.equal(destroy.mock.calls.length, 1); + assert.equal(destroy.mock.calls[0]?.[0], '/settings/giphy'); +}); diff --git a/tests/Unit/GiphyKeySettingRender.test.ts b/tests/Unit/GiphyKeySettingRender.test.ts new file mode 100644 index 000000000..d1680947f --- /dev/null +++ b/tests/Unit/GiphyKeySettingRender.test.ts @@ -0,0 +1,18 @@ +import { execFileSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +await test('connects, updates, and removes the giphy key from profile settings', () => { + const cli = fileURLToPath( + new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), + ); + + execFileSync( + process.execPath, + [cli, 'test', 'run', '--config', 'tests/giphy-key.config.ts'], + { + cwd: fileURLToPath(new URL('../..', import.meta.url)), + stdio: 'inherit', + }, + ); +}); diff --git a/tests/giphy-key.config.ts b/tests/giphy-key.config.ts new file mode 100644 index 000000000..a0cd83205 --- /dev/null +++ b/tests/giphy-key.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('../resources/js', import.meta.url)), + }, + }, + test: { + include: ['tests/Unit/GiphyKeySetting.test.tsx'], + }, +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index eaf32428e..803f8a79b 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -8,7 +8,9 @@ "Unit/**/*.test.ts", "Unit/DraftStash.test.tsx", "Unit/CommitGiphy.test.tsx", + "Unit/GiphyKeySetting.test.tsx", "draft-images.config.ts", - "commit-giphy.config.ts" + "commit-giphy.config.ts", + "giphy-key.config.ts" ] } From db38a05272d1b159435c2f189319c78a777efbff Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 13:51:35 -0400 Subject: [PATCH 29/83] chore: satisfy the gates for the byok giphy key --- app/Http/Requests/UpdateGiphyKeyRequest.php | 2 +- tests/Feature/Controllers/ExportControllerTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Http/Requests/UpdateGiphyKeyRequest.php b/app/Http/Requests/UpdateGiphyKeyRequest.php index d6efa000b..bb4533d60 100644 --- a/app/Http/Requests/UpdateGiphyKeyRequest.php +++ b/app/Http/Requests/UpdateGiphyKeyRequest.php @@ -18,7 +18,7 @@ public function prepareForValidation(): void $key = $this->input('key'); if (is_string($key)) { - $this->merge(['key' => trim($key)]); + $this->merge(['key' => mb_trim($key)]); } } diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index ea1de4763..49ddfb20f 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -398,6 +398,7 @@ $response = $this->actingAs($user)->get(route('export')); $response->assertOk(); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data['profile'])->not->toHaveKey('giphy_key') From 8d33ec4ed9115f5d5dd9dbaeda79ee9a03d21285 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 15:13:58 -0400 Subject: [PATCH 30/83] fix: drop the legacy schedule shims and target upstream selectors --- resources/js/components/commit-composer.tsx | 24 --------------------- tests/Unit/ComposerDraft.test.tsx | 6 +++--- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 1aacc51ac..34a74fe16 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -282,13 +282,6 @@ function CommitComposerBody({ errors.scheduled_at ? true : undefined } /> -

- No GIFs matched. /dev/null remains empty. -

- )} - {status === 'ready' && results.length > 0 && ( - <> -
    - {results.map((result) => { - const preview = previewUrlFor(result); - - return preview === null ? null : ( -
  • - -
  • - ); - })} -
- {results.length % pageSize === 0 && ( - - )} - - )} -
- - Powered By GIPHY - -
- - - ); -} -``` - -- [ ] **Step 3: Implement the button** - -`resources/js/components/giphy-picker-button.tsx`: - -```tsx -import { usePage } from '@inertiajs/react'; -import { ImagePlay } from 'lucide-react'; -import { useState } from 'react'; -import { GiphyPickerDialog } from '@/components/giphy-picker-dialog'; -import { Button } from '@/components/ui/button'; -import type { GiphyAttachment } from '@/lib/giphy'; -import { cn } from '@/lib/utils'; - -/** - * Invisible without a configured GIPHY key, so self-hosters without one never see the feature. - */ -export function GiphyPickerButton({ - disabled, - hasGif, - onPick, - className, -}: { - disabled?: boolean; - hasGif: boolean; - onPick: (gif: GiphyAttachment) => void; - className?: string; -}) { - const { giphy } = usePage().props; - const [open, setOpen] = useState(false); - - if (giphy === null) { - return null; - } - - return ( - <> - - - - ); -} -``` - -(If `ImagePlay` is missing from the installed lucide-react, use `Clapperboard` — verify with `rg -n "ImagePlay|Clapperboard" node_modules/lucide-react/dist/lucide-react.d.ts | head -2`.) - -- [ ] **Step 4: Implement the composer preview thumb** - -`resources/js/components/giphy-attachment-preview.tsx`: - -```tsx -import { X } from 'lucide-react'; -import type { GiphyAttachment } from '@/lib/giphy'; - -export function GiphyAttachmentPreview({ - gif, - onRemove, -}: { - gif: GiphyAttachment | null; - onRemove: () => void; -}) { - if (gif === null) { - return null; - } - - return ( -
-
- {gif.title - - - GIF - -
-
- ); -} -``` - -- [ ] **Step 5: Verify types and commit** - -Run: `composer test:types` → PASS. - -```bash -git add resources/js/components/giphy-picker-button.tsx resources/js/components/giphy-picker-dialog.tsx resources/js/components/giphy-attachment-preview.tsx public/images/powered-by-giphy.png -git commit -m "feat: add the giphy picker dialog and button" -``` - ---- - -### Task 13: Wire the three composers - -**Files:** -- Modify: `resources/js/components/commit-composer.tsx`, `resources/js/components/commit-thread.tsx` (ReplyComposer), `resources/js/components/commit-quote-button.tsx` (QuoteComposer) - -**Interfaces:** -- Consumes: `GiphyPickerButton`, `GiphyAttachmentPreview` (Task 12), `giphyFormFields` (Task 9), `useStash().gif/updateGif` (Task 11), `CommitGiphy` (Task 10). -- Produces: all three composers submit the `giphy_*` fields; exclusivity per spec — picking a GIF clears uploads and poll; attaching images or starting a poll clears the GIF. - -- [ ] **Step 1: Wire the main composer** - -In `resources/js/components/commit-composer.tsx`: - -a) Imports: `GiphyPickerButton`, `GiphyAttachmentPreview`, `giphyFormFields` and `type GiphyAttachment`. - -b) In `CommitComposer`, destructure nothing new (keep using `stash.gif`); extend the `transform` chain — the current ternary becomes: - -```tsx - transform={(data) => - isScheduling - ? { - ...data, - scheduled_at: new Date(scheduledAt) - .toISOString() - .replace(/\.\d{3}Z$/, '+00:00'), - } - : stash.gif !== null - ? { ...data, ...giphyFormFields(stash.gif) } - : hasImages - ? { - ...data, - images: attachments.images, - image_descriptions: attachments.descriptions, - } - : data - } -``` - -(`drop()` inside `onSuccess` already clears the stashed GIF after Task 11.) - -c) In `CommitComposerBody` (which receives `stash` and `attachments` as props), define the handlers near the top: - -```tsx - const pickGif = (gif: GiphyAttachment) => { - attachments.clear(); - setPoll(null); - stash.updateGif(gif); - }; -``` - -d) Toolbar: change the `ImagePickerButton`'s `onPick` from `attachments.attach` to: - -```tsx - onPick={(files) => { - stash.updateGif(null); - attachments.attach(files); - }} -``` - -add, right after the `ImagePickerButton` block: - -```tsx - {!isScheduling && ( - - )} -``` - -and extend the poll button's `onClick` with `stash.updateGif(null);` as its first statement, plus its `disabled` with `|| stash.gif !== null` and a matching `title` branch (`'A commit can have a GIF or a poll, not both'`). - -e) Render the preview thumb next to `ImagePickerPreviews` (same conditional context): - -```tsx - stash.updateGif(null)} - /> -``` - -f) Preview mode: find where the composer's preview branch renders draft images (`rg -n "CommitImages|ImagePickerPreviews" resources/js/components/composer-preview.tsx resources/js/components/commit-composer.tsx`). If the preview branch renders a draft ``, render next to it: - -```tsx - {stash.gif !== null && ( - - )} -``` - -(`GiphyAttachment` is structurally assignable to `CommitGif`.) If drafts only show `ImagePickerPreviews` outside the preview, skip this — the thumb from (e) already covers it. - -g) Server error surfacing: in the spot where `imageErrorFrom(errors)` feeds `InputError`, also surface GIF errors — extend `imageErrorFrom` in `resources/js/components/commit-image-picker.tsx` so its key scan adds `|| name.startsWith('giphy_')`. - -- [ ] **Step 2: Wire the reply composer** - -In `resources/js/components/commit-thread.tsx` (`ReplyComposer`): - -a) Destructure `gif, updateGif` from the `useStash(...)` call. Extend the `useHttp` data generic with the optional fields: - -```tsx - giphy_id?: string; - giphy_url?: string; - giphy_page_url?: string; - giphy_title?: string; - giphy_username?: string; - giphy_width?: number; - giphy_height?: number; -``` - -b) In `submit()`, extend the `request.setData(() => ({ ... }))` object: - -```tsx - ...(gif !== null ? giphyFormFields(gif) : {}), -``` - -(and drop `images`/`image_descriptions` from the payload when `gif !== null`: `images: gif === null ? images : [],` and `image_descriptions: gif === null ? descriptions : [],`). - -c) Toolbar: after the `ImagePickerButton` (whose `onPick` becomes the clear-gif wrapper as in Step 1d): - -```tsx - { - attachments.clear(); - updateGif(picked); - }} - className="-ml-2 size-8" - /> -``` - -d) Render ` updateGif(null)} />` next to the reply's `ImagePickerPreviews`, and add `drop()`-adjacent cleanup: the existing `onSuccess` already calls `drop()`, which clears the GIF. - -- [ ] **Step 3: Wire the quote composer** - -In `resources/js/components/commit-quote-button.tsx` (`QuoteComposer`): same pattern — destructure `gif, updateGif` from `useStash`, extend the ``: - -```tsx - transform={(data) => - gif !== null - ? { ...data, ...giphyFormFields(gif) } - : images.length > 0 - ? { - ...data, - images, - image_descriptions: descriptions, - } - : data - } -``` - -add the `GiphyPickerButton` after the `ImagePickerButton` (wrapping `onPick` to clear the GIF, as in Step 1d), and render the `GiphyAttachmentPreview` next to the quote's image previews. - -- [ ] **Step 4: Verify** - -Run: `composer test:types` → PASS. -Run: `bun run test:unit` → PASS (existing composer suites must not break; if `ComposerDraft.test.tsx` or `QuoteDraft.test.tsx` fail because `useStash` now returns extra keys or `usePage().props.giphy` is read, extend their mocks minimally — add `giphy: null` to the mocked page props and `gif: null, updateGif: () => {}` to any stubbed stash objects). -Manual QA (run `composer dev` with `GIPHY_KEY` set and `AUTO_LOGIN=true`): pick a GIF in each composer; confirm picking a GIF removes attached images and vice versa; post and confirm the card renders the GIF with attribution; reload mid-draft and confirm the GIF is restored; confirm the button is absent with `GIPHY_KEY` empty. - -- [ ] **Step 5: Commit** - -```bash -git add resources/js/components/commit-composer.tsx resources/js/components/commit-thread.tsx resources/js/components/commit-quote-button.tsx resources/js/components/commit-image-picker.tsx -git commit -m "feat: attach giphy gifs from every composer" -``` - ---- - -### Task 14: Docs and full verification - -**Files:** -- Modify: `README.md`, `PRODUCT.md` - -- [ ] **Step 1: README** - -After the Embeds section, add: - -```markdown -### GIFs - -With a [GIPHY API key](https://developers.giphy.com) in `GIPHY_KEY`, every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. The browser talks to GIPHY directly (its terms require client-side calls, so the key ships to the client) and commits store only the GIF's URLs; nothing is downloaded or rehosted. Without a key the picker is hidden. Beta keys allow about 42 searches an hour; production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. -``` - -- [ ] **Step 2: PRODUCT.md** - -a) In "Capabilities and Constraints", add a bullet after the link-cards bullet: - -```markdown -- GIFs: with a GIPHY key configured, a commit, reply, or quote can carry one GIF from GIPHY's catalog — never alongside uploaded images or a poll, and never on a scheduled commit. The GIF is hotlinked from GIPHY with creator attribution; /dev/null stores only its URLs, as GIPHY's API terms require. -``` - -b) In the "Deliberately excluded for now" bullet, remove `images/media,` (stale — commits already carry images, and now GIFs). - -- [ ] **Step 3: Full verification** - -Run: `composer test` -Expected: PASS — lint (Rector/Pint/Oxfmt dry-run), PHPStan + tsc, 100% type coverage, Pest at exactly 100.0% coverage, then `bun run test:unit`. If Pint/oxfmt flag formatting, run `composer lint` and re-run `composer test`. - -- [ ] **Step 4: Commit** - -```bash -git add README.md PRODUCT.md -git commit -m "docs: document the giphy gif picker" -``` - ---- - -## Self-Review Notes - -- Spec coverage: data model + validation (Tasks 1–3), key delivery (4), moderation (5), public API (6), OG (7), Atom (8), client library + `rating=g` + renditions (9), rendering + attribution + fallback (10), stash (11), picker + Powered By GIPHY (12), composer wiring + exclusivity + error surfacing (13), docs incl. PRODUCT.md (14). Quote embeds and the photo viewer intentionally untouched (spec: out of scope). -- Type consistency: `Giphy` DTO fields ↔ `giphy()` helper ↔ `toColumns()` ↔ migration columns ↔ `gif()` array ↔ `CommitGif` TS type ↔ `giphyFormFields` keys all use the same seven names. -- Known locate-steps (API/OG/feed test files, reply controller test name, ComposerDraft mocks) exist because those files were not read during planning; each has an exact `rg` command and the content to produce. diff --git a/docs/superpowers/plans/2026-10-17-giphy-byok.md b/docs/superpowers/plans/2026-10-17-giphy-byok.md deleted file mode 100644 index fb5201c63..000000000 --- a/docs/superpowers/plans/2026-10-17-giphy-byok.md +++ /dev/null @@ -1,1225 +0,0 @@ -# Bring-Your-Own GIPHY Key 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:** Move the GIPHY key from the instance env var to an encrypted per-user credential, so the GIF picker is opt-in and each user's searches run on their own quota. - -**Architecture:** One encrypted `users.giphy_key` column, hidden from all model serialization and delivered to its owner only through the existing Inertia `giphy` prop. A "Giphy" section on the Profile settings page connects/updates/removes the key; the browser verifies the key against GIPHY before saving. The env key (`GIPHY_KEY`, `services.giphy`) is removed entirely. Server never calls GIPHY. - -**Tech Stack:** Laravel 13 (PHP 8.5, strict types), Inertia 3 + React 19 + TypeScript, Pest 5, node:test for lib tests, vite-plus for component tests. - -**Spec:** `docs/superpowers/specs/2026-10-17-giphy-byok-design.md` — read it before starting. - -## Global Constraints - -- PHP: `declare(strict_types=1);` in every file; classes `final`; actions `final readonly` with a single `handle()`; validation/authorization only in Form Requests (enforced by `tests/Unit/ArchTest.php`). -- Tests: Pest with **exactly 100.0% line coverage** and 100% type coverage; PHPStan at max level. Every new PHP branch needs a test. -- No real HTTP in tests: `Http::preventStrayRequests()` is global; frontend tests stub `fetch`. -- GIPHY ToS: **no server-side GIPHY request anywhere** — not even to verify a key (the browser verifies); committed GIF media URLs stay verbatim; the picker keeps its "Powered By GIPHY" mark; all API calls send `rating=g`. -- Wayfinder: `resources/js/actions`, `resources/js/routes`, and `resources/js/wayfinder` are gitignored and generated. After any route/controller change run `php artisan wayfinder:generate --with-form` (matches the Vite plugin's `formVariants: true`) and **never commit the generated files**. -- The backend stores a key it never validates; a malicious client can only store garbage for its own account. -- Copy voice: dry, terminal-literate; a post is a "commit". -- House rule: `git add` new files immediately after creating them. -- Formatting: if Pint/oxfmt complain at the end, run `composer lint` and include the fixes in the final task's commit. -- JS gates: `bun run test:unit`, `bun run test:types`, `bun run test:lint`. PHP gate: `composer test`. - ---- - -### Task 1: `users.giphy_key` — encrypted column, hidden from serialization - -**Files:** -- Create: `database/migrations/_add_giphy_key_to_users_table.php` (via artisan) -- Modify: `app/Models/User.php` (docblock, `#[Hidden]`, `casts()`) -- Modify: `database/factories/UserFactory.php` -- Test: `tests/Unit/Models/UserTest.php` - -**Interfaces:** -- Consumes: nothing new. -- Produces: `User::$giphy_key` (`string|null`, encrypted at rest, hidden from `toArray()`/JSON); `UserFactory::withGiphyKey(string $key = 'giphy-test-key'): self`. Later tasks use the factory state and read `$user->giphy_key`. - -- [ ] **Step 1: Create the migration** - -Run: `php artisan make:migration add_giphy_key_to_users_table` - -Then make the file read exactly: - -```php -text('giphy_key')->nullable(); - }); - } -}; -``` - -(No `down()` — matches the existing `add_giphy_to_commits_table` migration.) - -- [ ] **Step 2: Write the failing tests** - -Append to `tests/Unit/Models/UserTest.php` (it already imports `DB` and `User`): - -```php -it('stores the giphy key encrypted and reads it back', function (): void { - $user = User::factory()->withGiphyKey('giphy-test-key')->create(); - - expect($user->refresh()->giphy_key)->toBe('giphy-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) - ->toBeString() - ->not->toBe('giphy-test-key') - ->not->toContain('giphy-test-key'); -}); - -it('defaults the giphy key to null', function (): void { - expect(User::factory()->create()->refresh()->giphy_key)->toBeNull(); -}); - -it('serializes no giphy key', function (): void { - $user = User::factory()->withGiphyKey()->create(); - - expect($user->toArray())->not->toHaveKey('giphy_key') - ->and($user->jsonSerialize())->not->toHaveKey('giphy_key'); -}); -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `php artisan test tests/Unit/Models/UserTest.php` -Expected: FAIL — `Call to undefined method Database\Factories\UserFactory::withGiphyKey()`. - -- [ ] **Step 4: Implement the factory state, cast, and hidden attribute** - -In `database/factories/UserFactory.php`, add after `withoutProfile()`: - -```php - public function withGiphyKey(string $key = 'giphy-test-key'): self - { - return $this->state(fn (array $attributes): array => [ - 'giphy_key' => $key, - ]); - } -``` - -In `app/Models/User.php`: - -1. Docblock — add after `@property-read string|null $remember_token`: - -```php - * @property-read string|null $giphy_key -``` - -2. `#[Hidden([...])]` attribute — add `'giphy_key',` to the list (after `'remember_token',`). - -3. `casts()` — add after `'remember_token' => 'string',`: - -```php - 'giphy_key' => 'encrypted', -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `php artisan test tests/Unit/Models/UserTest.php` -Expected: PASS (the pre-existing `to array` test must also still pass — it pins the exact serialized keys). - -- [ ] **Step 6: Commit** - -```bash -git add database/migrations database/factories/UserFactory.php app/Models/User.php tests/Unit/Models/UserTest.php -git commit -m "feat: store a per-user giphy key" -``` - ---- - -### Task 2: Share the key from the user account, retire the env key - -**Files:** -- Modify: `app/Http/Middleware/HandleInertiaRequests.php:48-52` (the `giphy` prop) -- Modify: `config/services.php` (remove the `giphy` block) -- Modify: `.env.example:73-76` (remove the `GIPHY_KEY` block) -- Modify: `README.md:59` (the "GIFs" paragraph) -- Test: `tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` (rewrite) - -**Interfaces:** -- Consumes: `User::$giphy_key` and `UserFactory::withGiphyKey()` from Task 1. -- Produces: the `giphy` page prop (`{key: string} | null`) sourced from the signed-in user; guests get `null`. The client modules and `types/global.d.ts` are unchanged. - -- [ ] **Step 1: Rewrite the failing middleware tests** - -Replace the whole contents of `tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` with: - -```php -withGiphyKey('giphy-test')->create(); - - $this->actingAs($user) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy.key', 'giphy-test')); -}); - -it('shares no giphy config without a key', function (): void { - $this->actingAs(User::factory()->create()) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); -}); - -it('shares no giphy config for guests', function (): void { - $this->get(route('log.show', Log::Trending)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `php artisan test tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` -Expected: FAIL — the prop still comes from `config('services.giphy.key')`, so the signed-in user's key is not shared. - -- [ ] **Step 3: Source the prop from the user** - -In `app/Http/Middleware/HandleInertiaRequests.php`, replace the `giphy` entry (currently lines 48–52): - -```php - 'giphy' => fn (): ?array => (function () use ($request): ?array { - $key = $request->user()?->giphy_key; - - return is_string($key) && $key !== '' ? ['key' => $key] : null; - })(), -``` - -- [ ] **Step 4: Remove the env key everywhere** - -1. `config/services.php` — delete this block: - -```php - 'giphy' => [ - 'key' => env('GIPHY_KEY'), - ], -``` - -2. `.env.example` — delete the four-line block: - -``` -# GIF picker (GIPHY). The key is shipped to the browser, as GIPHY requires client-side API calls, -# and the picker is hidden without one. Beta keys are heavily rate limited; production use needs -# an approved key with "Powered By GIPHY" attribution (https://developers.giphy.com). -GIPHY_KEY= -``` - -3. `README.md` — replace the whole `### GIFs` paragraph with: - -```markdown -GIF search is bring-your-own-key: connect your own free [GIPHY API key](https://developers.giphy.com) under Settings → Profile → Giphy and every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. Searches run on the connected key and its quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to GIPHY directly and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. Production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `php artisan test tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php` -Expected: PASS (3 tests). - -Also run the neighboring suite to catch stragglers: `php artisan test tests/Feature/Controllers/CommitControllerTest.php tests/Feature/Controllers/CommitReplyControllerTest.php` -Expected: PASS — commit payload tests never needed the key. - -- [ ] **Step 6: Commit** - -```bash -git add app/Http/Middleware/HandleInertiaRequests.php config/services.php .env.example README.md tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php -git commit -m "feat: share the giphy key from the user account" -``` - ---- - -### Task 3: Connect and remove endpoints - -**Files:** -- Create: `app/Http/Requests/UpdateGiphyKeyRequest.php` -- Create: `app/Http/Controllers/GiphyKeyController.php` -- Modify: `routes/web.php` (import + two routes after the pasteis-mode route) -- Test: `tests/Feature/Controllers/GiphyKeyControllerTest.php` (create) -- Test: `tests/Feature/Controllers/ExportControllerTest.php` (append one test) - -**Interfaces:** -- Consumes: `User::$giphy_key` (Task 1). -- Produces: `PUT settings/giphy` → route name `giphy-key.update`, `DELETE settings/giphy` → route name `giphy-key.destroy`; generated actions at `@/actions/App/Http/Controllers/GiphyKeyController` (`update.url()`, `destroy.url()`); success toasts `'GIPHY key connected.'` / `'GIPHY key removed.'`. - -- [ ] **Step 1: Write the failing controller tests** - -Create `tests/Feature/Controllers/GiphyKeyControllerTest.php`: - -```php -create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('giphy-key.update'), ['key' => ' giphy-test-key ']) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key connected.', - ]); - - expect($user->refresh()->giphy_key)->toBe('giphy-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) - ->not->toContain('giphy-test-key'); -}); - -it('replaces an existing key', function (): void { - $user = User::factory()->withGiphyKey('first-key')->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('giphy-key.update'), ['key' => 'second-key']) - ->assertRedirectToRoute('user-profile.edit'); - - expect($user->refresh()->giphy_key)->toBe('second-key'); -}); - -it('removes the key', function (): void { - $user = User::factory()->withGiphyKey()->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->delete(route('giphy-key.destroy')) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key removed.', - ]); - - expect($user->refresh()->giphy_key)->toBeNull(); -}); - -it('rejects invalid keys without touching the stored one', function (array $payload): void { - $user = User::factory()->withGiphyKey('stored-key')->create(); - - $this->actingAs($user)->putJson(route('giphy-key.update'), $payload) - ->assertUnprocessable()->assertJsonValidationErrors('key'); - - expect($user->refresh()->giphy_key)->toBe('stored-key'); -})->with([ - 'missing' => [fn (): array => []], - 'empty' => [fn (): array => ['key' => '']], - 'whitespace' => [fn (): array => ['key' => ' ']], - 'not a string' => [fn (): array => ['key' => ['nested']]], - 'too long' => [fn (): array => ['key' => str_repeat('a', 256)]], -]); - -it('requires authentication to connect a key', function (): void { - $this->putJson(route('giphy-key.update'), ['key' => 'giphy-test-key'])->assertUnauthorized(); -}); - -it('requires authentication to remove a key', function (): void { - $this->deleteJson(route('giphy-key.destroy'))->assertUnauthorized(); -}); -``` - -Then append to `tests/Feature/Controllers/ExportControllerTest.php`: - -```php -it('never exports the giphy key', function (): void { - $user = User::factory()->withGiphyKey('giphy-test-key')->create(); - - $response = $this->actingAs($user)->get(route('export')); - - $response->assertOk(); - $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - - expect($data['profile'])->not->toHaveKey('giphy_key') - ->and($response->streamedContent())->not->toContain('giphy-test-key'); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `php artisan test tests/Feature/Controllers/GiphyKeyControllerTest.php tests/Feature/Controllers/ExportControllerTest.php` -Expected: FAIL — `Route [giphy-key.update] not defined` (export test fails on `withGiphyKey` only if Task 1 is missing; it should pass once Task 1 is in). - -- [ ] **Step 3: Write the Form Request** - -Create `app/Http/Requests/UpdateGiphyKeyRequest.php`: - -```php -input('key'); - - if (is_string($key)) { - $this->merge(['key' => trim($key)]); - } - } - - /** - * @return array> - */ - public function rules(): array - { - return [ - 'key' => ['required', 'string', 'max:255'], - ]; - } -} -``` - -- [ ] **Step 4: Write the controller** - -Create `app/Http/Controllers/GiphyKeyController.php`: - -```php -update(['giphy_key' => $request->string('key')->toString()]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key connected.', - ]); - - return back(); - } - - public function destroy(#[CurrentUser] User $user): RedirectResponse - { - $user->update(['giphy_key' => null]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key removed.', - ]); - - return back(); - } -} -``` - -- [ ] **Step 5: Register the routes** - -In `routes/web.php`: - -1. Add `use App\Http\Controllers\GiphyKeyController;` in the controller import block (alphabetically, after the `FailedJob*` imports). -2. Add these two routes right after the `settings/pasteis-mode` route (around line 256): - -```php - // Giphy key... - Route::put('settings/giphy', [GiphyKeyController::class, 'update'])->name('giphy-key.update'); - Route::delete('settings/giphy', [GiphyKeyController::class, 'destroy'])->name('giphy-key.destroy'); -``` - -- [ ] **Step 6: Regenerate the TypeScript route actions** - -Run: `php artisan wayfinder:generate --with-form` -Then verify: `ls resources/js/actions/App/Http/Controllers/GiphyKeyController.ts` -Expected: the file exists (it is gitignored — do not commit it). - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `php artisan test tests/Feature/Controllers/GiphyKeyControllerTest.php tests/Feature/Controllers/ExportControllerTest.php` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add app/Http/Requests/UpdateGiphyKeyRequest.php app/Http/Controllers/GiphyKeyController.php routes/web.php tests/Feature/Controllers/GiphyKeyControllerTest.php tests/Feature/Controllers/ExportControllerTest.php -git commit -m "feat: connect and remove the giphy key" -``` - ---- - -### Task 4: Browser-side key verification and copy - -**Files:** -- Modify: `resources/js/lib/giphy.ts` (add `validateGiphyKey`, update `GiphyRateLimited` message) -- Modify: `resources/js/components/giphy-picker-dialog.tsx:124` (rate-limit copy) -- Modify: `resources/js/components/giphy-picker-button.tsx:10` (docblock) -- Test: `tests/Unit/Giphy.test.ts` (append tests) - -**Interfaces:** -- Consumes: `giphyTrendingUrl` (existing). -- Produces: `export type GiphyKeyValidation = 'valid' | 'invalid' | 'rate-limited' | 'unreachable'` and `export async function validateGiphyKey(key: string): Promise`. Task 5's component calls it before saving: 200 → `'valid'`, 401/403 → `'invalid'`, 429 → `'rate-limited'`, network error or any other status → `'unreachable'`. - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/Unit/Giphy.test.ts` (add `validateGiphyKey` to the existing import from `../../resources/js/lib/giphy.ts`): - -```ts -const originalFetch = globalThis.fetch; - -function stubFetch(result: number | 'network'): string[] { - const calls: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL) => { - calls.push(String(input)); - - if (result === 'network') { - throw new TypeError('fetch failed'); - } - - return new Response('{}', { status: result }); - }) as typeof fetch; - - return calls; -} - -function restoreFetch(): void { - globalThis.fetch = originalFetch; -} - -await test('validates a key against the trending endpoint', async () => { - const calls = stubFetch(200); - - try { - assert.equal(await validateGiphyKey('key-123'), 'valid'); - assert.ok(calls[0]?.includes('api_key=key-123')); - assert.ok(calls[0]?.includes('rating=g')); - } finally { - restoreFetch(); - } -}); - -await test('treats a refused key as invalid', async () => { - for (const status of [401, 403]) { - stubFetch(status); - - try { - assert.equal(await validateGiphyKey('key-123'), 'invalid'); - } finally { - restoreFetch(); - } - } -}); - -await test('treats a throttled key as rate limited but real', async () => { - stubFetch(429); - - try { - assert.equal(await validateGiphyKey('key-123'), 'rate-limited'); - } finally { - restoreFetch(); - } -}); - -await test('treats network failures and unexpected statuses as unreachable', async () => { - stubFetch('network'); - - try { - assert.equal(await validateGiphyKey('key-123'), 'unreachable'); - } finally { - restoreFetch(); - } - - stubFetch(500); - - try { - assert.equal(await validateGiphyKey('key-123'), 'unreachable'); - } finally { - restoreFetch(); - } -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `bun run test:unit` -Expected: FAIL — `validateGiphyKey` is not exported. - -- [ ] **Step 3: Implement the validation helper and copy changes** - -In `resources/js/lib/giphy.ts`: - -1. Update the `GiphyRateLimited` message: - -```ts -export class GiphyRateLimited extends Error { - constructor() { - super('Your GIPHY key is rate limiting searches.'); - } -} -``` - -2. Add below `fetchGifs` (and export it): - -```ts -export type GiphyKeyValidation = - | 'valid' - | 'invalid' - | 'rate-limited' - | 'unreachable'; - -/** - * Verifies a pasted key against GIPHY from the browser, so no key ever - * reaches the server for validation. A throttled key is still a real key. - */ -export async function validateGiphyKey( - key: string, -): Promise { - let response: Response; - - try { - response = await fetch(giphyTrendingUrl(key)); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} -``` - -In `resources/js/components/giphy-picker-dialog.tsx`, find (line ~124): - -```tsx - GIPHY is rate limiting searches. Try again shortly. -``` - -and replace it with: - -```tsx - You’ve hit your GIPHY key’s search rate limit. Try - again shortly. -``` - -(Preserve the file's typographic apostrophes; keep surrounding JSX as-is.) - -In `resources/js/components/giphy-picker-button.tsx`, replace the docblock: - -```ts -/** - * Invisible until the signed-in user connects their own GIPHY key in settings. - */ -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `bun run test:unit && bun run test:types` -Expected: PASS (all suites, including the untouched composer tests). - -- [ ] **Step 5: Commit** - -```bash -git add resources/js/lib/giphy.ts resources/js/components/giphy-picker-dialog.tsx resources/js/components/giphy-picker-button.tsx tests/Unit/Giphy.test.ts -git commit -m "feat: verify the giphy key in the browser" -``` - ---- - -### Task 5: The Giphy settings section - -**Files:** -- Create: `resources/js/components/giphy-key-setting.tsx` -- Modify: `resources/js/pages/user-profile/edit.tsx` -- Modify: `app/Http/Controllers/UserProfileController.php` (add `giphyConnected`) -- Modify: `tests/Feature/Controllers/UserProfileControllerTest.php` (append one test) -- Create: `tests/Unit/GiphyKeySetting.test.tsx` -- Create: `tests/giphy-key.config.ts` -- Create: `tests/Unit/GiphyKeySettingRender.test.ts` -- Modify: `tests/tsconfig.json` - -**Interfaces:** -- Consumes: `validateGiphyKey` (Task 4); generated actions `{ update, destroy }` from `@/actions/App/Http/Controllers/GiphyKeyController` and route URLs `/settings/giphy` (Task 3); page prop `giphyConnected: boolean` from `UserProfileController::edit`. -- Produces: `GiphyKeySetting({ giphyConnected }: { giphyConnected: boolean })` rendered on the Profile settings page. - -- [ ] **Step 1: Write the failing profile page test** - -Append to `tests/Feature/Controllers/UserProfileControllerTest.php`: - -```php -it('renders whether a giphy key is connected', function (bool $connected): void { - $user = $connected - ? User::factory()->withGiphyKey()->create() - : User::factory()->create(); - - $this->actingAs($user) - ->get(route('user-profile.edit')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('user-profile/edit') - ->where('giphyConnected', $connected) - ->missing('auth.user.giphy_key')); -})->with([true, false]); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php` -Expected: FAIL — `giphyConnected` prop is missing. - -- [ ] **Step 3: Add the prop to the page controller** - -In `app/Http/Controllers/UserProfileController.php`, add to the `Inertia::render` payload in `edit()`: - -```php - 'giphyConnected' => $user->giphy_key !== null, -``` - -- [ ] **Step 4: Write the failing component test** - -Create `tests/giphy-key.config.ts`: - -```ts -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('../resources/js', import.meta.url)), - }, - }, - test: { - include: ['tests/Unit/GiphyKeySetting.test.tsx'], - }, -}); -``` - -Create `tests/Unit/GiphyKeySetting.test.tsx`: - -```tsx -/// -import assert from 'node:assert/strict'; -import * as React from 'react'; -import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; - -let state: unknown[] = []; -let cursor = 0; - -vi.doMock('react', () => ({ - ...React, - useEffect: (effect: () => void) => effect(), - useMemo: (factory: () => unknown) => factory(), - useId: () => `preview-${cursor++}`, - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => - snapshot(), - useRef: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = { current: initial }; - } - - return state[index]; - }, - useState: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = typeof initial === 'function' ? initial() : initial; - } - - return [ - state[index], - (value: unknown) => { - state[index] = - typeof value === 'function' - ? (value as (current: unknown) => unknown)(state[index]) - : value; - }, - ]; - }, -})); - -const put = vi.fn(); -const destroy = vi.fn(); - -vi.doMock('@inertiajs/react', async () => ({ - ...(await vi.importActual('@inertiajs/react')), - router: { put, delete: destroy }, -})); - -const { GiphyKeySetting } = await import( - '../../resources/js/components/giphy-key-setting' -); - -type Element = ReactElement>; - -function elements(node: ReactNode): Element[] { - if (!React.isValidElement(node)) { - return []; - } - - const element = node as Element; - - return [ - element, - ...React.Children.toArray(element.props.children as ReactNode).flatMap( - elements, - ), - ]; -} - -function render(giphyConnected: boolean): Element[] { - cursor = 0; - - return elements(GiphyKeySetting({ giphyConnected })); -} - -function field(fields: Element[], testId: string): Element { - const found = fields.find( - (element) => element.props['data-test'] === testId, - ); - - assert.ok(found, `no element with data-test="${testId}"`); - - return found; -} - -function stubFetch(result: number | 'network'): void { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - if (result === 'network') { - throw new TypeError('fetch failed'); - } - - return new Response('{}', { status: result }); - }), - ); -} - -function typeKey(fields: Element[], value: string): Element[] { - field(fields, 'giphy-key-input').props.onChange({ - target: { value }, - }); - - return render(false); -} - -beforeEach(() => { - state = []; - cursor = 0; - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); - -test('saves a valid key after giphy accepts it', async () => { - stubFetch(200); - - let fields = typeKey(render(false), ' key-123 '); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 1); - assert.equal(put.mock.calls[0]?.[0], '/settings/giphy'); - assert.deepEqual(put.mock.calls[0]?.[1], { key: 'key-123' }); - - const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; - options.onSuccess(); - - fields = render(true); - assert.ok(field(fields, 'giphy-connected')); -}); - -test('saves a rate-limited key because the key itself is real', async () => { - stubFetch(429); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 1); -}); - -test('rejects a refused key without saving', async () => { - stubFetch(401); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 0); - assert.equal( - field(render(false), 'giphy-key-error').props.children, - 'That key was rejected by GIPHY.', - ); -}); - -test('does not save when giphy cannot be reached', async () => { - stubFetch('network'); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 0); - assert.equal( - field(render(false), 'giphy-key-error').props.children, - "Couldn't reach GIPHY to verify the key.", - ); -}); - -test('disables connect until a key is typed', () => { - const fields = render(false); - - assert.equal(field(fields, 'giphy-connect-button').props.disabled, true); -}); - -test('surfaces server validation errors', async () => { - stubFetch(200); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - const options = put.mock.calls[0]?.[2] as { - onError: (errors: Record) => void; - }; - options.onError({ key: 'The key field is required.' }); - - assert.equal( - field(render(false), 'giphy-key-error').props.children, - 'The key field is required.', - ); -}); - -test('removes a connected key', () => { - const fields = render(true); - - (field(fields, 'giphy-remove-button').props.onClick as () => void)(); - - assert.equal(destroy.mock.calls.length, 1); - assert.equal(destroy.mock.calls[0]?.[0], '/settings/giphy'); -}); -``` - -Create `tests/Unit/GiphyKeySettingRender.test.ts`: - -```ts -import { execFileSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -await test('connects, updates, and removes the giphy key from profile settings', () => { - const cli = fileURLToPath( - new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), - ); - - execFileSync( - process.execPath, - [cli, 'test', 'run', '--config', 'tests/giphy-key.config.ts'], - { - cwd: fileURLToPath(new URL('../..', import.meta.url)), - stdio: 'inherit', - }, - ); -}); -``` - -- [ ] **Step 5: Run the component test to verify it fails** - -Run: `bun run test:unit` -Expected: FAIL — cannot resolve `../../resources/js/components/giphy-key-setting`. - -- [ ] **Step 6: Write the component** - -Create `resources/js/components/giphy-key-setting.tsx`: - -```tsx -import { router } from '@inertiajs/react'; -import { useState } from 'react'; -import { - destroy, - update, -} from '@/actions/App/Http/Controllers/GiphyKeyController'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Spinner } from '@/components/ui/spinner'; -import { validateGiphyKey } from '@/lib/giphy'; - -export function GiphyKeySetting({ - giphyConnected, -}: { - giphyConnected: boolean; -}) { - const [key, setKey] = useState(''); - const [editing, setEditing] = useState(!giphyConnected); - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(null); - - const connect = async () => { - if (processing) { - return; - } - - const candidate = key.trim(); - - if (candidate === '') { - return; - } - - setProcessing(true); - setError(null); - - const verdict = await validateGiphyKey(candidate); - - if (verdict === 'invalid') { - setError('That key was rejected by GIPHY.'); - setProcessing(false); - - return; - } - - if (verdict === 'unreachable') { - setError("Couldn't reach GIPHY to verify the key."); - setProcessing(false); - - return; - } - - router.put( - update.url(), - { key: candidate }, - { - preserveScroll: true, - onFinish: () => setProcessing(false), - onError: (errors) => - setError(errors.key ?? 'Could not save the key.'), - onSuccess: () => { - setKey(''); - setError(null); - setEditing(false); - }, - }, - ); - }; - - const remove = () => { - if (processing) { - return; - } - - setError(null); - - router.delete(destroy.url(), { - preserveScroll: true, - onStart: () => setProcessing(true), - onFinish: () => setProcessing(false), - onError: () => setError('Could not remove the key.'), - }); - }; - - return ( -
-

- Giphy -

-

- GIF search runs on your own GIPHY API key, under your own - quota. Get a free key at{' '} - - developers.giphy.com - - , connect it here, and every composer gains a GIF picker. - Without a key, the picker stays hidden. -

- {giphyConnected && !editing ? ( -
-

- GIPHY is connected. -

- - -
- ) : ( -
- -
- setKey(event.target.value)} - placeholder="Paste your GIPHY API key" - autoComplete="off" - data-test="giphy-key-input" - /> - -
-
- )} - {error !== null && ( -

- {error} -

- )} -
- ); -} -``` - -- [ ] **Step 7: Wire the page** - -In `resources/js/pages/user-profile/edit.tsx`: - -1. Import: `import { GiphyKeySetting } from '@/components/giphy-key-setting';` (alphabetically among component imports). -2. Add `giphyConnected` to the destructured props and the inline type: - -```tsx -export default function Edit({ - preferredLane, - automaticLane, - githubSyncedAt, - giphyConnected, - activityGraphUrl, - profileUrl, -}: { - preferredLane: number | null; - automaticLane: number; - githubSyncedAt: string | null; - giphyConnected: boolean; - activityGraphUrl: string; - profileUrl: string; -}) { -``` - -3. Render the section right after ``: - -```tsx - -``` - -- [ ] **Step 8: Register the test files for type checking** - -In `tests/tsconfig.json`, extend `include` with: - -```json - "Unit/GiphyKeySetting.test.tsx", - "giphy-key.config.ts" -``` - -- [ ] **Step 9: Run everything** - -Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php` -Expected: PASS. - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. If lint/format complains, run `bun run lint` and include the fixes. - -- [ ] **Step 10: Commit** - -```bash -git add resources/js/components/giphy-key-setting.tsx resources/js/pages/user-profile/edit.tsx app/Http/Controllers/UserProfileController.php tests/Feature/Controllers/UserProfileControllerTest.php tests/Unit/GiphyKeySetting.test.tsx tests/giphy-key.config.ts tests/Unit/GiphyKeySettingRender.test.ts tests/tsconfig.json -git commit -m "feat: add the giphy key section to profile settings" -``` - ---- - -### Task 6: Full gates - -**Files:** -- Modify: only if gates surface fixes. - -**Interfaces:** -- Consumes: everything above. -- Produces: a green branch ready for review. - -- [ ] **Step 1: Run the full PHP gate** - -Run: `composer test` -Expected: PASS — Pint, PHPStan max, 100% line/type coverage, full Pest suite. If Pint flags formatting, run `composer lint` and commit the fixes with the task. - -- [ ] **Step 2: Run the full JS gates** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS — lib tests, component tests, both tsconfigs, fmt + lint. - -- [ ] **Step 3: Sanity-check the feature end to end (manual, optional)** - -Run `bun run dev` (or use the worktree's built assets), sign in, open Settings → Profile → Giphy, paste any junk string: the browser-side check must show "That key was rejected by GIPHY." without saving. With a real key the toast "GIPHY key connected." appears and the composer's GIF button turns visible. - -- [ ] **Step 4: Commit any residual fixes** - -```bash -git add -A -git commit -m "chore: satisfy the gates for the byok giphy key" -``` - -(Only if Steps 1–2 produced changes; otherwise skip.) diff --git a/docs/superpowers/specs/2026-10-16-giphy-picker-design.md b/docs/superpowers/specs/2026-10-16-giphy-picker-design.md deleted file mode 100644 index f425cea48..000000000 --- a/docs/superpowers/specs/2026-10-16-giphy-picker-design.md +++ /dev/null @@ -1,188 +0,0 @@ -# Giphy GIF picker — design - -**Date:** 2026-10-16 -**Status:** Approved design, pending implementation plan - -## Summary - -Let a commit carry one Giphy GIF. The composer (commit, reply, and quote) gains a GIF picker -that searches Giphy directly from the browser; the chosen GIF is stored as URL references on -the commit and rendered by hotlinking Giphy's CDN. The server never calls Giphy. - -A commit body is always required (3–280 chars); the GIF is an attachment next to text, never -instead of it. A commit carries **at most one GIF, with no uploaded images alongside it** -(one solo GIF). GIF + link card is allowed, as it is for images today. - -## Approach (decided) - -**Direct client-side integration.** Giphy's API terms prohibit caching or storing copies of -their media and prohibit proxying API calls or media loads — media must be loaded directly -from the URLs their API returns, on the client, with "Powered By GIPHY" attribution. So: - -- The browser calls `api.giphy.com` for search/trending with the API key (shared via Inertia). -- Commits store the GIF's media URL (verbatim), page URL, title, and creator username. -- Rendering hotlinks `media.giphy.com` directly, like GitHub avatars already are. -- No server-side Giphy request, storage, or proxy, anywhere. - -Rejected alternatives: a server-side search proxy (violates "do not proxy API calls", hides -the key for no functional gain) and downloading/storing GIFs like uploads (explicitly -prohibited by the ToS, and GD cannot re-encode animations). - -Accepted costs: the API key is visible in the browser (how Giphy keys are designed to work), -viewers' IPs reach Giphy, and a GIF breaks if Giphy removes it. - -## Data model - -Migration `add_giphy_to_commits_table` — nullable columns on `commits` (not a shared table: -the metadata never refetches server-side, max one per commit, and it is tiny): - -| Column | Type | Purpose | -|---|---|---| -| `giphy_id` | string(64) | GIF id, `[A-Za-z0-9]+` | -| `giphy_url` | text | media URL **exactly as returned by the API** — query string included, never rewritten (ToS) | -| `giphy_page_url` | text | giphy.com page URL, for the attribution link | -| `giphy_title` | string(300) | creator title → alt text | -| `giphy_username` | string(100) | creator username, for attribution where available | -| `giphy_width` | unsignedSmallInteger | rendition width, for layout stability and OG tags | -| `giphy_height` | unsignedSmallInteger | rendition height, for layout stability and OG tags | - -**Rendition:** the stored `giphy_url` is always a **`.gif` rendition** (prefer -`downsized_medium`, fall back to `downsized`, then `original`), so the `image/gif` MIME type -declared in Atom enclosures and OG tags is honest. Picker thumbnails use a small fixed-height -rendition and are never stored. - -**Serialization:** `Commit::toArray()` strips the raw `giphy_*` keys and appends a derived -`gif` object — `{id, url, page_url, title, username, width, height} | null`. The existing tombstone branch -already whitelists only `id/parent_id/deleted_at/replies_count`, so deleted commits drop the -GIF for free. Pending/rejected commits only ship to their author and admins (existing -`isVisibleTo` gating), which covers GIF visibility — the URL is public on Giphy regardless. - -**Deletion:** nothing to clean up — Giphy hosts the bytes. Soft-delete, restore window, and -tombstone semantics are unchanged. - -## Validation - -In `CreateCommitRequest` and `CreateCommitReplyRequest` (these cover commits, quotes, and -replies; the CLI commit path does not accept attachments and is untouched): - -- `giphy_url`: `https` scheme, host matching `media\d*.giphy.com`, path under `/media/` - ending in `.gif`, **query string allowed and preserved verbatim**, no fragment. (The path - is not required to contain `giphy_id`: modern Giphy media URLs use opaque `v1.…` path - tokens instead of the plain id. The host allowlist is the security boundary.) -- `giphy_id`: `[A-Za-z0-9]+`, max 64. -- `giphy_width`/`giphy_height`: positive integers, required with `giphy_id`. -- `giphy_page_url`: `https`, host `giphy.com` or `www.giphy.com`. -- `giphy_title` max 300, `giphy_username` max 100, both nullable. -- The `giphy_*` fields arrive as a set — partial submissions are rejected. -- Exclusivity, mirroring the existing images/poll rules: GIF ⟂ uploaded images, GIF ⟂ poll, - GIF ⟂ scheduling. GIF + link card is allowed. -- Server validation accepts well-formed GIF fields even when `GIPHY_KEY` is unset — the key - is a client-side search credential, not a server dependency. - -`CreateCommit` takes the validated fields via a small readonly DTO in `App\Dto` (like the -existing `Embed`). `CreateCommitImages` is untouched. - -## Composer UX and frontend structure - -All in `resources/js`, following the existing `commit-image-picker.tsx` patterns (ghost -toolbar buttons, `data-test` attributes, Radix/shadcn `ui/*` primitives): - -- **`lib/giphy.ts`** — pure, node-testable: the `GiphyAttachment` type - (`{id, url, page_url, title, username, width, height}`), direct fetches to `api.giphy.com` - (`/v1/gifs/search`, `/v1/gifs/trending`, always `rating=g`), and - `attachmentFromResult(item)` which picks the `.gif` rendition verbatim. -- **`components/giphy-picker-dialog.tsx`** — Radix Dialog: debounced (~300 ms) search field, - trending on empty query, thumbnail grid, "Load more" (offset pagination, no infinite - scroll), loading/empty/error states, click to select. Carries the required - **"Powered By GIPHY"** mark (official logo asset bundled under `public/`). -- **`components/giphy-picker-button.tsx`** — ghost toolbar button next to the image picker, - hidden entirely when no key is configured. -- **`components/giphy-attachment-preview.tsx`** — selected-GIF thumbnail with a remove - button in the composer, mirroring `ImagePickerPreviews`. -- **Stash:** `use-stash` entries gain a persisted `gif` field. localStorage is enough — - there is no `File`, so drafts survive reloads like bodies do. -- **Mutual exclusion in the composer:** picking a GIF clears uploads (and poll); attaching - images or starting a poll clears the GIF. No confirmation dialog — the preview - disappearing is feedback enough. -- **Wiring:** `commit-composer.tsx`, `commit-thread.tsx` (`ReplyComposer`), and - `commit-quote-button.tsx` (`QuoteComposer`) all get the button; submissions send hidden - `giphy_*` fields exactly like `images`/`image_descriptions` today. -- **Key delivery:** `HandleInertiaRequests` shares `giphy` from `config/services.php` - (`GIPHY_KEY` env), typed in `types/global.d.ts`. No key ⇒ the feature is invisible. -- **Composer preview:** `CommitPreviewController` only highlights the body server-side, and - draft attachments already render client-side — the draft GIF renders through the same - commit component with no endpoint change. - -Out of scope by design: photo-viewer/lightbox integration, GIF downloads (ToS), any server -proxy, and GIFs in quoted-commit embeds (`quoted-commit.tsx` renders body + tombstones only, -never attachments — GIFs keep that parity). - -## Rendering - -A new `components/commit-giphy.tsx`, used by `commit-card.tsx` in the same single-image frame -as `CommitImages`: - -- `{gif.title` - — URL verbatim, direct from Giphy, with the same one-shot `/favicon.svg` broken-image - fallback as uploaded images. `no-referrer` trims viewer data sent to Giphy without touching - the URL (their analytics ride the query params). -- Attribution overlay linking to `gif.page_url`: `GIF by @{username}` when known, else - "via GIPHY" — satisfying Giphy's content-attribution clause. The "Powered By GIPHY" mark - lives in the picker, where the API is utilized. - -## Server-side touchpoints (no Giphy calls, ever) - -- **Public API:** `CommitResource` gains `gif` (`{id, url, page_url, title, username, width, height} | null`), - mirroring how `images` are surfaced. Consumers load media directly from Giphy. -- **OG metadata:** `PageMetadata::commitImages()` emits `giphy_url` as the OG image for GIF - commits. Crawlers fetch Giphy directly — the permitted client-side pattern. -- **Atom feeds:** `CreateUserFeed` adds the GIF as an `image/gif` enclosure. -- **Moderation:** `UpdateCommitAiDecision` appends `giphy_url` to the image array it passes - to `OpenAI::moderate()`/`showsMinors()` — both already wrap entries as `image_url`, and - OpenAI accepts remote HTTPS URLs there, fetching Giphy itself (a third-party fetch, not us - proxying). Rename the `$imageDataUrls` parameters to `$imageUrls`, and extend the - `showsMinors` gate from "has images" to "has images or a GIF". If OpenAI's fetch fails, - moderation degrades exactly as it does today. -- Photo viewer, downloads, and `commit-image.show` are untouched — a GIF is not a stored - `CommitImage`. - -## Edge cases and error handling - -- **Giphy removes a GIF:** the `` breaks → favicon fallback. The post and its text - survive; nothing to clean server-side. -- **Malicious client submits a non-`g`-rated or junk URL:** format validation guarantees a - real `media*.giphy.com` reference; content safety is covered by moderation fetching the - URL. A non-existent GIF renders broken; moderation's fetch degrades gracefully. -- **Picker errors:** search failure → inline retry message in the dialog (not a toast); - Giphy 429 → "GIPHY is rate limiting searches, try again shortly."; no results → dry - empty-state copy in the app's voice. -- **Stashed drafts** whose GIF is later removed from Giphy show a broken preview; remove and - re-pick. No special handling. -- **Beta keys** are throttled hard (~42 searches/hour) and prohibited in production — the - README notes that a production key (with Giphy's "Powered By GIPHY" screenshot approval) - is needed for real deployments. - -## Testing - -Keeping the repo's 100% coverage and type-coverage bars; no real Giphy calls anywhere in CI -(`Http::preventStrayRequests()` already enforces this server-side; frontend fetches mocked): - -- **Pest, Feature:** valid GIF accepted on commit/reply/quote; URL scheme/host/path - rejections; query strings accepted and stored verbatim; partial `giphy_*` set rejected; - GIF ⟂ images, GIF ⟂ poll, GIF ⟂ scheduling; GIF + link card accepted; payload exposes the - `gif` object and never the raw columns; tombstones drop it. API v1 `gif` field, - `PageMetadata` OG image, Atom enclosure. -- **Pest, Unit:** `CreateCommit` persists the DTO; `UpdateCommitAiDecision` includes the - Giphy URL in `moderate`/`showsMinors` payloads (`Http::fake` on OpenAI, as existing tests - do). -- **Frontend:** `tests/Unit/Giphy.test.ts` (node:test) for `lib/giphy.ts` — request URL - construction, `rating=g` always present, rendition picking, result normalization. A Vite+ - `.test.tsx` for composer exclusivity and stash round-trip, following - `ComposerDraft.test.tsx` patterns. - -## Documentation - -- `.env.example` gains `GIPHY_KEY` with a comment. -- README gets a short "GIFs" section next to Embeds (key setup, production-key caveat). -- `PRODUCT.md` is updated so its "deliberately excluded" list (which still names - images/media and is already stale) does not contradict the GIF capability. diff --git a/docs/superpowers/specs/2026-10-17-giphy-byok-design.md b/docs/superpowers/specs/2026-10-17-giphy-byok-design.md deleted file mode 100644 index 2d7c298ee..000000000 --- a/docs/superpowers/specs/2026-10-17-giphy-byok-design.md +++ /dev/null @@ -1,157 +0,0 @@ -# Bring-your-own GIPHY key — design - -**Date:** 2026-10-17 -**Status:** Approved design, pending implementation plan - -## Summary - -Move the GIPHY key from an instance-wide env var to a per-user credential. The GIF picker -becomes fully opt-in: it stays invisible until the signed-in user connects their own GIPHY -API key. Their searches then run on their own quota instead of a shared rate limit, which -makes the feature viable in production for an open-source project with no GIPHY bill. - -Display of already-attached GIFs is untouched: media renders by hotlinking the stored GIPHY -URLs, which needs no key and no API call. - -## Approach (decided) - -**Server-stored, browser-used.** The key is stored encrypted on the user account -(`users.giphy_key`) and delivered back to its owner through the existing Inertia `giphy` -prop — the same delivery channel as today, just sourced from the user instead of -`config/services.php`. The browser continues to make every GIPHY call; no server-side GIPHY -request anywhere. - -Rejected alternatives: client-only localStorage (no cross-device sync, silent data loss when -storage is cleared) and keeping the env key as an instance fallback (resurrects the shared -rate limit this change exists to remove). The env key is removed entirely — `GIPHY_KEY`, -the `services.giphy` config block, and the `.env.example` entry all go away. - -Accepted costs: the key transits the server (stored encrypted at rest, never echoed back to -the UI) and appears in the owner's browser traffic (how GIPHY keys work — the API is called -client-side). Losing the key means pasting it again; there is nothing to recover. - -## Data model - -Migration `add_giphy_key_to_users_table` — one nullable `text` column on `users`. - -- `User::casts()` gains `'giphy_key' => 'encrypted'` (transparent encryption at rest). -- `User`'s `#[Hidden]` list gains `'giphy_key'` so raw model serialization - (`auth.user`, moderation pages, any other prop shipping a `User`) can never leak it. - The middleware's dedicated `giphy` prop is the only channel that ships the key, only to - its owner. -- `UserResource` is already an explicit whitelist and needs no change. - -**Gating:** `HandleInertiaRequests` shares `giphy: {key} | null` sourced from -`$request->user()?->giphy_key` (guests and keyless users get `null`). The prop shape is -unchanged, so `giphy-picker-button.tsx`, the dialog, and `lib/giphy.ts` need no structural -changes — only the rate-limit copy below. - -## Settings UI - -A **"Giphy" section on the Profile settings page** (`user-profile/edit`) — the fifth -`PageTabs` slot was rejected because `PageTabs` gives every tab `flex-1` with no overflow -handling, so "Integrations" would squish on mobile. The section follows the -`PasteisModeSetting` pattern: a self-contained component, its own routes, no new settings -page. - -- **Component:** `resources/js/components/giphy-key-setting.tsx`, rendered as a - `
` like its neighbors. -- **Props:** `UserProfileController::edit` adds `giphyConnected: bool`. The page prop is a - boolean only — the UI is **write-only** and never renders the stored key back. -- **Disconnected state:** short explainer (a free key from developers.giphy.com; searches - use the user's own quota) + key input + Connect button. -- **Connected state:** "GIPHY is connected." + Update (reveals the input again) and Remove - buttons. -- **Feedback:** `Inertia::flash('toast', ...)` toasts for connect/update/remove, matching - `UserProfileController::update`. - -## Validation and saving - -Validation of the key happens **in the browser, before saving** — GIPHY stays a -client-side concern, and the server never makes a GIPHY call to verify: - -| Browser test call result | Behavior | -|---|---| -| 200 | Save: `PUT settings/giphy` | -| 401/403 | Inline error "That key was rejected by GIPHY." — nothing saved | -| 429 | Key is real but throttled: save it anyway (limits reset hourly) | -| Network failure | Inline error "Couldn't reach GIPHY to verify the key." — nothing saved | - -The test call reuses the existing client module: a new `validateGiphyKey(key)` in -`resources/js/lib/giphy.ts` mapping the trending call's status codes to -`valid | invalid | rate-limited | unreachable`. - -Server side: - -- Routes (auth group, next to the profile settings routes; no throttle, matching - `user-lane`/`user-pasteis-mode`): `PUT settings/giphy` (`giphy-key.update`) and - `DELETE settings/giphy` (`giphy-key.destroy`). -- `app/Http/Requests/UpdateGiphyKeyRequest.php`: `key` required, string, trimmed, - non-empty, max 255 (validation lives only in Form Requests per `ArchTest`). -- `app/Http/Controllers/GiphyKeyController.php` (`final readonly`): update stores the - trimmed key; destroy nulls it. Both `return back()` with a toast flash. -- The server is a dumb encrypted box: a malicious client bypassing validation can only - store garbage for its own account. - -## Client behavior changes - -- **Picker gating:** unchanged mechanics — button hidden when `giphy` is `null`; now that - happens per-user (no key connected ⇒ invisible, fully opt-in as decided). -- **Rate-limit copy:** the dialog's 429 message becomes - "You've hit your GIPHY key's search rate limit. Try again shortly." (was the anonymous - "GIPHY is rate limiting searches."). Other states ("GIPHY did not answer.", retry, - empty results) are unchanged. -- **Display path:** zero changes — attached GIFs render from stored URLs with no key. - -## Edge cases - -- **Key removed while a picker dialog is open:** the current page's prop still has the key - until the next Inertia visit; harmless (it is the user's own key). -- **Key invalidated on GIPHY's side later:** searches fail; the dialog shows its error - state and the settings section lets the user replace the key. -- **Key updated:** the picker uses the new key on the next page load; existing commits are - unaffected. -- **Existing attached GIFs:** unaffected by connection state — display never needed a key. -- **Guests:** `giphy` prop is `null`; composers are auth-only, so no guest-facing path - exists. - -## Security - -- Encrypted cast at rest; `#[Hidden]` on the model; write-only UI; key never logged. -- Per-user delivery only: the middleware resolves it from the request's own user. -- The public API (`UserResource`, `CommitResource`) has no path to it. - -## Testing - -Keeping the repo's 100% coverage bars; frontend fetches stubbed, no real GIPHY calls. - -- **Pest, Feature — `GiphyKeyControllerTest`:** stores the trimmed key; encryption at rest - (raw DB value ≠ plaintext, cast decrypts); update replaces; destroy nulls; validation - failures (missing/empty whitespace/non-string/too long) leave the stored key untouched. -- **Pest, Feature — profile page:** `giphyConnected` true/false; the page never ships the - key itself; `auth.user` serialization excludes `giphy_key`. -- **Pest, Feature — `HandleInertiaRequestsGiphyTest` (rewrite):** `giphy.key` shared from - the authenticated user's key; `null` when unset and for guests. -- **Pest, Unit:** none expected beyond coverage obligations from the new branches. -- **Frontend — `tests/Unit/Giphy.test.ts`:** `validateGiphyKey` mapping for - 200/401/429/network. -- **Frontend — Vite+ (`vite-plus/test`) component tests:** `giphy-key-setting` state - machine (connect with valid key PUTs; invalid key shows the inline error and does not - PUT; remove DELETEs), following `ComposerDraft.test.tsx` patterns. -- **Existing tests:** the picker/gating suite stays green unchanged (prop shape is - identical). - -## Documentation - -- README "GIFs" section rewritten for BYOK: connect your own free key in Settings → - Profile (Giphy); no instance configuration; production caveat about GIPHY key approval - now applies to each user's own key. -- `.env.example`: `GIPHY_KEY` block removed. -- `PRODUCT.md` unchanged (no capability change). - -## Out of scope - -- Any server-side GIPHY proxy or call. -- OAuth-style GIPHY connect flows (paste-a-key only). -- Multiple keys per user, key labels, or usage counters. -- A dedicated settings tab or page (Profile section only). From 0eb26f6e395d3b85ef7ddb9bca53ac122c3c0b1e Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 15:26:49 -0400 Subject: [PATCH 33/83] style: tighten the giphy comments --- app/Dto/Giphy.php | 4 +--- app/Rules/GiphyMediaUrl.php | 6 ++---- resources/js/components/commit-giphy.tsx | 4 +--- resources/js/lib/gif-storage.ts | 4 +--- resources/js/lib/giphy.ts | 21 +++++---------------- 5 files changed, 10 insertions(+), 29 deletions(-) diff --git a/app/Dto/Giphy.php b/app/Dto/Giphy.php index 002d6b8d2..df8e99c81 100644 --- a/app/Dto/Giphy.php +++ b/app/Dto/Giphy.php @@ -4,9 +4,7 @@ namespace App\Dto; -/** - * A GIF picked from GIPHY. URLs are kept exactly as GIPHY's API returned them, as its terms require. - */ +/** A GIF picked from GIPHY; URLs kept exactly as its API returned them. */ final readonly class Giphy { public function __construct( diff --git a/app/Rules/GiphyMediaUrl.php b/app/Rules/GiphyMediaUrl.php index fa6ed2bf3..b5dd80215 100644 --- a/app/Rules/GiphyMediaUrl.php +++ b/app/Rules/GiphyMediaUrl.php @@ -8,10 +8,8 @@ use Illuminate\Contracts\Validation\ValidationRule; /** - * GIPHY media URLs are stored and rendered verbatim, so only https URLs on GIPHY's media hosts pointing at a .gif - * rendition are accepted. Query strings stay, as GIPHY's terms forbid stripping them; the path is not required to - * carry the GIF id, since modern media URLs use opaque path tokens instead. Dot-segments (`.` and `..`) are rejected - * so the `/media/` boundary still holds after browsers normalize the URL. + * Only https URLs on GIPHY's media hosts pointing at a .gif rendition. Query strings stay verbatim and dot-segments + * are rejected, so the `/media/` boundary holds after browsers normalize the URL. */ final readonly class GiphyMediaUrl implements ValidationRule { diff --git a/resources/js/components/commit-giphy.tsx b/resources/js/components/commit-giphy.tsx index 81e2f9132..9c609836d 100644 --- a/resources/js/components/commit-giphy.tsx +++ b/resources/js/components/commit-giphy.tsx @@ -1,9 +1,7 @@ import { cn } from '@/lib/utils'; import type { CommitGif } from '@/types/commit'; -/** - * An attached GIPHY GIF, hotlinked exactly as GIPHY's API returned it, with the attribution its terms require. - */ +/** An attached GIPHY GIF, hotlinked verbatim with its required attribution. */ export function CommitGiphy({ gif, className, diff --git a/resources/js/lib/gif-storage.ts b/resources/js/lib/gif-storage.ts index 8ed192aab..82864a89b 100644 --- a/resources/js/lib/gif-storage.ts +++ b/resources/js/lib/gif-storage.ts @@ -3,9 +3,7 @@ import { readStorage, writeStorage } from './storage.ts'; const gifKey = (key: string): string => `${key}:gif`; -/** - * A stashed GIF is tiny JSON in localStorage, unlike stashed images, which are Files in IndexedDB. - */ +/** A stashed GIF is tiny JSON in localStorage; images are Files in IndexedDB. */ export function readStoredGif(key: string): GiphyAttachment | null { const raw = readStorage(gifKey(key)); diff --git a/resources/js/lib/giphy.ts b/resources/js/lib/giphy.ts index 91fa516a3..0b784c31f 100644 --- a/resources/js/lib/giphy.ts +++ b/resources/js/lib/giphy.ts @@ -55,9 +55,7 @@ export function giphyTrendingUrl(key: string, offset = 0): string { return `${baseUrl}/trending?${params(key, offset)}`; } -/** - * GIPHY requires client-side API calls, so this runs in the browser with the shared key. - */ +/** GIPHY requires client-side API calls, so this runs in the browser. */ export async function fetchGifs( url: string, signal?: AbortSignal, @@ -83,10 +81,7 @@ export type GiphyKeyValidation = | 'rate-limited' | 'unreachable'; -/** - * Verifies a pasted key against GIPHY from the browser, so no key ever - * reaches the server for validation. A throttled key is still a real key. - */ +/** Verifies a pasted key from the browser; a throttled key is still real. */ export async function validateGiphyKey( key: string, ): Promise { @@ -109,9 +104,7 @@ export async function validateGiphyKey( return response.status === 429 ? 'rate-limited' : 'unreachable'; } -/** - * The small fixed-height rendition keeps the picker grid light; bigger renditions stand in when it is missing. - */ +/** Small fixed-height rendition for the picker grid. */ export function previewUrlFor(result: GiphyResult): string | null { const rendition = result.images.fixed_height_small ?? @@ -122,9 +115,7 @@ export function previewUrlFor(result: GiphyResult): string | null { return rendition?.url ?? null; } -/** - * The stored rendition is always a .gif around feed size, and its URL is kept verbatim as GIPHY's terms require. - */ +/** Stored rendition: a .gif around feed size, kept verbatim per GIPHY's terms. */ export function attachmentFromResult( result: GiphyResult, ): GiphyAttachment | null { @@ -148,9 +139,7 @@ export function attachmentFromResult( }; } -/** - * The hidden fields every composer submits for an attached GIF, named exactly as the server validates them. - */ +/** Hidden fields every composer submits for an attached GIF. */ export function giphyFormFields( gif: GiphyAttachment, ): Record { From c05477328680c53d170311caa5ab737e0ceb5a02 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sat, 17 Oct 2026 15:47:52 -0400 Subject: [PATCH 34/83] docs: drop the stale giphy production key note --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 34f6768c9..1f417437d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key: connect your own free [GIPHY API key](https://developers.giphy.com) under Settings → Profile → Giphy and every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. Searches run on the connected key and its quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to GIPHY directly and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. Production use needs an approved key, which requires the "Powered By GIPHY" mark the picker already shows. +GIF search is bring-your-own-key: connect your own free [GIPHY API key](https://developers.giphy.com) under Settings → Profile → Giphy and every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. Searches run on the connected key and its quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to GIPHY directly and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. ### Verify installation From db145ac2e79d69efb4c5aa1c27e1473f64f01c1f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Sun, 18 Oct 2026 11:01:49 -0400 Subject: [PATCH 35/83] fix: migrate the giphy inputs off the removed ui/input --- resources/js/components/giphy-key-setting.tsx | 5 +++-- resources/js/components/giphy-picker-dialog.tsx | 6 ++++-- tests/Unit/ComposerDraft.test.tsx | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/resources/js/components/giphy-key-setting.tsx b/resources/js/components/giphy-key-setting.tsx index 2a59b24e9..0743c27bc 100644 --- a/resources/js/components/giphy-key-setting.tsx +++ b/resources/js/components/giphy-key-setting.tsx @@ -5,7 +5,6 @@ import { update, } from '@/actions/App/Http/Controllers/GiphyKeyController'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { validateGiphyKey } from '@/lib/giphy'; @@ -134,12 +133,14 @@ export function GiphyKeySetting({
- setKey(event.target.value)} placeholder="Paste your GIPHY API key" autoComplete="off" + spellCheck={false} + className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" data-test="giphy-key-input" /> +
+ )} + {status === 'ready' && results.length === 0 && ( +

+ No GIFs matched. /dev/null remains empty. +

+ )} + {status === 'ready' && results.length > 0 && ( + <> +
    + {results.map((result) => + result.previewUrl === null ? null : ( +
  • + +
  • + ), + )} +
+ {nextCursor !== null && ( + + )} + + )} + + + + ); +} +``` + +Replace `resources/js/components/gif-picker-button.tsx` with: + +```tsx +import { usePage } from '@inertiajs/react'; +import { ImagePlay } from 'lucide-react'; +import { useState } from 'react'; +import { GifPickerDialog } from '@/components/gif-picker-dialog'; +import { Button } from '@/components/ui/button'; +import { gifProviderFor } from '@/lib/gif-providers'; +import type { GifAttachment } from '@/lib/gif-providers'; +import { cn } from '@/lib/utils'; + +/** Invisible until the signed-in user connects a GIF provider in settings. */ +export function GifPickerButton({ + disabled, + hasGif, + onPick, + className, +}: { + disabled?: boolean; + hasGif: boolean; + onPick: (gif: GifAttachment) => void; + className?: string; +}) { + const { gif } = usePage().props; + const [open, setOpen] = useState(false); + + if (gif === null) { + return null; + } + + return ( + <> + + + + ); +} +``` + +`gif-attachment-preview.tsx`: rename the component, type, and data-test only: + +```tsx +import { X } from 'lucide-react'; +import type { GifAttachment } from '@/lib/gif-providers'; + +export function GifAttachmentPreview({ + gif, + onRemove, +}: { + gif: GifAttachment | null; + onRemove: () => void; +}) { + if (gif === null) { + return null; + } + + return ( +
+
+ {gif.title + + + GIF + +
+
+ ); +} +``` + +Replace `resources/js/components/commit-gif.tsx` attribution: + +```tsx +const provider = gifProviders[gif.provider]; +const credit = + gif.username !== null + ? `GIF by @${gif.username} on ${provider.label}` + : `via ${provider.label}`; +``` + +and use `credit` inside the attribution link (body otherwise unchanged except `data-test="commit-gif"`). + +- [ ] **Step 5: Update the composer, quote, thread, and stash** + +Apply these replacements in `commit-composer.tsx`, `commit-quote-button.tsx`, `commit-thread.tsx`: + +- imports: `GiphyAttachmentPreview` → `GifAttachmentPreview` (path `gif-attachment-preview`); `GiphyPickerButton` → `GifPickerButton`; `CommitGiphy` → `CommitGif` (path `commit-gif`); `giphyFormFields` → `gifFormFields` from `@/lib/gif-providers`; `GiphyAttachment` → `GifAttachment` from `@/lib/gif-providers`. +- JSX component names updated accordingly (``, ``, ``). +- `commit-thread.tsx`: replace the inline gif field type block (`giphy_id?`, …, `giphy_height?`) with `gif_provider?: 'giphy' | 'klipy';` plus `gif_id?`, `gif_url?`, `gif_page_url?`, `gif_title?`, `gif_username?`, `gif_width?`, `gif_height?`. +- `use-stash.ts`: import `GifAttachment` from `@/lib/gif-providers/types` (type-only); replace `GiphyAttachment` references with `GifAttachment` (the `updateGif` API name stays). + +- [ ] **Step 6: Update the tests and configs** + +- `git mv tests/Unit/CommitGiphy.test.tsx tests/Unit/CommitGif.test.tsx`; `git mv tests/Unit/CommitGiphyRender.test.ts tests/Unit/CommitGifRender.test.ts`; `git mv tests/commit-giphy.config.ts tests/commit-gif.config.ts`. +- In `CommitGif.test.tsx`: rename imports/component (`CommitGif` from `commit-gif`); fixture `CommitGif` gains `provider: 'giphy'`; add two attribution cases: + +```tsx +test('credits klipy without a giphy-style username', () => { + const { attribution } = renderedGif({ provider: 'klipy', username: null }); + + assert.equal(attribution.props.children, 'via KLIPY'); +}); +``` + +(Adjust `renderedGif` so the fixture type carries `provider`.) +- `CommitGifRender.test.ts`: config path becomes `tests/commit-gif.config.ts`; update the config include to `tests/Unit/CommitGif.test.tsx`. +- `ComposerDraft.test.tsx` / `QuoteDraft.test.tsx` / `DraftStash.test.tsx`: replace `GiphyAttachment` with `GifAttachment` (+ `provider: 'giphy'` in fixtures) and every expected form field `giphy_*` with `gif_*`, including `gif_provider: 'giphy'`. +- `tests/tsconfig.json`: replace `Unit/CommitGiphy.test.tsx` with `Unit/CommitGif.test.tsx`; replace `commit-giphy.config.ts` with `commit-gif.config.ts`; add `Unit/GifPickerDialog.test.tsx` and `gif-picker.config.ts`. +- Add `'tests/Unit/GifPickerDialog.test.tsx'` to `tests/draft-images.config.ts`? No — the new picker test belongs to `tests/gif-picker.config.ts` only (wrapper runs it from `test:unit`). + +- [ ] **Step 7: Run the tests** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add resources/js/components resources/js/hooks/use-stash.ts resources/js/lib/gif-providers tests/Unit tests/commit-gif.config.ts tests/gif-picker.config.ts tests/tsconfig.json +git commit -m "feat: run the gif picker through the provider interface" +``` + +--- + +### Task 7: The "GIFs" settings section + +**Files:** +- Rename+rewrite: `resources/js/components/giphy-key-setting.tsx` → `gif-key-setting.tsx` +- Modify: `resources/js/pages/user-profile/edit.tsx`, `README.md` +- Test: rename `tests/Unit/GiphyKeySetting.test.tsx` → `tests/Unit/GifKeySetting.test.tsx`, `tests/Unit/GiphyKeySettingRender.test.ts` → `tests/Unit/GifKeySettingRender.test.ts`, `tests/giphy-key.config.ts` → `tests/gif-key.config.ts`; update `tests/tsconfig.json` + +**Interfaces:** +- Consumes: generated `GifKeyController` actions (`update.url()`, `destroy.url()` → `/settings/gif`), `gifProviders`/`gifProviderFor`, page props `gifConnected: boolean`, `gifProvider: 'giphy' | 'klipy' | null`. +- Produces: `GifKeySetting({ gifConnected, gifProvider })` rendered on the profile page as section "GIFs". + +- [ ] **Step 1: Rename the test and write the failing expectations** + +```bash +git mv resources/js/components/giphy-key-setting.tsx resources/js/components/gif-key-setting.tsx +git mv tests/Unit/GiphyKeySetting.test.tsx tests/Unit/GifKeySetting.test.tsx +git mv tests/Unit/GiphyKeySettingRender.test.ts tests/Unit/GifKeySettingRender.test.ts +git mv tests/giphy-key.config.ts tests/gif-key.config.ts +``` + +In `tests/gif-key.config.ts` include `tests/Unit/GifKeySetting.test.tsx`. In `GifKeySettingRender.test.ts` use `--config tests/gif-key.config.ts` and the new test name. + +Update `GifKeySetting.test.tsx`: +- Mock `@inertiajs/react` router as before; the component is invoked with `{ gifConnected: false, gifProvider: null }` or `{ gifConnected: true, gifProvider: 'giphy' }`. +- The connect flow sends `{ provider: 'giphy', key: 'key-123' }`; after selecting KLIPY (click the provider button with `data-test="provider-klipy"`) the flow sends `{ provider: 'klipy', key: 'key-123' }`. +- The validation error copy becomes provider-agnostic: `'That key was rejected by KLIPY.'` when KLIPY is selected, `'That key was rejected by GIPHY.'` for GIPHY; unreachable similarly. +- Add: + +```tsx +test('replaces the connected provider when another is chosen', async () => { + stubFetch(200); + + let fields = render({ gifConnected: true, gifProvider: 'giphy' }); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + + fields = render({ gifConnected: true, gifProvider: 'giphy' }); + (field(fields, 'provider-klipy').props.onClick as () => void)(); + + fields = render({ gifConnected: true, gifProvider: 'giphy' }); + field(fields, 'gif-key-input').props.onChange({ target: { value: 'klipy-key' } }); + + fields = render({ gifConnected: true, gifProvider: 'giphy' }); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(put.mock.calls[0]?.[1], { provider: 'klipy', key: 'klipy-key' }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun run test:unit` +Expected: FAIL — `GifKeySetting` does not exist. + +- [ ] **Step 3: Implement the settings component** + +Replace `resources/js/components/gif-key-setting.tsx` with: + +```tsx +import { router } from '@inertiajs/react'; +import { useState } from 'react'; +import { + destroy, + update, +} from '@/actions/App/Http/Controllers/GifKeyController'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; + +const helpHref: Record = { + giphy: 'https://developers.giphy.com', + klipy: 'https://partner.klipy.com', +}; + +export function GifKeySetting({ + gifConnected, + gifProvider, +}: { + gifConnected: boolean; + gifProvider: GifProviderId | null; +}) { + const [providerId, setProviderId] = useState( + gifProvider ?? 'giphy', + ); + const [key, setKey] = useState(''); + const [editing, setEditing] = useState(!gifConnected); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const provider = gifProviderFor(providerId); + + const connect = async () => { + if (processing) { + return; + } + + const candidate = key.trim(); + + if (candidate === '') { + return; + } + + setProcessing(true); + setError(null); + + const verdict = await provider.validateKey(candidate); + + if (verdict === 'invalid') { + setError(`That key was rejected by ${provider.label}.`); + setProcessing(false); + + return; + } + + if (verdict === 'unreachable') { + setError(`Couldn't reach ${provider.label} to verify the key.`); + setProcessing(false); + + return; + } + + router.put( + update.url(), + { provider: providerId, key: candidate }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onError: (errors) => + setError( + errors.key ?? + errors.provider ?? + 'Could not save the key.', + ), + onSuccess: () => { + setKey(''); + setError(null); + setEditing(false); + }, + }, + ); + }; + + const remove = () => { + if (processing) { + return; + } + + setError(null); + + router.delete(destroy.url(), { + preserveScroll: true, + onStart: () => setProcessing(true), + onFinish: () => setProcessing(false), + onError: () => setError('Could not remove the provider.'), + }); + }; + + return ( +
+

+ GIFs +

+

+ GIF search runs on your own {provider.label} API key, under your + own quota. Get a key at{' '} + + {provider.label} + + , connect it here, and every composer gains a GIF picker. One + provider at a time; without a key the picker stays hidden. +

+ {gifConnected && !editing ? ( +
+

+ Connected to {gifProviderFor(gifProvider ?? 'giphy').label}. +

+ + +
+ ) : ( +
+
+ {Object.values(gifProviders).map((candidate) => ( + + ))} +
+ +
+ setKey(event.target.value)} + placeholder={`Paste your ${provider.label} API key`} + autoComplete="off" + spellCheck={false} + className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" + data-test="gif-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} +
+ ); +} +``` + +- [ ] **Step 4: Wire the profile page and README** + +`resources/js/pages/user-profile/edit.tsx`: import `GifKeySetting`; page props gain `gifProvider: 'giphy' | 'klipy' | null`; render `` after ``. + +`README.md` GIFs paragraph: replace the GIPHY-only text with: + +```markdown +GIF search is bring-your-own-key: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. +``` + +- [ ] **Step 5: Update the test configs** + +- `tests/tsconfig.json`: replace `Unit/GiphyKeySetting.test.tsx` with `Unit/GifKeySetting.test.tsx`; replace `giphy-key.config.ts` with `gif-key.config.ts`. +- Delete remaining references to `giphy-key.config.ts` (e.g. any include lists). + +- [ ] **Step 6: Run everything** + +Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php && bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add resources/js/components/gif-key-setting.tsx resources/js/pages/user-profile/edit.tsx README.md tests/Unit/GifKeySetting.test.tsx tests/Unit/GifKeySettingRender.test.ts tests/gif-key.config.ts tests/tsconfig.json +git commit -m "feat: choose the gif provider in profile settings" +``` + +--- + +### Task 8: Sweep, regenerate, and full gates + +**Files:** +- Modify: any file still using legacy `giphy*` naming (verify with the search below) +- Test: full suites + +**Interfaces:** +- Consumes: everything above. +- Produces: a branch with no stale GIPHY-only naming beyond the provider adapter/labels, green gates. + +- [ ] **Step 1: Sweep for leftovers** + +Run: + +```bash +rg -n "giphy|Giphy|GIPHY" app resources tests routes database README.md +``` + +Expected remaining matches only: +- `app/Enums/GifProvider.php` (GIPHY case/labels/hosts) +- `resources/js/lib/gif-providers/giphy.ts` (adapter) +- `resources/js/components/gif-key-setting.tsx` / `gif-picker-dialog.tsx` (labels/help copy come from adapters) +- test fixtures for the GIPHY adapter and provider labels. + +Rename anything else (`GiphyUploader`, `giphyConnected`, `withGiphy`, `giphy-key.*` route names, etc.) to the gif equivalents. + +- [ ] **Step 2: Regenerate generated routes and assets** + +```bash +php artisan wayfinder:generate --with-form +bun run build +``` + +- [ ] **Step 3: Run the full gates** + +```bash +composer test:lint +composer test:types +composer test:type-coverage +php -d memory_limit=-1 vendor/bin/pest --fresh +bun run test:unit +bun run test:types +bun run test:lint +``` + +Expected: all PASS. On machines without a PHP coverage driver, run the Pest suite without `--coverage` (CI enforces `--exactly=100.0`); note it in the PR. + +- [ ] **Step 4: Commit residual fixes (only if any)** + +```bash +git add -A +git commit -m "chore: sweep legacy giphy naming and satisfy the gates" +``` From 508c878501500473a62203a47fbee216dc34050b Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:11:06 -0400 Subject: [PATCH 38/83] feat: generalize gif storage across providers --- app/Enums/GifProvider.php | 41 ++++++++++++ app/Models/Commit.php | 67 ++++++++++--------- app/Models/User.php | 13 ++-- app/Queries/CommitBuilder.php | 2 +- database/factories/CommitFactory.php | 33 ++++++--- database/factories/UserFactory.php | 6 +- ..._10_17_024509_add_gif_to_commits_table.php | 24 +++++++ ...0_17_024509_add_giphy_to_commits_table.php | 23 ------- ..._17_133426_add_gif_key_to_users_table.php} | 3 +- tests/Unit/Enums/GifProviderTest.php | 40 +++++++++++ tests/Unit/Models/CommitTest.php | 20 +++++- tests/Unit/Models/UserTest.php | 34 ++++++---- 12 files changed, 221 insertions(+), 85 deletions(-) create mode 100644 app/Enums/GifProvider.php create mode 100644 database/migrations/2026_10_17_024509_add_gif_to_commits_table.php delete mode 100644 database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php rename database/migrations/{2026_10_17_133426_add_giphy_key_to_users_table.php => 2026_10_17_133426_add_gif_key_to_users_table.php} (74%) create mode 100644 tests/Unit/Enums/GifProviderTest.php diff --git a/app/Enums/GifProvider.php b/app/Enums/GifProvider.php new file mode 100644 index 000000000..7c0738cf8 --- /dev/null +++ b/app/Enums/GifProvider.php @@ -0,0 +1,41 @@ + 'GIPHY', + self::Klipy => 'KLIPY', + }; + } + + /** + * @return list + */ + public function mediaHostPatterns(): array + { + return match ($this) { + self::Giphy => ['/^media\d*\.giphy\.com$/'], + self::Klipy => ['/^static\d*\.klipy\.com$/'], + }; + } + + /** + * @return list + */ + public function pageHostPatterns(): array + { + return match ($this) { + self::Giphy => ['/^(www\.)?giphy\.com$/'], + self::Klipy => ['/^(www\.)?klipy\.com$/'], + }; + } +} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 01794315e..d2edbba3e 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Queries\CommitBuilder; use App\Services\CommitBodies; use Carbon\CarbonInterface; @@ -36,13 +37,14 @@ * @property-read CommitStatus|null $ai_decision * @property-read list|null $ai_categories * @property-read list|null $embedding - * @property-read string|null $giphy_id - * @property-read string|null $giphy_url - * @property-read string|null $giphy_page_url - * @property-read string|null $giphy_title - * @property-read string|null $giphy_username - * @property-read int|null $giphy_width - * @property-read int|null $giphy_height + * @property-read GifProvider|null $gif_provider + * @property-read string|null $gif_id + * @property-read string|null $gif_url + * @property-read string|null $gif_page_url + * @property-read string|null $gif_title + * @property-read string|null $gif_username + * @property-read int|null $gif_width + * @property-read int|null $gif_height * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read CarbonInterface|null $deleted_at @@ -67,13 +69,14 @@ 'ai_decision', 'ai_categories', 'embedding', - 'giphy_id', - 'giphy_url', - 'giphy_page_url', - 'giphy_title', - 'giphy_username', - 'giphy_width', - 'giphy_height', + 'gif_provider', + 'gif_id', + 'gif_url', + 'gif_page_url', + 'gif_title', + 'gif_username', + 'gif_width', + 'gif_height', ])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model @@ -130,13 +133,14 @@ public function casts(): array 'ai_decision' => CommitStatus::class, 'ai_categories' => 'array', 'embedding' => AsVector::class, - 'giphy_id' => 'string', - 'giphy_url' => 'string', - 'giphy_page_url' => 'string', - 'giphy_title' => 'string', - 'giphy_username' => 'string', - 'giphy_width' => 'integer', - 'giphy_height' => 'integer', + 'gif_provider' => GifProvider::class, + 'gif_id' => 'string', + 'gif_url' => 'string', + 'gif_page_url' => 'string', + 'gif_title' => 'string', + 'gif_username' => 'string', + 'gif_width' => 'integer', + 'gif_height' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -296,24 +300,25 @@ public function imagesAreVisibleTo(?User $user): bool } /** - * The attached GIPHY GIF, hotlinked by its media URL exactly as GIPHY's API returned it. + * The attached GIF, hotlinked by its media URL exactly as the provider returned it. * - * @return array{id: string, url: string, page_url: string, title: string|null, username: string|null, width: int|null, height: int|null}|null + * @return array{provider: string, id: string, url: string, page_url: string, title: string|null, username: string|null, width: int|null, height: int|null}|null */ public function gif(): ?array { - if ($this->giphy_id === null || $this->giphy_url === null || $this->giphy_page_url === null) { + if ($this->gif_id === null || $this->gif_url === null || $this->gif_page_url === null || $this->gif_provider === null) { return null; } return [ - 'id' => $this->giphy_id, - 'url' => $this->giphy_url, - 'page_url' => $this->giphy_page_url, - 'title' => $this->giphy_title, - 'username' => $this->giphy_username, - 'width' => $this->giphy_width, - 'height' => $this->giphy_height, + 'provider' => $this->gif_provider->value, + 'id' => $this->gif_id, + 'url' => $this->gif_url, + 'page_url' => $this->gif_page_url, + 'title' => $this->gif_title, + 'username' => $this->gif_username, + 'width' => $this->gif_width, + 'height' => $this->gif_height, ]; } diff --git a/app/Models/User.php b/app/Models/User.php index f2e5a0264..940339c8c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Actions\CreateTomFriendship; +use App\Enums\GifProvider; use App\Enums\Log; use App\Enums\RemotesFrom; use Carbon\CarbonInterface; @@ -46,7 +47,8 @@ * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at * @property-read string|null $remember_token - * @property-read string|null $giphy_key + * @property-read GifProvider|null $gif_provider + * @property-read string|null $gif_key * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -70,7 +72,8 @@ 'github_id', 'email', 'remember_token', - 'giphy_key', + 'gif_provider', + 'gif_key', 'preferred_lane', 'lane_color', 'pasteis_mode', @@ -107,7 +110,8 @@ final class User extends Authenticatable 'pasteis_received' => 0, 'pasteis_given_today' => 0, 'pasteis_given_on' => null, - 'giphy_key' => null, + 'gif_provider' => null, + 'gif_key' => null, ]; /** @@ -142,7 +146,8 @@ public function casts(): array 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', 'remember_token' => 'string', - 'giphy_key' => 'encrypted', + 'gif_provider' => GifProvider::class, + 'gif_key' => 'encrypted', 'github_synced_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 43c0520cb..d15ff61ee 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -28,7 +28,7 @@ final class CommitBuilder extends Builder private const array COLUMNS_BUT_EMBEDDING = [ 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', - 'giphy_id', 'giphy_url', 'giphy_page_url', 'giphy_title', 'giphy_username', 'giphy_width', 'giphy_height', + 'gif_provider', 'gif_id', 'gif_url', 'gif_page_url', 'gif_title', 'gif_username', 'gif_width', 'gif_height', ]; /** diff --git a/database/factories/CommitFactory.php b/database/factories/CommitFactory.php index 322d4e2f6..e6e4299a6 100644 --- a/database/factories/CommitFactory.php +++ b/database/factories/CommitFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Models\Commit; use App\Models\User; use App\Services\TheAlgorithm; @@ -58,16 +59,32 @@ public function rejected(): self ]); } - public function withGiphy(): self + public function withGif(GifProvider $provider = GifProvider::Giphy): self { + $gif = match ($provider) { + GifProvider::Giphy => [ + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_title' => 'Deal With It GIF', + 'gif_username' => 'giphy', + 'gif_width' => 480, + 'gif_height' => 270, + ], + GifProvider::Klipy => [ + 'gif_id' => 'hello-hi-662', + 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', + 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'gif_title' => 'Hello', + 'gif_username' => null, + 'gif_width' => 498, + 'gif_height' => 498, + ], + }; + return $this->state(fn (array $attributes): array => [ - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_title' => 'Deal With It GIF', - 'giphy_username' => 'giphy', - 'giphy_width' => 480, - 'giphy_height' => 270, + 'gif_provider' => $provider->value, + ...$gif, ]); } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 176659663..d76ff6142 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\GifProvider; use App\Enums\RemotesFrom; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -61,10 +62,11 @@ public function withoutProfile(): self ]); } - public function withGiphyKey(string $key = 'giphy-test-key'): self + public function withGifKey(GifProvider $provider = GifProvider::Giphy, string $key = 'gif-test-key'): self { return $this->state(fn (array $attributes): array => [ - 'giphy_key' => $key, + 'gif_provider' => $provider->value, + 'gif_key' => $key, ]); } diff --git a/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php b/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php new file mode 100644 index 000000000..596e0b025 --- /dev/null +++ b/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php @@ -0,0 +1,24 @@ +string('gif_provider', 16)->nullable(); + $table->string('gif_id', 64)->nullable(); + $table->text('gif_url')->nullable(); + $table->text('gif_page_url')->nullable(); + $table->string('gif_title', 300)->nullable(); + $table->string('gif_username', 100)->nullable(); + $table->unsignedSmallInteger('gif_width')->nullable(); + $table->unsignedSmallInteger('gif_height')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php b/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php deleted file mode 100644 index 48598ea47..000000000 --- a/database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php +++ /dev/null @@ -1,23 +0,0 @@ -string('giphy_id', 64)->nullable(); - $table->text('giphy_url')->nullable(); - $table->text('giphy_page_url')->nullable(); - $table->string('giphy_title', 300)->nullable(); - $table->string('giphy_username', 100)->nullable(); - $table->unsignedSmallInteger('giphy_width')->nullable(); - $table->unsignedSmallInteger('giphy_height')->nullable(); - }); - } -}; diff --git a/database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php b/database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php similarity index 74% rename from database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php rename to database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php index 4e9143272..d531fdca6 100644 --- a/database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php +++ b/database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php @@ -11,7 +11,8 @@ public function up(): void { Schema::table('users', function (Blueprint $table): void { - $table->text('giphy_key')->nullable(); + $table->string('gif_provider', 16)->nullable(); + $table->text('gif_key')->nullable(); }); } }; diff --git a/tests/Unit/Enums/GifProviderTest.php b/tests/Unit/Enums/GifProviderTest.php new file mode 100644 index 000000000..a604d291d --- /dev/null +++ b/tests/Unit/Enums/GifProviderTest.php @@ -0,0 +1,40 @@ +label())->toBe('GIPHY') + ->and(GifProvider::Klipy->label())->toBe('KLIPY'); +}); + +it('accepts only its own media hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->mediaHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy media' => [GifProvider::Giphy, 'media2.giphy.com', true], + 'giphy api host' => [GifProvider::Giphy, 'api.giphy.com', false], + 'klipy static' => [GifProvider::Klipy, 'static.klipy.com', true], + 'klipy numbered static' => [GifProvider::Klipy, 'static2.klipy.com', true], + 'klipy api host' => [GifProvider::Klipy, 'api.klipy.com', false], +]); + +it('accepts only its own page hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->pageHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy page' => [GifProvider::Giphy, 'giphy.com', true], + 'giphy www page' => [GifProvider::Giphy, 'www.giphy.com', true], + 'klipy page' => [GifProvider::Klipy, 'klipy.com', true], + 'klipy www page' => [GifProvider::Klipy, 'www.klipy.com', true], + 'giphy page against klipy' => [GifProvider::Klipy, 'giphy.com', false], +]); diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 9b724dc90..2e9b3ec5e 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -4,6 +4,7 @@ use App\Actions\DeleteUser; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; @@ -439,11 +440,12 @@ }); it('serializes an attached gif as one object and hides the raw columns', function (): void { - $commit = Commit::factory()->withGiphy()->create(); + $commit = Commit::factory()->withGif()->create(); $payload = $commit->toArray(); expect($payload['gif'])->toBe([ + 'provider' => 'giphy', 'id' => 'xT9IgzoKnwFNmISR8I', 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', @@ -451,7 +453,19 @@ 'username' => 'giphy', 'width' => 480, 'height' => 270, - ])->and(array_keys($payload))->each->not->toStartWith('giphy_'); + ])->and(array_keys($payload))->each->not->toStartWith('gif_'); +}); + +it('serializes the klipy provider too', function (): void { + $commit = Commit::factory()->withGif(GifProvider::Klipy)->create(); + + expect($commit->toArray()['gif'])->toMatchArray([ + 'provider' => 'klipy', + 'id' => 'hello-hi-662', + 'url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', + 'page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'username' => null, + ]); }); it('serializes the gif of a plain commit as null', function (): void { @@ -459,7 +473,7 @@ }); it('leaves the gif out of a tombstone', function (): void { - $commit = Commit::factory()->withGiphy()->create(); + $commit = Commit::factory()->withGif()->create(); $commit->delete(); expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index c8681c989..3844a23fe 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -242,23 +242,33 @@ ->and($other->blockers->modelKeys())->toBe([$user->id]); }); -it('stores the giphy key encrypted and reads it back', function (): void { - $user = User::factory()->withGiphyKey('giphy-test-key')->create(); +it('stores the gif key encrypted and reads it back', function (): void { + $user = User::factory()->withGifKey(key: 'gif-test-key')->create(); - expect($user->refresh()->giphy_key)->toBe('giphy-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) + expect($user->refresh()->gif_key)->toBe('gif-test-key') + ->and(DB::table('users')->where('id', $user->id)->value('gif_key')) ->toBeString() - ->not->toBe('giphy-test-key') - ->not->toContain('giphy-test-key'); + ->not->toBe('gif-test-key') + ->not->toContain('gif-test-key'); }); -it('defaults the giphy key to null', function (): void { - expect(User::factory()->create()->refresh()->giphy_key)->toBeNull(); +it('defaults the gif key to null', function (): void { + expect(User::factory()->create()->refresh()->gif_key)->toBeNull(); }); -it('serializes no giphy key', function (): void { - $user = User::factory()->withGiphyKey()->create(); +it('defaults the gif provider to null', function (): void { + $user = User::factory()->create()->refresh(); + + expect($user->gif_key)->toBeNull() + ->and($user->gif_provider)->toBeNull() + ->and(DB::table('users')->where('id', $user->id)->value('gif_provider'))->toBeNull(); +}); + +it('serializes no gif key or provider', function (): void { + $user = User::factory()->withGifKey()->create(); - expect($user->toArray())->not->toHaveKey('giphy_key') - ->and($user->jsonSerialize())->not->toHaveKey('giphy_key'); + expect($user->toArray())->not->toHaveKey('gif_key') + ->not->toHaveKey('gif_provider') + ->and($user->jsonSerialize())->not->toHaveKey('gif_key') + ->not->toHaveKey('gif_provider'); }); From a762dd19e17910ca24cec3166ce894155c114e4d Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:16:23 -0400 Subject: [PATCH 39/83] test: cover a gif without a provider --- tests/Unit/Models/CommitTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 2e9b3ec5e..76a30a064 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -478,3 +478,14 @@ expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); }); + +it('serializes no gif when the provider is missing', function (): void { + $commit = Commit::factory()->create([ + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/xT9IgzoKnwFNmISR8I', + 'gif_provider' => null, + ]); + + expect($commit->toArray()['gif'])->toBeNull(); +}); From 962fcbde044d500f4fdb1d0d004cc925cec8c4e7 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:27:24 -0400 Subject: [PATCH 40/83] feat: validate gifs against the selected provider --- app/Actions/CreateCommit.php | 8 +- app/Dto/Gif.php | 41 ++++++ app/Dto/Giphy.php | 37 ----- app/Http/Controllers/CommitController.php | 2 +- .../Controllers/CommitReplyController.php | 2 +- .../Requests/CreateCommitReplyRequest.php | 46 ++++--- app/Http/Requests/CreateCommitRequest.php | 47 ++++--- .../{GiphyMediaUrl.php => GifMediaUrl.php} | 20 ++- app/Rules/GifPageUrl.php | 38 ++++++ .../Controllers/CommitControllerTest.php | 128 +++++++++++++----- .../Controllers/CommitReplyControllerTest.php | 75 +++++++--- tests/Unit/Rules/GifMediaUrlTest.php | 38 ++++++ tests/Unit/Rules/GifPageUrlTest.php | 25 ++++ 13 files changed, 363 insertions(+), 144 deletions(-) create mode 100644 app/Dto/Gif.php delete mode 100644 app/Dto/Giphy.php rename app/Rules/{GiphyMediaUrl.php => GifMediaUrl.php} (50%) create mode 100644 app/Rules/GifPageUrl.php create mode 100644 tests/Unit/Rules/GifMediaUrlTest.php create mode 100644 tests/Unit/Rules/GifPageUrlTest.php diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 61364277b..c6927aaf6 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -4,7 +4,7 @@ namespace App\Actions; -use App\Dto\Giphy; +use App\Dto\Gif; use App\Dto\NewPoll; use App\Enums\CommitStatus; use App\Jobs\EmbedCommit; @@ -44,7 +44,7 @@ public function __construct( * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?Giphy $giphy = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?Gif $gif = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -52,7 +52,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $giphy): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $gif): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -67,7 +67,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'quoted_commit_id' => $quotedCommit?->id, 'hot_score' => $this->theAlgorithm->score($createdAt, 0, 0), 'created_at' => $createdAt, - ...$giphy?->toColumns() ?? [], + ...$gif?->toColumns() ?? [], ]); $this->createCommitImages->handle($commit, $images, $imageDescriptions); diff --git a/app/Dto/Gif.php b/app/Dto/Gif.php new file mode 100644 index 000000000..6a5d37050 --- /dev/null +++ b/app/Dto/Gif.php @@ -0,0 +1,41 @@ + + */ + public function toColumns(): array + { + return [ + 'gif_provider' => $this->provider->value, + 'gif_id' => $this->id, + 'gif_url' => $this->url, + 'gif_page_url' => $this->pageUrl, + 'gif_title' => $this->title, + 'gif_username' => $this->username, + 'gif_width' => $this->width, + 'gif_height' => $this->height, + ]; + } +} diff --git a/app/Dto/Giphy.php b/app/Dto/Giphy.php deleted file mode 100644 index df8e99c81..000000000 --- a/app/Dto/Giphy.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ - public function toColumns(): array - { - return [ - 'giphy_id' => $this->id, - 'giphy_url' => $this->url, - 'giphy_page_url' => $this->pageUrl, - 'giphy_title' => $this->title, - 'giphy_username' => $this->username, - 'giphy_width' => $this->width, - 'giphy_height' => $this->height, - ]; - } -} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 8ba80d337..5817d4243 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -63,7 +63,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): RedirectResponse { - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), giphy: $request->giphy()); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), gif: $request->gif()); Inertia::flash('committed', [ 'id' => $commit->id, diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index b06027c27..fa6b235d9 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -26,7 +26,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), giphy: $request->giphy()); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), gif: $request->gif()); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index 845bd9026..f1fcca0f0 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -5,10 +5,12 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; -use App\Dto\Giphy; +use App\Dto\Gif; +use App\Enums\GifProvider; use App\Models\Commit; use App\Models\User; -use App\Rules\GiphyMediaUrl; +use App\Rules\GifMediaUrl; +use App\Rules\GifPageUrl; use App\Services\Urls; use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\ValidationRule; @@ -16,6 +18,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Dimensions; +use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\In; @@ -40,11 +43,12 @@ public function prepareForValidation(): void /** * Images are checked by their contents, not their extension, and SVG is never accepted since it can carry scripts. * - * @return array> + * @return array> */ public function rules(): array { $body = $this->input('body'); + $provider = GifProvider::tryFrom($this->string('gif_provider')->value()) ?? GifProvider::Giphy; return [ 'body' => $this->hasFile('images') ? CreateCommitRequest::BODY_WITH_IMAGES_RULES : CreateCommitRequest::BODY_RULES, @@ -60,13 +64,14 @@ public function rules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], - 'giphy_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images', 'required_with:giphy_url'], - 'giphy_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', new GiphyMediaUrl], - 'giphy_page_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', 'url:https', 'regex:#^https://(www\.)?giphy\.com/#'], - 'giphy_title' => ['nullable', 'string', 'max:300'], - 'giphy_username' => ['nullable', 'string', 'max:100'], - 'giphy_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], - 'giphy_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], + 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], + 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], + 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], + 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifPageUrl($provider)], + 'gif_title' => ['nullable', 'string', 'max:300'], + 'gif_username' => ['nullable', 'string', 'max:100'], + 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], + 'gif_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], ]; } @@ -123,20 +128,21 @@ public function imageDescriptions(): array return $descriptions; } - public function giphy(): ?Giphy + public function gif(): ?Gif { - if (! $this->filled('giphy_id')) { + if (! $this->filled('gif_id')) { return null; } - return new Giphy( - $this->string('giphy_id')->value(), - $this->string('giphy_url')->value(), - $this->string('giphy_page_url')->value(), - $this->filled('giphy_title') ? $this->string('giphy_title')->value() : null, - $this->filled('giphy_username') ? $this->string('giphy_username')->value() : null, - $this->integer('giphy_width'), - $this->integer('giphy_height'), + return new Gif( + GifProvider::from($this->string('gif_provider')->value()), + $this->string('gif_id')->value(), + $this->string('gif_url')->value(), + $this->string('gif_page_url')->value(), + $this->filled('gif_title') ? $this->string('gif_title')->value() : null, + $this->filled('gif_username') ? $this->string('gif_username')->value() : null, + $this->integer('gif_width'), + $this->integer('gif_height'), ); } } diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index e5acd12d3..2be311a27 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -5,12 +5,14 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; -use App\Dto\Giphy; +use App\Dto\Gif; use App\Dto\NewPoll; +use App\Enums\GifProvider; use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; -use App\Rules\GiphyMediaUrl; +use App\Rules\GifMediaUrl; +use App\Rules\GifPageUrl; use App\Rules\VisibleCommit; use App\Services\CommitBodies; use App\Services\Urls; @@ -47,7 +49,7 @@ public static function normalizeBody(FormRequest $request): void * * @return array> */ - public static function attachmentRules(): array + public static function attachmentRules(GifProvider $provider = GifProvider::Giphy): array { return [ 'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id,images'], @@ -65,13 +67,14 @@ public static function attachmentRules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], - 'giphy_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images,poll', 'required_with:giphy_url'], - 'giphy_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', new GiphyMediaUrl], - 'giphy_page_url' => ['nullable', 'string', 'max:2048', 'required_with:giphy_id', 'url:https', 'regex:#^https://(www\.)?giphy\.com/#'], - 'giphy_title' => ['nullable', 'string', 'max:300'], - 'giphy_username' => ['nullable', 'string', 'max:100'], - 'giphy_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], - 'giphy_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:giphy_id'], + 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], + 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], + 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], + 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifPageUrl($provider)], + 'gif_title' => ['nullable', 'string', 'max:300'], + 'gif_username' => ['nullable', 'string', 'max:100'], + 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], + 'gif_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], ]; } @@ -88,12 +91,13 @@ public function rules(): array $user = $this->user(); assert($user instanceof User); $body = $this->input('body'); + $provider = GifProvider::tryFrom($this->string('gif_provider')->value()) ?? GifProvider::Giphy; return [ 'body' => $this->hasFile('images') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES, 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], - ...self::attachmentRules(), + ...self::attachmentRules($provider), ]; } @@ -172,20 +176,21 @@ public function quotedCommit(): ?Commit : null; } - public function giphy(): ?Giphy + public function gif(): ?Gif { - if (! $this->filled('giphy_id')) { + if (! $this->filled('gif_id')) { return null; } - return new Giphy( - $this->string('giphy_id')->value(), - $this->string('giphy_url')->value(), - $this->string('giphy_page_url')->value(), - $this->filled('giphy_title') ? $this->string('giphy_title')->value() : null, - $this->filled('giphy_username') ? $this->string('giphy_username')->value() : null, - $this->integer('giphy_width'), - $this->integer('giphy_height'), + return new Gif( + GifProvider::from($this->string('gif_provider')->value()), + $this->string('gif_id')->value(), + $this->string('gif_url')->value(), + $this->string('gif_page_url')->value(), + $this->filled('gif_title') ? $this->string('gif_title')->value() : null, + $this->filled('gif_username') ? $this->string('gif_username')->value() : null, + $this->integer('gif_width'), + $this->integer('gif_height'), ); } diff --git a/app/Rules/GiphyMediaUrl.php b/app/Rules/GifMediaUrl.php similarity index 50% rename from app/Rules/GiphyMediaUrl.php rename to app/Rules/GifMediaUrl.php index b5dd80215..22b6a6a5a 100644 --- a/app/Rules/GiphyMediaUrl.php +++ b/app/Rules/GifMediaUrl.php @@ -4,27 +4,37 @@ namespace App\Rules; +use App\Enums\GifProvider; use Closure; use Illuminate\Contracts\Validation\ValidationRule; /** - * Only https URLs on GIPHY's media hosts pointing at a .gif rendition. Query strings stay verbatim and dot-segments - * are rejected, so the `/media/` boundary holds after browsers normalize the URL. + * Only https URLs on the provider's media hosts pointing at a .gif rendition. Query strings stay verbatim and + * dot-segments are rejected, so the host boundary holds after browsers normalize the URL. */ -final readonly class GiphyMediaUrl implements ValidationRule +final readonly class GifMediaUrl implements ValidationRule { + public function __construct(private GifProvider $provider) + { + // + } + public function validate(string $attribute, mixed $value, Closure $fail): void { $parts = is_string($value) ? parse_url($value) : false; + $host = is_array($parts) ? ($parts['host'] ?? '') : ''; $isValid = is_array($parts) && ($parts['scheme'] ?? '') === 'https' - && preg_match('/^media\d*\.giphy\.com$/', $parts['host'] ?? '') === 1 + && array_filter( + $this->provider->mediaHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ) !== [] && ! isset($parts['fragment']) && ! isset($parts['port']) && ! isset($parts['user']) && ! isset($parts['pass']) - && preg_match('#^/media/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 + && preg_match('#^/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1; if (! $isValid) { diff --git a/app/Rules/GifPageUrl.php b/app/Rules/GifPageUrl.php new file mode 100644 index 000000000..eb47dffba --- /dev/null +++ b/app/Rules/GifPageUrl.php @@ -0,0 +1,38 @@ +provider->pageHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ) !== [] + && ! isset($parts['port']) + && ! isset($parts['user']) + && ! isset($parts['pass']); + + if (! $isValid) { + $fail(__('The GIF could not be attached.')); + } + } +} diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 72985a11c..36ef3982a 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1437,21 +1437,71 @@ $response = $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Shipping it', - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_title' => 'Deal With It GIF', - 'giphy_username' => 'giphy', - 'giphy_width' => 480, - 'giphy_height' => 270, + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_title' => 'Deal With It GIF', + 'gif_username' => 'giphy', + 'gif_width' => 480, + 'gif_height' => 270, ]); $response->assertSessionHasNoErrors(); $commit = $user->commits()->sole(); - expect($commit->giphy_id)->toBe('xT9IgzoKnwFNmISR8I') - ->and($commit->giphy_url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') - ->and($commit->giphy_width)->toBe(480); + expect($commit->gif_id)->toBe('xT9IgzoKnwFNmISR8I') + ->and($commit->gif_url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') + ->and($commit->gif_width)->toBe(480); +}); + +it('accepts a klipy gif', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Klipy time', + 'gif_provider' => 'klipy', + 'gif_id' => 'hello-hi-662', + 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif', + 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'gif_width' => 498, + 'gif_height' => 498, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); +}); + +it('rejects a gif url from another provider', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit.store'), [ + 'body' => 'Mismatch', + 'gif_provider' => 'klipy', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://klipy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); +}); + +it('rejects a gif without a provider', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit.store'), [ + 'body' => 'No provider', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); }); it('rejects a gif media url that is not on giphy', function (string $url): void { @@ -1459,12 +1509,13 @@ $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Nice try', - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => $url, - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, - ])->assertSessionHasErrors('giphy_url'); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => $url, + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_url'); expect($user->commits()->count())->toBe(0); })->with([ @@ -1472,7 +1523,6 @@ 'lookalike host' => ['https://media2.giphy.com.evil.example/media/x/giphy.gif'], 'plain http' => ['http://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], 'not a gif rendition' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.webp'], - 'outside the media path' => ['https://media2.giphy.com/avatars/giphy.gif'], 'with a fragment' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif#frag'], 'with userinfo' => ['https://user@media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], 'with a port' => ['https://media2.giphy.com:8443/media/xT9IgzoKnwFNmISR8I/giphy.gif'], @@ -1485,8 +1535,9 @@ $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Half a gif', - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - ])->assertSessionHasErrors(['giphy_url', 'giphy_page_url', 'giphy_width', 'giphy_height']); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['gif_url', 'gif_page_url', 'gif_width', 'gif_height']); }); it('rejects a gif alongside uploaded images', function (): void { @@ -1497,12 +1548,13 @@ $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Both at once', 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, - ])->assertSessionHasErrors('giphy_id'); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); }); it('rejects a gif alongside a poll', function (): void { @@ -1511,12 +1563,13 @@ $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Gif or poll', 'poll' => ['options' => ['Yes', 'No'], 'duration' => 1440], - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, - ])->assertSessionHasErrors('giphy_id'); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); }); it('may quote a commit with a gif attached', function (): void { @@ -1526,11 +1579,12 @@ $this->actingAs($user)->post(route('commit.store'), [ 'body' => 'Quoting with style', 'quoted_commit_id' => $quoted->id, - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, ])->assertSessionHasNoErrors(); expect($user->commits()->sole()->quoted_commit_id)->toBe($quoted->id); @@ -1544,5 +1598,5 @@ ->assertInertia(fn ($page) => $page ->where('commit.gif.id', 'xT9IgzoKnwFNmISR8I') ->where('commit.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') - ->missing('commit.giphy_url')); + ->missing('commit.gif_url')); }); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 39beaf925..5787af639 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -957,16 +957,53 @@ $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ 'body' => 'Replying with a gif', - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, ]); $response->assertSessionHasNoErrors(); - expect($user->commits()->sole()->giphy_id)->toBe('xT9IgzoKnwFNmISR8I'); + expect($user->commits()->sole()->gif_id)->toBe('xT9IgzoKnwFNmISR8I'); +}); + +it('accepts a klipy gif reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Klipy time', + 'gif_provider' => 'klipy', + 'gif_id' => 'hello-hi-662', + 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif', + 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'gif_width' => 498, + 'gif_height' => 498, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); +}); + +it('rejects a reply gif url from another provider', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Mismatch', + 'gif_provider' => 'klipy', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://klipy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); }); it('rejects a reply gif alongside uploaded images', function (): void { @@ -978,12 +1015,13 @@ $this->actingAs($user)->post(route('commit-reply.store', $parent), [ 'body' => 'Both at once', 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, - ])->assertSessionHasErrors('giphy_id'); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); }); it('rejects a bad gif media url on a reply', function (): void { @@ -992,12 +1030,13 @@ $this->actingAs($user)->post(route('commit-reply.store', $parent), [ 'body' => 'Nice try', - 'giphy_id' => 'xT9IgzoKnwFNmISR8I', - 'giphy_url' => 'https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'giphy_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'giphy_width' => 480, - 'giphy_height' => 270, - ])->assertSessionHasErrors('giphy_url'); + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_url'); }); it('lists replies in pages of any size up to 50', function (int $limit, int $status): void { diff --git a/tests/Unit/Rules/GifMediaUrlTest.php b/tests/Unit/Rules/GifMediaUrlTest.php new file mode 100644 index 000000000..32640fddd --- /dev/null +++ b/tests/Unit/Rules/GifMediaUrlTest.php @@ -0,0 +1,38 @@ + $url], ['url' => [new GifMediaUrl($provider)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'], + 'klipy' => [GifProvider::Klipy, 'https://static2.klipy.com/ii/935d7ab9/14/af/8GCrVAB7.gif'], +]); + +it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl($provider)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy url for klipy' => [GifProvider::Klipy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'klipy url for giphy' => [GifProvider::Giphy, 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif'], +]); + +it('rejects unsafe or malformed media urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl(GifProvider::Klipy)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://static.klipy.com/ii/x.gif'], + 'not a gif' => ['https://static.klipy.com/ii/x.webp'], + 'with port' => ['https://static.klipy.com:8443/ii/x.gif'], + 'with userinfo' => ['https://user:pass@static.klipy.com/ii/x.gif'], + 'dot segment' => ['https://static.klipy.com/ii/../x.gif'], + 'array value' => [['nope']], +]); diff --git a/tests/Unit/Rules/GifPageUrlTest.php b/tests/Unit/Rules/GifPageUrlTest.php new file mode 100644 index 000000000..0313e2f94 --- /dev/null +++ b/tests/Unit/Rules/GifPageUrlTest.php @@ -0,0 +1,25 @@ + $url], ['url' => [new GifPageUrl($provider)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I'], + 'klipy' => [GifProvider::Klipy, 'https://klipy.com/gifs/hello-hi-662'], +]); + +it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifPageUrl($provider)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], + 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], +]); From a8cc08a90e98476d21ddbcb6b91bd98009e7aaed Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:38:18 -0400 Subject: [PATCH 41/83] fix: reject malformed gif providers without erroring --- app/Http/Requests/CreateCommitReplyRequest.php | 3 ++- app/Http/Requests/CreateCommitRequest.php | 3 ++- .../Controllers/CommitControllerTest.php | 16 ++++++++++++++++ .../Controllers/CommitReplyControllerTest.php | 17 +++++++++++++++++ tests/Unit/Rules/GifPageUrlTest.php | 11 +++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index f1fcca0f0..7eaaaac3c 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -48,7 +48,8 @@ public function prepareForValidation(): void public function rules(): array { $body = $this->input('body'); - $provider = GifProvider::tryFrom($this->string('gif_provider')->value()) ?? GifProvider::Giphy; + $rawProvider = $this->input('gif_provider'); + $provider = is_string($rawProvider) ? (GifProvider::tryFrom($rawProvider) ?? GifProvider::Giphy) : GifProvider::Giphy; return [ 'body' => $this->hasFile('images') ? CreateCommitRequest::BODY_WITH_IMAGES_RULES : CreateCommitRequest::BODY_RULES, diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 2be311a27..cc4871f85 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -91,7 +91,8 @@ public function rules(): array $user = $this->user(); assert($user instanceof User); $body = $this->input('body'); - $provider = GifProvider::tryFrom($this->string('gif_provider')->value()) ?? GifProvider::Giphy; + $rawProvider = $this->input('gif_provider'); + $provider = is_string($rawProvider) ? (GifProvider::tryFrom($rawProvider) ?? GifProvider::Giphy) : GifProvider::Giphy; return [ 'body' => $this->hasFile('images') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES, diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 36ef3982a..ab15c2e70 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1504,6 +1504,22 @@ $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); }); +it('rejects a non-string gif provider without erroring', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit.store'), [ + 'body' => 'Array provider', + 'gif_provider' => ['giphy'], + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +}); + it('rejects a gif media url that is not on giphy', function (string $url): void { $user = User::factory()->create(); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 5787af639..c8b6af00f 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -989,6 +989,23 @@ expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); }); +it('rejects a non-string gif provider without erroring', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Reply with an array provider', + 'gif_provider' => ['giphy'], + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +}); + it('rejects a reply gif url from another provider', function (): void { $user = User::factory()->create(); $parent = Commit::factory()->create(); diff --git a/tests/Unit/Rules/GifPageUrlTest.php b/tests/Unit/Rules/GifPageUrlTest.php index 0313e2f94..8f4bfc73e 100644 --- a/tests/Unit/Rules/GifPageUrlTest.php +++ b/tests/Unit/Rules/GifPageUrlTest.php @@ -23,3 +23,14 @@ 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], ]); + +it('rejects unsafe or malformed page urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifPageUrl(GifProvider::Klipy)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://klipy.com/gifs/x'], + 'with port' => ['https://klipy.com:8443/gifs/x'], + 'with userinfo' => ['https://user:pass@klipy.com/gifs/x'], + 'array value' => [['nope']], +]); From cfe9338e2eb22d194e54bc7f6808fa196f861813 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:43:12 -0400 Subject: [PATCH 42/83] feat: expose the gif provider on every read surface --- app/Actions/CreateUserFeed.php | 4 ++-- app/Actions/UpdateCommitAiDecision.php | 4 ++-- app/Services/PageMetadata.php | 10 +++++----- .../Controllers/Api/CommitControllerTest.php | 3 ++- .../Controllers/UserFeedControllerTest.php | 2 +- tests/Unit/Jobs/ModerateCommitTest.php | 17 ++++++++++++++++- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index e2abb08c0..2683c4a8c 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -71,8 +71,8 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'enclosure', $image->url(), 'image/webp'); } - if ($commit->giphy_url !== null) { - $this->link($xml, 'enclosure', $commit->giphy_url, 'image/gif'); + if ($commit->gif_url !== null) { + $this->link($xml, 'enclosure', $commit->gif_url, 'image/gif'); } $xml->writeElement('published', $commit->created_at->toAtomString()); diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 9f0b2cb78..d5b1f8555 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -36,8 +36,8 @@ public function handle(Commit $commit): void fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), )->all()); - if ($commit->giphy_url !== null) { - $images[] = $commit->giphy_url; + if ($commit->gif_url !== null) { + $images[] = $commit->gif_url; } $scores = $this->openAI->moderate($commit->body, $images); diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 3df10a048..a1f49310f 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -289,13 +289,13 @@ private function commitImages(Commit $commit, User $user): array ]; } - if ($commit->giphy_url !== null) { + if ($commit->gif_url !== null) { $images[] = [ - 'url' => $commit->giphy_url, - 'width' => (int) $commit->giphy_width, - 'height' => (int) $commit->giphy_height, + 'url' => $commit->gif_url, + 'width' => (int) $commit->gif_width, + 'height' => (int) $commit->gif_height, 'type' => 'image/gif', - 'alt' => $commit->giphy_title ?? "GIF attached to the commit of {$user->nameWithHandle()}", + 'alt' => $commit->gif_title ?? "GIF attached to the commit of {$user->nameWithHandle()}", ]; } diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index e54c3d3ff..17857a1ee 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -201,10 +201,11 @@ }); it('includes the gif of a commit', function (): void { - $commit = Commit::factory()->withGiphy()->create(); + $commit = Commit::factory()->withGif()->create(); $this->getJson("/api/v1/commits/{$commit->id}") ->assertOk() + ->assertJsonPath('data.gif.provider', 'giphy') ->assertJsonPath('data.gif.id', 'xT9IgzoKnwFNmISR8I') ->assertJsonPath('data.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') ->assertJsonPath('data.gif.width', 480); diff --git a/tests/Feature/Controllers/UserFeedControllerTest.php b/tests/Feature/Controllers/UserFeedControllerTest.php index f31406aca..89f3ff151 100644 --- a/tests/Feature/Controllers/UserFeedControllerTest.php +++ b/tests/Feature/Controllers/UserFeedControllerTest.php @@ -214,7 +214,7 @@ function atomFeed(TestResponse $response): SimpleXMLElement it('lists an attached gif as an enclosure', function (): void { $user = User::factory()->create(); - Commit::factory()->for($user)->withGiphy()->create(); + Commit::factory()->for($user)->withGif()->create(); $response = $this->get(route('user-feed.show', $user)); diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index 30040a2d3..433b01ee4 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -4,6 +4,7 @@ use App\Actions\DeleteCommit; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Enums\NotificationType; use App\Exceptions\CommitProcessingFailed; use App\Jobs\ModerateCommit; @@ -423,7 +424,7 @@ function reviewResponses(array $responses = []): Closure 'api.openai.com/v1/responses' => reviewResponses(), ]); - $commit = Commit::factory()->pending()->withGiphy()->create(); + $commit = Commit::factory()->pending()->withGif()->create(); dispatch_sync(new ModerateCommit($commit)); @@ -435,6 +436,20 @@ function reviewResponses(array $responses = []): Closure && str_contains($request->body(), 'media2.giphy.com')); }); +it('moderates an attached klipy gif by its url', function (): void { + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->withGif(GifProvider::Klipy)->create(); + + dispatch_sync(new ModerateCommit($commit)); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') + && str_contains($request->body(), 'static.klipy.com')); +}); + it('keeps unavailable moderation pending and recovers on a later queued attempt', function (bool $recovers): void { Exceptions::fake(); Http::fake([ From 402a38f0aff2c3e96fcb4ea4bff75657f46c109b Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:49:11 -0400 Subject: [PATCH 43/83] feat: store one active gif provider per user --- app/Http/Controllers/GifKeyController.php | 43 +++++++++++ app/Http/Controllers/GiphyKeyController.php | 38 --------- .../Controllers/UserProfileController.php | 3 +- app/Http/Middleware/HandleInertiaRequests.php | 10 ++- ...KeyRequest.php => UpdateGifKeyRequest.php} | 13 +++- routes/web.php | 8 +- .../Controllers/ExportControllerTest.php | 8 +- .../Controllers/GifKeyControllerTest.php | 77 +++++++++++++++++++ .../Controllers/GiphyKeyControllerTest.php | 69 ----------------- .../Controllers/UserProfileControllerTest.php | 18 +++-- tests/Feature/Middleware/GifPropTest.php | 29 +++++++ .../HandleInertiaRequestsGiphyTest.php | 26 ------- 12 files changed, 187 insertions(+), 155 deletions(-) create mode 100644 app/Http/Controllers/GifKeyController.php delete mode 100644 app/Http/Controllers/GiphyKeyController.php rename app/Http/Requests/{UpdateGiphyKeyRequest.php => UpdateGifKeyRequest.php} (56%) create mode 100644 tests/Feature/Controllers/GifKeyControllerTest.php delete mode 100644 tests/Feature/Controllers/GiphyKeyControllerTest.php create mode 100644 tests/Feature/Middleware/GifPropTest.php delete mode 100644 tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php diff --git a/app/Http/Controllers/GifKeyController.php b/app/Http/Controllers/GifKeyController.php new file mode 100644 index 000000000..d9165d85c --- /dev/null +++ b/app/Http/Controllers/GifKeyController.php @@ -0,0 +1,43 @@ +provider(); + + $user->update([ + 'gif_provider' => $provider, + 'gif_key' => $request->string('key')->toString(), + ]); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => $provider->label().' key connected.', + ]); + + return back(); + } + + public function destroy(#[CurrentUser] User $user): RedirectResponse + { + $user->update(['gif_provider' => null, 'gif_key' => null]); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'GIF provider removed.', + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/GiphyKeyController.php b/app/Http/Controllers/GiphyKeyController.php deleted file mode 100644 index 01ba611ba..000000000 --- a/app/Http/Controllers/GiphyKeyController.php +++ /dev/null @@ -1,38 +0,0 @@ -update(['giphy_key' => $request->string('key')->toString()]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key connected.', - ]); - - return back(); - } - - public function destroy(#[CurrentUser] User $user): RedirectResponse - { - $user->update(['giphy_key' => null]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key removed.', - ]); - - return back(); - } -} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index c18bb13d2..ffbced83d 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -19,7 +19,8 @@ public function edit(#[CurrentUser] User $user): Response 'preferredLane' => $user->preferredLane(), 'automaticLane' => $user->automaticLane(), 'githubSyncedAt' => $user->github_synced_at?->toIso8601String(), - 'giphyConnected' => $user->giphy_key !== null, + 'gifConnected' => $user->gif_key !== null, + 'gifProvider' => $user->gif_provider?->value, 'activityGraphUrl' => route('user-activity-graph.show', $user), 'profileUrl' => route('user.show', $user), ]); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3098cc052..569f36e1e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -48,10 +48,14 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), - 'giphy' => fn (): ?array => (function () use ($request): ?array { - $key = $request->user()?->giphy_key; + 'gif' => fn (): ?array => (function () use ($request): ?array { + $user = $request->user(); - return is_string($key) && $key !== '' ? ['key' => $key] : null; + if ($user?->gif_provider === null || $user->gif_key === null || $user->gif_key === '') { + return null; + } + + return ['provider' => $user->gif_provider->value, 'key' => $user->gif_key]; })(), 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), diff --git a/app/Http/Requests/UpdateGiphyKeyRequest.php b/app/Http/Requests/UpdateGifKeyRequest.php similarity index 56% rename from app/Http/Requests/UpdateGiphyKeyRequest.php rename to app/Http/Requests/UpdateGifKeyRequest.php index bb4533d60..b9080a9b2 100644 --- a/app/Http/Requests/UpdateGiphyKeyRequest.php +++ b/app/Http/Requests/UpdateGifKeyRequest.php @@ -4,9 +4,12 @@ namespace App\Http\Requests; +use App\Enums\GifProvider; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Enum; -final class UpdateGiphyKeyRequest extends FormRequest +final class UpdateGifKeyRequest extends FormRequest { public function authorize(): bool { @@ -23,12 +26,18 @@ public function prepareForValidation(): void } /** - * @return array> + * @return array> */ public function rules(): array { return [ + 'provider' => ['required', 'string', Rule::enum(GifProvider::class)], 'key' => ['required', 'string', 'max:255'], ]; } + + public function provider(): GifProvider + { + return GifProvider::from($this->string('provider')->value()); + } } diff --git a/routes/web.php b/routes/web.php index e68a23158..cb4d23f57 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,7 +41,7 @@ use App\Http\Controllers\FailedJobController; use App\Http\Controllers\FailedJobRetryController; use App\Http\Controllers\FeatureController; -use App\Http\Controllers\GiphyKeyController; +use App\Http\Controllers\GifKeyController; use App\Http\Controllers\InboxController; use App\Http\Controllers\IssueController; use App\Http\Controllers\LocalSessionController; @@ -391,9 +391,9 @@ Route::put('settings/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('user-pasteis-mode.update'); - // Giphy key... - Route::put('settings/giphy', [GiphyKeyController::class, 'update'])->name('giphy-key.update'); - Route::delete('settings/giphy', [GiphyKeyController::class, 'destroy'])->name('giphy-key.destroy'); + // GIF provider... + Route::put('settings/gif', [GifKeyController::class, 'update'])->name('gif-key.update'); + Route::delete('settings/gif', [GifKeyController::class, 'destroy'])->name('gif-key.destroy'); Route::put('settings/remotes', [UserRemotesController::class, 'update'])->name('user-remotes.update'); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index c7f3ef800..d4e629937 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -441,8 +441,8 @@ $this->actingAs($user)->get(route('export'))->assertOk(); }); -it('never exports the giphy key', function (): void { - $user = User::factory()->withGiphyKey('giphy-test-key')->create(); +it('never exports the gif key', function (): void { + $user = User::factory()->withGifKey()->create(); $response = $this->actingAs($user)->get(route('export')); @@ -450,8 +450,8 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - expect($data['profile'])->not->toHaveKey('giphy_key') - ->and($response->streamedContent())->not->toContain('giphy-test-key'); + expect($data['profile'])->not->toHaveKey('gif_key') + ->and($response->streamedContent())->not->toContain('gif-test-key'); }); it('exports the messages the user sent, and not the ones they received', function (): void { diff --git a/tests/Feature/Controllers/GifKeyControllerTest.php b/tests/Feature/Controllers/GifKeyControllerTest.php new file mode 100644 index 000000000..98c5748f7 --- /dev/null +++ b/tests/Feature/Controllers/GifKeyControllerTest.php @@ -0,0 +1,77 @@ +create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('gif-key.update'), ['provider' => 'giphy', 'key' => ' giphy-test-key ']) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => 'GIPHY key connected.', + ]); + + expect($user->refresh()->gif_key)->toBe('giphy-test-key') + ->and(DB::table('users')->where('id', $user->id)->value('gif_key')) + ->not->toContain('giphy-test-key'); +}); + +it('replaces one provider with another atomically', function (): void { + $user = User::factory()->withGifKey(GifProvider::Giphy, 'giphy-key')->create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('gif-key.update'), ['provider' => 'klipy', 'key' => 'klipy-key']) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'KLIPY key connected.']); + + expect($user->refresh()) + ->gif_provider->toBe(GifProvider::Klipy) + ->gif_key->toBe('klipy-key'); +}); + +it('removes the key', function (): void { + $user = User::factory()->withGifKey()->create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->delete(route('gif-key.destroy')) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => 'GIF provider removed.', + ]); + + expect($user->refresh()) + ->gif_key->toBeNull() + ->gif_provider->toBeNull(); +}); + +it('rejects invalid providers without touching the stored one', function (array $payload): void { + $user = User::factory()->withGifKey()->create(); + $field = in_array($payload['provider'] ?? null, ['giphy', 'klipy'], true) ? 'key' : 'provider'; + + $this->actingAs($user)->putJson(route('gif-key.update'), $payload) + ->assertUnprocessable()->assertJsonValidationErrors($field); + + expect($user->refresh()->gif_provider)->toBe(GifProvider::Giphy); +})->with([ + 'missing provider' => [fn (): array => ['key' => 'x']], + 'unknown provider' => [fn (): array => ['provider' => 'tenor', 'key' => 'x']], + 'missing key' => [fn (): array => ['provider' => 'giphy']], + 'blank key' => [fn (): array => ['provider' => 'giphy', 'key' => ' ']], + 'non-string key' => [fn (): array => ['provider' => 'giphy', 'key' => ['x']]], + 'too long' => [fn (): array => ['provider' => 'giphy', 'key' => str_repeat('a', 256)]], +]); + +it('requires authentication to connect a key', function (): void { + $this->putJson(route('gif-key.update'), ['provider' => 'giphy', 'key' => 'giphy-test-key'])->assertUnauthorized(); +}); + +it('requires authentication to remove a key', function (): void { + $this->deleteJson(route('gif-key.destroy'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/GiphyKeyControllerTest.php b/tests/Feature/Controllers/GiphyKeyControllerTest.php deleted file mode 100644 index 94402923b..000000000 --- a/tests/Feature/Controllers/GiphyKeyControllerTest.php +++ /dev/null @@ -1,69 +0,0 @@ -create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('giphy-key.update'), ['key' => ' giphy-test-key ']) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key connected.', - ]); - - expect($user->refresh()->giphy_key)->toBe('giphy-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('giphy_key')) - ->not->toContain('giphy-test-key'); -}); - -it('replaces an existing key', function (): void { - $user = User::factory()->withGiphyKey('first-key')->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('giphy-key.update'), ['key' => 'second-key']) - ->assertRedirectToRoute('user-profile.edit'); - - expect($user->refresh()->giphy_key)->toBe('second-key'); -}); - -it('removes the key', function (): void { - $user = User::factory()->withGiphyKey()->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->delete(route('giphy-key.destroy')) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key removed.', - ]); - - expect($user->refresh()->giphy_key)->toBeNull(); -}); - -it('rejects invalid keys without touching the stored one', function (array $payload): void { - $user = User::factory()->withGiphyKey('stored-key')->create(); - - $this->actingAs($user)->putJson(route('giphy-key.update'), $payload) - ->assertUnprocessable()->assertJsonValidationErrors('key'); - - expect($user->refresh()->giphy_key)->toBe('stored-key'); -})->with([ - 'missing' => [fn (): array => []], - 'empty' => [fn (): array => ['key' => '']], - 'whitespace' => [fn (): array => ['key' => ' ']], - 'not a string' => [fn (): array => ['key' => ['nested']]], - 'too long' => [fn (): array => ['key' => str_repeat('a', 256)]], -]); - -it('requires authentication to connect a key', function (): void { - $this->putJson(route('giphy-key.update'), ['key' => 'giphy-test-key'])->assertUnauthorized(); -}); - -it('requires authentication to remove a key', function (): void { - $this->deleteJson(route('giphy-key.destroy'))->assertUnauthorized(); -}); diff --git a/tests/Feature/Controllers/UserProfileControllerTest.php b/tests/Feature/Controllers/UserProfileControllerTest.php index 45eae31a6..5885a396d 100644 --- a/tests/Feature/Controllers/UserProfileControllerTest.php +++ b/tests/Feature/Controllers/UserProfileControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\GifProvider; use App\Models\User; use Illuminate\Support\Facades\Http; @@ -189,16 +190,17 @@ Http::assertNothingSent(); }); -it('renders whether a giphy key is connected', function (bool $connected): void { - $user = $connected - ? User::factory()->withGiphyKey()->create() - : User::factory()->create(); +it('renders the connected gif provider', function (?string $provider): void { + $user = $provider === null + ? User::factory()->create() + : User::factory()->withGifKey(GifProvider::from($provider))->create(); $this->actingAs($user) ->get(route('user-profile.edit')) ->assertOk() ->assertInertia(fn ($page) => $page - ->component('user-profile/edit') - ->where('giphyConnected', $connected) - ->missing('auth.user.giphy_key')); -})->with([true, false]); + ->where('gifConnected', $provider !== null) + ->where('gifProvider', $provider) + ->missing('auth.user.gif_key') + ->missing('auth.user.gif_provider')); +})->with([null, 'giphy', 'klipy']); diff --git a/tests/Feature/Middleware/GifPropTest.php b/tests/Feature/Middleware/GifPropTest.php new file mode 100644 index 000000000..2e4ff27ce --- /dev/null +++ b/tests/Feature/Middleware/GifPropTest.php @@ -0,0 +1,29 @@ +withGifKey(GifProvider::Klipy, 'klipy-key')->create(); + + $this->actingAs($user) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page + ->where('gif.provider', 'klipy') + ->where('gif.key', 'klipy-key')); +}); + +it('shares no gif config without a connected provider', function (): void { + $this->actingAs(User::factory()->create()) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); +}); + +it('shares no gif config for guests', function (): void { + $this->get(route('log.show', Log::Trending)) + ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); +}); diff --git a/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php b/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php deleted file mode 100644 index b45319be6..000000000 --- a/tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php +++ /dev/null @@ -1,26 +0,0 @@ -withGiphyKey('giphy-test')->create(); - - $this->actingAs($user) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy.key', 'giphy-test')); -}); - -it('shares no giphy config without a key', function (): void { - $this->actingAs(User::factory()->create()) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); -}); - -it('shares no giphy config for guests', function (): void { - $this->get(route('log.show', Log::Trending)) - ->assertInertia(fn (Assert $page): Assert => $page->where('giphy', null)); -}); From 68cf0fa441e51bdcda18a6bb33a3594b4bc08650 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 15:55:53 -0400 Subject: [PATCH 44/83] test: cover the gif prop guard and fresh provider persistence --- tests/Feature/Controllers/GifKeyControllerTest.php | 3 ++- tests/Feature/Middleware/GifPropTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Controllers/GifKeyControllerTest.php b/tests/Feature/Controllers/GifKeyControllerTest.php index 98c5748f7..a805e9782 100644 --- a/tests/Feature/Controllers/GifKeyControllerTest.php +++ b/tests/Feature/Controllers/GifKeyControllerTest.php @@ -19,7 +19,8 @@ expect($user->refresh()->gif_key)->toBe('giphy-test-key') ->and(DB::table('users')->where('id', $user->id)->value('gif_key')) - ->not->toContain('giphy-test-key'); + ->not->toContain('giphy-test-key') + ->and($user->refresh()->gif_provider)->toBe(GifProvider::Giphy); }); it('replaces one provider with another atomically', function (): void { diff --git a/tests/Feature/Middleware/GifPropTest.php b/tests/Feature/Middleware/GifPropTest.php index 2e4ff27ce..05134a58b 100644 --- a/tests/Feature/Middleware/GifPropTest.php +++ b/tests/Feature/Middleware/GifPropTest.php @@ -27,3 +27,14 @@ $this->get(route('log.show', Log::Trending)) ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); }); + +it('shares no gif config when a provider has no key', function (?string $key): void { + $user = User::factory()->create([ + 'gif_provider' => GifProvider::Klipy, + 'gif_key' => $key, + ]); + + $this->actingAs($user) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); +})->with([null, '']); From 4fed0659f81afa026a15b9b19d6dbeda0b18f0e9 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:01:07 -0400 Subject: [PATCH 45/83] feat: add the gif provider adapters and registry --- resources/js/lib/gif-providers/giphy.ts | 135 +++++++++++++++++++ resources/js/lib/gif-providers/index.ts | 41 ++++++ resources/js/lib/gif-providers/klipy.ts | 122 +++++++++++++++++ resources/js/lib/gif-providers/types.ts | 48 +++++++ tests/Unit/GifProviders.test.ts | 171 ++++++++++++++++++++++++ 5 files changed, 517 insertions(+) create mode 100644 resources/js/lib/gif-providers/giphy.ts create mode 100644 resources/js/lib/gif-providers/index.ts create mode 100644 resources/js/lib/gif-providers/klipy.ts create mode 100644 resources/js/lib/gif-providers/types.ts create mode 100644 tests/Unit/GifProviders.test.ts diff --git a/resources/js/lib/gif-providers/giphy.ts b/resources/js/lib/gif-providers/giphy.ts new file mode 100644 index 000000000..9abd26422 --- /dev/null +++ b/resources/js/lib/gif-providers/giphy.ts @@ -0,0 +1,135 @@ +import type { + GifKeyValidation, + GifProvider, + GifResult, + GifSearchPage, +} from './types.ts'; +import { GifRateLimited } from './types.ts'; + +type GiphyRendition = { url: string; width: string; height: string }; + +type GiphyItem = { + id: string; + title: string; + username: string; + url: string; + images: Partial< + Record< + | 'downsized_medium' + | 'downsized' + | 'original' + | 'fixed_height_small', + GiphyRendition + > + >; +}; + +const pageSize = 24; +const baseUrl = 'https://api.giphy.com/v1/gifs'; + +const params = (key: string, offset: string | null): URLSearchParams => + new URLSearchParams({ + api_key: key, + limit: String(pageSize), + offset: offset ?? '0', + rating: 'g', + }); + +const normalize = (item: GiphyItem): GifResult | null => { + const stored = + item.images.downsized_medium ?? + item.images.downsized ?? + item.images.original; + + if (stored === undefined) { + return null; + } + + const preview = + item.images.fixed_height_small ?? + item.images.downsized ?? + item.images.downsized_medium ?? + item.images.original; + + return { + id: item.id, + url: stored.url, + pageUrl: item.url, + title: item.title === '' ? null : item.title, + username: item.username === '' ? null : item.username, + width: Number.parseInt(stored.width, 10), + height: Number.parseInt(stored.height, 10), + previewUrl: preview?.url ?? null, + }; +}; + +async function page( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`GIPHY request failed: ${response.status}`); + } + + const data = (await response.json()) as { data: GiphyItem[] }; + const results = data.data + .map(normalize) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: + data.data.length < pageSize + ? null + : String(Number.parseInt(options.cursor ?? '0', 10) + pageSize), + }; +} + +async function validateKey(key: string): Promise { + const search = new URLSearchParams({ + api_key: key, + limit: '1', + offset: '0', + rating: 'g', + }); + + let response: Response; + + try { + response = await fetch(`${baseUrl}/trending?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const giphy: GifProvider = { + id: 'giphy', + label: 'GIPHY', + searchPlaceholder: 'Search GIPHY', + attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }, + search: (key, query, options) => { + const search = params(key, options.cursor); + search.set('q', query); + + return page(`${baseUrl}/search?${search}`, options); + }, + trending: (key, options) => + page(`${baseUrl}/trending?${params(key, options.cursor)}`, options), + validateKey, +}; diff --git a/resources/js/lib/gif-providers/index.ts b/resources/js/lib/gif-providers/index.ts new file mode 100644 index 000000000..2d93f0728 --- /dev/null +++ b/resources/js/lib/gif-providers/index.ts @@ -0,0 +1,41 @@ +import { giphy } from './giphy.ts'; +import { klipy } from './klipy.ts'; +import type { + GifAttachment, + GifProvider, + GifProviderId, + GifResult, +} from './types.ts'; + +export * from './types.ts'; + +export const gifProviders: Record = { + giphy, + klipy, +}; + +export function gifProviderFor(id: GifProviderId): GifProvider { + return gifProviders[id]; +} + +export function attachmentFromResult( + id: GifProviderId, + result: GifResult, +): GifAttachment { + return { ...result, provider: id }; +} + +export function gifFormFields( + gif: GifAttachment, +): Record { + return { + gif_provider: gif.provider, + gif_id: gif.id, + gif_url: gif.url, + gif_page_url: gif.pageUrl, + gif_title: gif.title ?? '', + gif_username: gif.username ?? '', + gif_width: gif.width, + gif_height: gif.height, + }; +} diff --git a/resources/js/lib/gif-providers/klipy.ts b/resources/js/lib/gif-providers/klipy.ts new file mode 100644 index 000000000..7db0ab839 --- /dev/null +++ b/resources/js/lib/gif-providers/klipy.ts @@ -0,0 +1,122 @@ +import type { + GifKeyValidation, + GifProvider, + GifResult, + GifSearchPage, +} from './types.ts'; +import { GifRateLimited } from './types.ts'; + +type KlipyMedia = { url: string; dims: [number, number] }; + +type KlipyItem = { + id: string; + title?: string; + content_description?: string; + itemurl: string; + media_formats?: { gif?: KlipyMedia; tinygif?: KlipyMedia }; +}; + +type KlipyResponse = { results?: KlipyItem[]; next?: string }; + +const pageSize = 24; +const baseUrl = 'https://api.klipy.com/v2'; + +const params = (key: string, cursor: string | null): URLSearchParams => { + const search = new URLSearchParams({ + key, + limit: String(pageSize), + contentfilter: 'high', + }); + + if (cursor !== null) { + search.set('pos', cursor); + } + + return search; +}; + +const normalize = (item: KlipyItem): GifResult | null => { + const gif = item.media_formats?.gif; + + if (gif === undefined) { + return null; + } + + return { + id: item.id, + url: gif.url, + pageUrl: item.itemurl, + title: item.title || item.content_description || null, + username: null, + width: gif.dims[0], + height: gif.dims[1], + previewUrl: item.media_formats?.tinygif?.url ?? gif.url, + }; +}; + +async function page( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`KLIPY request failed: ${response.status}`); + } + + const data = (await response.json()) as KlipyResponse; + const results = (data.results ?? []) + .map(normalize) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: data.next === '' ? null : (data.next ?? null), + }; +} + +async function validateKey(key: string): Promise { + const search = new URLSearchParams({ + key, + limit: '8', + contentfilter: 'high', + }); + + let response: Response; + + try { + response = await fetch(`${baseUrl}/featured?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const klipy: GifProvider = { + id: 'klipy', + label: 'KLIPY', + searchPlaceholder: 'Search KLIPY', + attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, + search: (key, query, options) => { + const search = params(key, options.cursor); + search.set('q', query); + + return page(`${baseUrl}/search?${search}`, options); + }, + trending: (key, options) => + page(`${baseUrl}/featured?${params(key, options.cursor)}`, options), + validateKey, +}; diff --git a/resources/js/lib/gif-providers/types.ts b/resources/js/lib/gif-providers/types.ts new file mode 100644 index 000000000..1772ea16e --- /dev/null +++ b/resources/js/lib/gif-providers/types.ts @@ -0,0 +1,48 @@ +export type GifProviderId = 'giphy' | 'klipy'; + +export type GifResult = { + id: string; + url: string; + pageUrl: string; + title: string | null; + username: string | null; + width: number; + height: number; + previewUrl: string | null; +}; + +export type GifAttachment = GifResult & { provider: GifProviderId }; + +export type GifSearchPage = { + results: GifResult[]; + nextCursor: string | null; +}; + +export type GifKeyValidation = + | 'valid' + | 'invalid' + | 'rate-limited' + | 'unreachable'; + +export class GifRateLimited extends Error { + constructor() { + super('The GIF provider is rate limiting searches.'); + } +} + +export interface GifProvider { + readonly id: GifProviderId; + readonly label: string; + readonly searchPlaceholder: string; + readonly attribution: { readonly label: string; readonly href: string }; + search( + key: string, + query: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + trending( + key: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + validateKey(key: string): Promise; +} diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts new file mode 100644 index 000000000..516b11cea --- /dev/null +++ b/tests/Unit/GifProviders.test.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + attachmentFromResult, + gifFormFields, +} from '../../resources/js/lib/gif-providers/index.ts'; +import { giphy } from '../../resources/js/lib/gif-providers/giphy.ts'; +import { klipy } from '../../resources/js/lib/gif-providers/klipy.ts'; +import { GifRateLimited } from '../../resources/js/lib/gif-providers/types.ts'; + +const originalFetch = globalThis.fetch; + +function stubFetch(payload: unknown, status = 200): string[] { + const calls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + + return new Response(JSON.stringify(payload), { status }); + }) as typeof fetch; + + return calls; +} + +function restoreFetch(): void { + globalThis.fetch = originalFetch; +} + +const giphyPayload = { + data: [{ + id: 'xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + images: { + fixed_height_small: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', width: '178', height: '100' }, + downsized_medium: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', width: '480', height: '270' }, + }, + }], +}; + +const klipyPayload = { + results: [{ + id: 'hello-hi-662', + title: 'Hello', + content_description: 'hello greeting', + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { url: 'https://static.klipy.com/ii/8GCrVAB7.gif', dims: [498, 498] }, + tinygif: { url: 'https://static.klipy.com/ii/y6iepZM7.gif', dims: [220, 220] }, + }, + }], + next: 'cursor-2', +}; + +test('giphy builds search and trending urls with the g rating', async () => { + const searchCalls = stubFetch(giphyPayload); + try { + const page = await giphy.search('key-123', 'deal with it', { cursor: null }); + const url = new URL(searchCalls[0]!); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); + assert.equal(url.searchParams.get('api_key'), 'key-123'); + assert.equal(url.searchParams.get('q'), 'deal with it'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '0'); + assert.equal(page.results[0]?.url, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc'); + assert.equal(page.results[0]?.previewUrl, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc'); + assert.equal(page.nextCursor, null); + } finally { + restoreFetch(); + } + + const trendingCalls = stubFetch(giphyPayload); + try { + await giphy.trending('key-123', { cursor: '24' }); + assert.ok(trendingCalls[0]!.includes('/v1/gifs/trending?')); + assert.ok(trendingCalls[0]!.includes('offset=24')); + } finally { + restoreFetch(); + } +}); + +test('klipy builds tenor-compatible urls and maps the next cursor', async () => { + const calls = stubFetch(klipyPayload); + try { + const page = await klipy.search('k-key', 'hello', { cursor: null }); + const url = new URL(calls[0]!); + assert.equal(url.origin + url.pathname, 'https://api.klipy.com/v2/search'); + assert.equal(url.searchParams.get('key'), 'k-key'); + assert.equal(url.searchParams.get('q'), 'hello'); + assert.equal(url.searchParams.get('contentfilter'), 'high'); + assert.equal(page.results[0]?.pageUrl, 'https://klipy.com/gifs/hello-hi-662'); + assert.equal(page.results[0]?.username, null); + assert.equal(page.results[0]?.previewUrl, 'https://static.klipy.com/ii/y6iepZM7.gif'); + assert.equal(page.nextCursor, 'cursor-2'); + } finally { + restoreFetch(); + } + + const trendingCalls = stubFetch({ results: [], next: '' }); + try { + const page = await klipy.trending('k-key', { cursor: null }); + assert.ok(trendingCalls[0]!.includes('/v2/featured?')); + assert.equal(page.nextCursor, null); + } finally { + restoreFetch(); + } +}); + +test('adapters map throttling and key verdicts to the shared shapes', async () => { + stubFetch({}, 429); + try { + await assert.rejects(() => giphy.trending('k', { cursor: null }), GifRateLimited); + assert.equal(await giphy.validateKey('k'), 'rate-limited'); + assert.equal(await klipy.validateKey('k'), 'rate-limited'); + } finally { + restoreFetch(); + } + + for (const status of [401, 403]) { + stubFetch({}, status); + try { + assert.equal(await giphy.validateKey('k'), 'invalid'); + assert.equal(await klipy.validateKey('k'), 'invalid'); + } finally { + restoreFetch(); + } + } + + stubFetch({}, 200); + try { + assert.equal(await giphy.validateKey('k'), 'valid'); + assert.equal(await klipy.validateKey('k'), 'valid'); + } finally { + restoreFetch(); + } + + globalThis.fetch = (async () => { + throw new TypeError('network'); + }) as typeof fetch; + try { + assert.equal(await giphy.validateKey('k'), 'unreachable'); + assert.equal(await klipy.validateKey('k'), 'unreachable'); + } finally { + restoreFetch(); + } +}); + +test('attachments carry the provider and shape the hidden form fields', () => { + const gif = attachmentFromResult('klipy', { + id: 'hello-hi-662', + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + pageUrl: 'https://klipy.com/gifs/hello-hi-662', + title: 'Hello', + username: null, + width: 498, + height: 498, + previewUrl: 'https://static.klipy.com/ii/y6iepZM7.gif', + }); + + assert.deepEqual(gifFormFields(gif), { + gif_provider: 'klipy', + gif_id: 'hello-hi-662', + gif_url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + gif_page_url: 'https://klipy.com/gifs/hello-hi-662', + gif_title: 'Hello', + gif_username: '', + gif_width: 498, + gif_height: 498, + }); +}); From 502ba88b171075b1896fd7f02eb9aa681211782e Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:04:04 -0400 Subject: [PATCH 46/83] test: pin provider cursors, constraints, and drop branches --- tests/Unit/GifProviders.test.ts | 100 +++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index 516b11cea..80a98f723 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -73,8 +73,46 @@ test('giphy builds search and trending urls with the g rating', async () => { const trendingCalls = stubFetch(giphyPayload); try { await giphy.trending('key-123', { cursor: '24' }); + const trendingUrl = new URL(trendingCalls[0]!); assert.ok(trendingCalls[0]!.includes('/v1/gifs/trending?')); - assert.ok(trendingCalls[0]!.includes('offset=24')); + assert.equal(trendingUrl.searchParams.get('offset'), '24'); + assert.equal(trendingUrl.searchParams.get('rating'), 'g'); + } finally { + restoreFetch(); + } +}); + +test('giphy derives the next cursor from a full page and passes it back as offset', async () => { + const fullPage = { + data: Array.from({ length: 24 }, (_, index) => ({ + id: `gif-${index}`, + title: `GIF ${index}`, + username: 'giphy', + url: `https://giphy.com/gifs/gif-${index}`, + images: { + downsized_medium: { + url: `https://media2.giphy.com/media/gif-${index}/giphy.gif`, + width: '480', + height: '270', + }, + }, + })), + }; + + stubFetch(fullPage); + try { + const page = await giphy.search('key-123', 'deal', { cursor: null }); + assert.equal(page.nextCursor, '24'); + } finally { + restoreFetch(); + } + + const nextCalls = stubFetch(fullPage); + try { + const page = await giphy.search('key-123', 'deal', { cursor: '24' }); + const url = new URL(nextCalls[0]!); + assert.equal(url.searchParams.get('offset'), '24'); + assert.equal(page.nextCursor, '48'); } finally { restoreFetch(); } @@ -97,16 +135,74 @@ test('klipy builds tenor-compatible urls and maps the next cursor', async () => restoreFetch(); } + const nextCalls = stubFetch(klipyPayload); + try { + await klipy.search('k-key', 'hello', { cursor: 'cursor-2' }); + const url = new URL(nextCalls[0]!); + assert.equal(url.searchParams.get('pos'), 'cursor-2'); + } finally { + restoreFetch(); + } + const trendingCalls = stubFetch({ results: [], next: '' }); try { - const page = await klipy.trending('k-key', { cursor: null }); + const page = await klipy.trending('k-key', { cursor: 'cursor-2' }); + const url = new URL(trendingCalls[0]!); assert.ok(trendingCalls[0]!.includes('/v2/featured?')); + assert.equal(url.searchParams.get('contentfilter'), 'high'); + assert.equal(url.searchParams.get('pos'), 'cursor-2'); assert.equal(page.nextCursor, null); } finally { restoreFetch(); } }); +test('adapters drop items without a usable rendition', async () => { + stubFetch({ + data: [{ + id: 'no-renditions', + title: 'Nothing', + username: '', + url: 'https://giphy.com/gifs/nothing', + images: {}, + }], + }); + try { + const page = await giphy.search('key-123', 'nothing', { cursor: null }); + assert.deepEqual(page.results, []); + } finally { + restoreFetch(); + } + + stubFetch({ + results: [{ + id: 'no-gif', + title: 'Nothing', + itemurl: 'https://klipy.com/gifs/no-gif', + media_formats: { + tinygif: { url: 'https://static.klipy.com/ii/no-gif.gif', dims: [220, 220] }, + }, + }], + next: '', + }); + try { + const page = await klipy.search('k-key', 'nothing', { cursor: null }); + assert.deepEqual(page.results, []); + } finally { + restoreFetch(); + } +}); + +test('adapters reject non-429 http failures', async () => { + stubFetch({}, 500); + try { + await assert.rejects(() => giphy.trending('k', { cursor: null }), /request failed/); + await assert.rejects(() => klipy.trending('k', { cursor: null }), /request failed/); + } finally { + restoreFetch(); + } +}); + test('adapters map throttling and key verdicts to the shared shapes', async () => { stubFetch({}, 429); try { From 3a2c641490136e0acc4c23aef7d9130473d421a8 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:07:56 -0400 Subject: [PATCH 47/83] feat: choose the gif provider in profile settings --- README.md | 2 +- ...hy-key-setting.tsx => gif-key-setting.tsx} | 110 ++++--- resources/js/pages/user-profile/edit.tsx | 13 +- tests/Unit/GifKeySetting.test.tsx | 283 ++++++++++++++++++ ...er.test.ts => GifKeySettingRender.test.ts} | 4 +- tests/Unit/GiphyKeySetting.test.tsx | 202 ------------- ...{giphy-key.config.ts => gif-key.config.ts} | 2 +- tests/tsconfig.json | 4 +- 8 files changed, 374 insertions(+), 246 deletions(-) rename resources/js/components/{giphy-key-setting.tsx => gif-key-setting.tsx} (52%) create mode 100644 tests/Unit/GifKeySetting.test.tsx rename tests/Unit/{GiphyKeySettingRender.test.ts => GifKeySettingRender.test.ts} (72%) delete mode 100644 tests/Unit/GiphyKeySetting.test.tsx rename tests/{giphy-key.config.ts => gif-key.config.ts} (82%) diff --git a/README.md b/README.md index bf4dfddc7..6e5227ab8 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key: connect your own free [GIPHY API key](https://developers.giphy.com) under Settings → Profile → Giphy and every composer gains a GIF picker: search GIPHY and attach one GIF to a commit, reply, or quote — never alongside uploaded images or a poll. Searches run on the connected key and its quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to GIPHY directly and commits store only the GIF's URLs and its metadata — nothing is downloaded or rehosted. +GIF search is bring-your-own-key: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. ### Verify installation diff --git a/resources/js/components/giphy-key-setting.tsx b/resources/js/components/gif-key-setting.tsx similarity index 52% rename from resources/js/components/giphy-key-setting.tsx rename to resources/js/components/gif-key-setting.tsx index 0743c27bc..83ee5da8c 100644 --- a/resources/js/components/giphy-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -3,21 +3,33 @@ import { useState } from 'react'; import { destroy, update, -} from '@/actions/App/Http/Controllers/GiphyKeyController'; +} from '@/actions/App/Http/Controllers/GifKeyController'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; -import { validateGiphyKey } from '@/lib/giphy'; +import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; -export function GiphyKeySetting({ - giphyConnected, +const helpHref: Record = { + giphy: 'https://developers.giphy.com', + klipy: 'https://partner.klipy.com', +}; + +export function GifKeySetting({ + gifConnected, + gifProvider, }: { - giphyConnected: boolean; + gifConnected: boolean; + gifProvider: GifProviderId | null; }) { + const [providerId, setProviderId] = useState( + gifProvider ?? 'giphy', + ); const [key, setKey] = useState(''); - const [editing, setEditing] = useState(!giphyConnected); + const [editing, setEditing] = useState(!gifConnected); const [processing, setProcessing] = useState(false); const [error, setError] = useState(null); + const provider = gifProviderFor(providerId); const connect = async () => { if (processing) { @@ -33,17 +45,17 @@ export function GiphyKeySetting({ setProcessing(true); setError(null); - const verdict = await validateGiphyKey(candidate); + const verdict = await provider.validateKey(candidate); if (verdict === 'invalid') { - setError('That key was rejected by GIPHY.'); + setError(`That key was rejected by ${provider.label}.`); setProcessing(false); return; } if (verdict === 'unreachable') { - setError("Couldn't reach GIPHY to verify the key."); + setError(`Couldn't reach ${provider.label} to verify the key.`); setProcessing(false); return; @@ -51,12 +63,16 @@ export function GiphyKeySetting({ router.put( update.url(), - { key: candidate }, + { provider: providerId, key: candidate }, { preserveScroll: true, onFinish: () => setProcessing(false), onError: (errors) => - setError(errors.key ?? 'Could not save the key.'), + setError( + errors.key ?? + errors.provider ?? + 'Could not save the key.', + ), onSuccess: () => { setKey(''); setError(null); @@ -77,36 +93,37 @@ export function GiphyKeySetting({ preserveScroll: true, onStart: () => setProcessing(true), onFinish: () => setProcessing(false), - onError: () => setError('Could not remove the key.'), + onError: () => setError('Could not remove the provider.'), }); }; return (
-

- Giphy +

+ GIFs

- GIF search runs on your own GIPHY API key, under your own quota. - Get a free key at{' '} + GIF search runs on your own {provider.label} API key, under your + own quota. Get a key at{' '} - developers.giphy.com + {provider.label} - , connect it here, and every composer gains a GIF picker. - Without a key, the picker stays hidden. + , connect it here, and every composer gains a GIF picker. One + provider at a time; without a key the picker stays hidden.

- {giphyConnected && !editing ? ( + {gifConnected && !editing ? (
-

- GIPHY is connected. +

+ Connected to{' '} + {gifProviderFor(gifProvider ?? 'giphy').label}.

) : ( -
- +
+
+ {Object.values(gifProviders).map((candidate) => ( + + ))} +
+
setKey(event.target.value)} - placeholder="Paste your GIPHY API key" + placeholder={`Paste your ${provider.label} API key`} autoComplete="off" spellCheck={false} className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="giphy-key-input" + data-test="gif-key-input" />
@@ -159,7 +201,7 @@ export function GiphyKeySetting({

{error}

diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index 0758bcc97..e79abc5cd 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -4,7 +4,7 @@ import ExportController from '@/actions/App/Http/Controllers/ExportController'; import UserProfileController from '@/actions/App/Http/Controllers/UserProfileController'; import ActivityGraphEmbed from '@/components/activity-graph-embed'; import DeleteUser from '@/components/delete-user'; -import { GiphyKeySetting } from '@/components/giphy-key-setting'; +import { GifKeySetting } from '@/components/gif-key-setting'; import { GitHubProfile } from '@/components/github-profile'; import Heading from '@/components/heading'; import LanePicker from '@/components/lane-picker'; @@ -25,14 +25,16 @@ export default function Edit({ preferredLane, automaticLane, githubSyncedAt, - giphyConnected, + gifConnected, + gifProvider, activityGraphUrl, profileUrl, }: { preferredLane: Lane | null; automaticLane: number; githubSyncedAt: string | null; - giphyConnected: boolean; + gifConnected: boolean; + gifProvider: 'giphy' | 'klipy' | null; activityGraphUrl: string; profileUrl: string; }) { @@ -114,7 +116,10 @@ export default function Edit({ - + diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx new file mode 100644 index 000000000..9625fbbe4 --- /dev/null +++ b/tests/Unit/GifKeySetting.test.tsx @@ -0,0 +1,283 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +const put = vi.fn(); +const destroy = vi.fn(); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + router: { put, delete: destroy }, +})); + +const { GifKeySetting } = await import( + '../../resources/js/components/gif-key-setting' +); + +type Element = ReactElement>; +type Props = { + gifConnected: boolean; + gifProvider: 'giphy' | 'klipy' | null; +}; + +const disconnected: Props = { gifConnected: false, gifProvider: null }; +const connected: Props = { gifConnected: true, gifProvider: 'giphy' }; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(props: Props): Element[] { + cursor = 0; + + return elements(GifKeySetting(props)); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +function stubFetch(result: number | 'network'): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (result === 'network') { + throw new TypeError('fetch failed'); + } + + return new Response('{}', { status: result }); + }), + ); +} + +function typeKey(fields: Element[], value: string, props: Props): Element[] { + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ + target: { value }, + }); + + return render(props); +} + +function selectProvider(fields: Element[], id: 'klipy'): Element[] { + (field(fields, `provider-${id}`).props.onClick as () => void)(); + + return render(disconnected); +} + +beforeEach(() => { + state = []; + cursor = 0; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +test('saves a valid key after giphy accepts it', async () => { + stubFetch(200); + + let fields = typeKey(render(disconnected), ' key-123 ', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 1); + assert.equal(put.mock.calls[0]?.[0], '/settings/gif'); + assert.deepEqual(put.mock.calls[0]?.[1], { + provider: 'giphy', + key: 'key-123', + }); + + const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; + options.onSuccess(); + + fields = render(connected); + assert.ok(field(fields, 'gif-connected')); +}); + +test('saves the key against the selected provider', async () => { + stubFetch(200); + + let fields = selectProvider(render(disconnected), 'klipy'); + fields = typeKey(fields, 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 1); + assert.deepEqual(put.mock.calls[0]?.[1], { + provider: 'klipy', + key: 'key-123', + }); +}); + +test('saves a rate-limited key because the key itself is real', async () => { + stubFetch(429); + + const fields = typeKey(render(disconnected), 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 1); +}); + +test('rejects a refused key without saving', async () => { + stubFetch(401); + + const fields = typeKey(render(disconnected), 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(disconnected), 'gif-key-error').props.children, + 'That key was rejected by GIPHY.', + ); +}); + +test('names klipy when it refuses the key', async () => { + stubFetch(401); + + let fields = selectProvider(render(disconnected), 'klipy'); + fields = typeKey(fields, 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(disconnected), 'gif-key-error').props.children, + 'That key was rejected by KLIPY.', + ); +}); + +test('does not save when the provider cannot be reached', async () => { + stubFetch('network'); + + const fields = typeKey(render(disconnected), 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(disconnected), 'gif-key-error').props.children, + "Couldn't reach GIPHY to verify the key.", + ); +}); + +test('names klipy when it cannot be reached', async () => { + stubFetch('network'); + + let fields = selectProvider(render(disconnected), 'klipy'); + fields = typeKey(fields, 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); + assert.equal( + field(render(disconnected), 'gif-key-error').props.children, + "Couldn't reach KLIPY to verify the key.", + ); +}); + +test('disables connect until a key is typed', () => { + const fields = render(disconnected); + + assert.equal(field(fields, 'gif-connect-button').props.disabled, true); +}); + +test('surfaces server validation errors', async () => { + stubFetch(200); + + const fields = typeKey(render(disconnected), 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + const options = put.mock.calls[0]?.[2] as { + onError: (errors: Record) => void; + }; + options.onError({ key: 'The key field is required.' }); + + assert.equal( + field(render(disconnected), 'gif-key-error').props.children, + 'The key field is required.', + ); +}); + +test('removes a connected key', () => { + const fields = render(connected); + + (field(fields, 'gif-remove-button').props.onClick as () => void)(); + + assert.equal(destroy.mock.calls.length, 1); + assert.equal(destroy.mock.calls[0]?.[0], '/settings/gif'); +}); + +test('replaces the connected provider when another is chosen', async () => { + stubFetch(200); + + let fields = render(connected); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + + fields = render(connected); + (field(fields, 'provider-klipy').props.onClick as () => void)(); + + fields = render(connected); + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value: 'klipy-key' } }); + + fields = render(connected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(put.mock.calls[0]?.[1], { + provider: 'klipy', + key: 'klipy-key', + }); +}); diff --git a/tests/Unit/GiphyKeySettingRender.test.ts b/tests/Unit/GifKeySettingRender.test.ts similarity index 72% rename from tests/Unit/GiphyKeySettingRender.test.ts rename to tests/Unit/GifKeySettingRender.test.ts index d1680947f..1c1b74f2c 100644 --- a/tests/Unit/GiphyKeySettingRender.test.ts +++ b/tests/Unit/GifKeySettingRender.test.ts @@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; -await test('connects, updates, and removes the giphy key from profile settings', () => { +await test('connects, changes, and removes the gif provider from profile settings', () => { const cli = fileURLToPath( new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), ); execFileSync( process.execPath, - [cli, 'test', 'run', '--config', 'tests/giphy-key.config.ts'], + [cli, 'test', 'run', '--config', 'tests/gif-key.config.ts'], { cwd: fileURLToPath(new URL('../..', import.meta.url)), stdio: 'inherit', diff --git a/tests/Unit/GiphyKeySetting.test.tsx b/tests/Unit/GiphyKeySetting.test.tsx deleted file mode 100644 index 5cf4e0886..000000000 --- a/tests/Unit/GiphyKeySetting.test.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/// -import assert from 'node:assert/strict'; -import * as React from 'react'; -import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; - -let state: unknown[] = []; -let cursor = 0; - -vi.doMock('react', () => ({ - ...React, - useEffect: (effect: () => void) => effect(), - useMemo: (factory: () => unknown) => factory(), - useId: () => `preview-${cursor++}`, - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => - snapshot(), - useRef: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = { current: initial }; - } - - return state[index]; - }, - useState: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = typeof initial === 'function' ? initial() : initial; - } - - return [ - state[index], - (value: unknown) => { - state[index] = - typeof value === 'function' - ? (value as (current: unknown) => unknown)(state[index]) - : value; - }, - ]; - }, -})); - -const put = vi.fn(); -const destroy = vi.fn(); - -vi.doMock('@inertiajs/react', async () => ({ - ...(await vi.importActual('@inertiajs/react')), - router: { put, delete: destroy }, -})); - -const { GiphyKeySetting } = await import( - '../../resources/js/components/giphy-key-setting' -); - -type Element = ReactElement>; - -function elements(node: ReactNode): Element[] { - if (!React.isValidElement(node)) { - return []; - } - - const element = node as Element; - - return [ - element, - ...React.Children.toArray(element.props.children as ReactNode).flatMap( - elements, - ), - ]; -} - -function render(giphyConnected: boolean): Element[] { - cursor = 0; - - return elements(GiphyKeySetting({ giphyConnected })); -} - -function field(fields: Element[], testId: string): Element { - const found = fields.find( - (element) => element.props['data-test'] === testId, - ); - - assert.ok(found, `no element with data-test="${testId}"`); - - return found; -} - -function stubFetch(result: number | 'network'): void { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - if (result === 'network') { - throw new TypeError('fetch failed'); - } - - return new Response('{}', { status: result }); - }), - ); -} - -function typeKey(fields: Element[], value: string): Element[] { - (field(fields, 'giphy-key-input').props.onChange as (event: { - target: { value: string }; - }) => void)({ - target: { value }, - }); - - return render(false); -} - -beforeEach(() => { - state = []; - cursor = 0; - vi.clearAllMocks(); - vi.unstubAllGlobals(); -}); - -test('saves a valid key after giphy accepts it', async () => { - stubFetch(200); - - let fields = typeKey(render(false), ' key-123 '); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 1); - assert.equal(put.mock.calls[0]?.[0], '/settings/giphy'); - assert.deepEqual(put.mock.calls[0]?.[1], { key: 'key-123' }); - - const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; - options.onSuccess(); - - fields = render(true); - assert.ok(field(fields, 'giphy-connected')); -}); - -test('saves a rate-limited key because the key itself is real', async () => { - stubFetch(429); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 1); -}); - -test('rejects a refused key without saving', async () => { - stubFetch(401); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 0); - assert.equal( - field(render(false), 'giphy-key-error').props.children, - 'That key was rejected by GIPHY.', - ); -}); - -test('does not save when giphy cannot be reached', async () => { - stubFetch('network'); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 0); - assert.equal( - field(render(false), 'giphy-key-error').props.children, - "Couldn't reach GIPHY to verify the key.", - ); -}); - -test('disables connect until a key is typed', () => { - const fields = render(false); - - assert.equal(field(fields, 'giphy-connect-button').props.disabled, true); -}); - -test('surfaces server validation errors', async () => { - stubFetch(200); - - const fields = typeKey(render(false), 'key-123'); - await (field(fields, 'giphy-connect-button').props.onClick as () => Promise)(); - - const options = put.mock.calls[0]?.[2] as { - onError: (errors: Record) => void; - }; - options.onError({ key: 'The key field is required.' }); - - assert.equal( - field(render(false), 'giphy-key-error').props.children, - 'The key field is required.', - ); -}); - -test('removes a connected key', () => { - const fields = render(true); - - (field(fields, 'giphy-remove-button').props.onClick as () => void)(); - - assert.equal(destroy.mock.calls.length, 1); - assert.equal(destroy.mock.calls[0]?.[0], '/settings/giphy'); -}); diff --git a/tests/giphy-key.config.ts b/tests/gif-key.config.ts similarity index 82% rename from tests/giphy-key.config.ts rename to tests/gif-key.config.ts index a0cd83205..f1d9ec5c3 100644 --- a/tests/giphy-key.config.ts +++ b/tests/gif-key.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ }, }, test: { - include: ['tests/Unit/GiphyKeySetting.test.tsx'], + include: ['tests/Unit/GifKeySetting.test.tsx'], }, }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index a89c22a32..ec078dbfb 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -8,7 +8,7 @@ "Unit/**/*.test.ts", "Unit/DraftStash.test.tsx", "Unit/CommitGiphy.test.tsx", - "Unit/GiphyKeySetting.test.tsx", + "Unit/GifKeySetting.test.tsx", "Unit/ComposerDraft.test.tsx", "Unit/SearchDialogComposition.test.tsx", "Unit/DraftImageReads.test.tsx", @@ -16,6 +16,6 @@ "../resources/js/types/*.d.ts", "draft-images.config.ts", "commit-giphy.config.ts", - "giphy-key.config.ts" + "gif-key.config.ts" ] } From ab43248b5eaf9b8837124d4c69138d4124166202 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:13:40 -0400 Subject: [PATCH 48/83] fix: polish the gif provider settings and its tests --- resources/js/components/gif-key-setting.tsx | 12 ++++++++-- tests/Unit/GifKeySetting.test.tsx | 26 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index 83ee5da8c..ba5aac3c8 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -98,7 +98,11 @@ export function GifKeySetting({ }; return ( -
+

GIFs

@@ -164,7 +168,11 @@ export function GifKeySetting({ ? 'default' : 'outline' } - onClick={() => setProviderId(candidate.id)} + aria-pressed={candidate.id === providerId} + onClick={() => { + setProviderId(candidate.id); + setError(null); + }} data-test={`provider-${candidate.id}`} > {candidate.label} diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index 9625fbbe4..0720fb041 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -233,6 +233,15 @@ test('disables connect until a key is typed', () => { assert.equal(field(fields, 'gif-connect-button').props.disabled, true); }); +test('does not save a whitespace-only key', async () => { + stubFetch(200); + + const fields = typeKey(render(disconnected), ' ', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(put.mock.calls.length, 0); +}); + test('surfaces server validation errors', async () => { stubFetch(200); @@ -280,4 +289,21 @@ test('replaces the connected provider when another is chosen', async () => { provider: 'klipy', key: 'klipy-key', }); + + const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; + options.onSuccess(); + + fields = render({ gifConnected: true, gifProvider: 'klipy' }); + + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to KLIPY.', + ); + + fields = render(disconnected); + assert.equal(field(fields, 'gif-key-input').props.value, ''); }); From 09e3e967d6cb48791b9182ac85ff5617dd0fba7a Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:19:07 -0400 Subject: [PATCH 49/83] feat: run the gif picker through the provider interface --- resources/js/components/commit-card.tsx | 4 +- resources/js/components/commit-composer.tsx | 22 ++- .../{commit-giphy.tsx => commit-gif.tsx} | 17 +- .../js/components/commit-quote-button.tsx | 12 +- resources/js/components/commit-thread.tsx | 33 ++-- ...preview.tsx => gif-attachment-preview.tsx} | 8 +- ...icker-button.tsx => gif-picker-button.tsx} | 22 +-- ...icker-dialog.tsx => gif-picker-dialog.tsx} | 118 ++++++------ resources/js/hooks/use-stash.ts | 14 +- resources/js/lib/gif-storage.ts | 13 +- resources/js/lib/giphy.ts | 155 --------------- resources/js/types/commit.ts | 1 + resources/js/types/global.d.ts | 2 +- ...ommitGiphy.test.tsx => CommitGif.test.tsx} | 27 ++- tests/Unit/CommitGifRender.test.ts | 18 ++ tests/Unit/ComposerDraft.test.tsx | 41 ++-- tests/Unit/GifPickerDialog.test.tsx | 133 +++++++++++++ ....test.ts => GifPickerDialogRender.test.ts} | 4 +- tests/Unit/GifStorage.test.ts | 16 +- tests/Unit/Giphy.test.ts | 180 ------------------ tests/Unit/QuoteDraft.test.tsx | 2 +- ...t-giphy.config.ts => commit-gif.config.ts} | 2 +- tests/gif-picker.config.ts | 13 ++ tests/tsconfig.json | 6 +- 24 files changed, 369 insertions(+), 494 deletions(-) rename resources/js/components/{commit-giphy.tsx => commit-gif.tsx} (78%) rename resources/js/components/{giphy-attachment-preview.tsx => gif-attachment-preview.tsx} (86%) rename resources/js/components/{giphy-picker-button.tsx => gif-picker-button.tsx} (67%) rename resources/js/components/{giphy-picker-dialog.tsx => gif-picker-dialog.tsx} (69%) delete mode 100644 resources/js/lib/giphy.ts rename tests/Unit/{CommitGiphy.test.tsx => CommitGif.test.tsx} (76%) create mode 100644 tests/Unit/CommitGifRender.test.ts create mode 100644 tests/Unit/GifPickerDialog.test.tsx rename tests/Unit/{CommitGiphyRender.test.ts => GifPickerDialogRender.test.ts} (72%) delete mode 100644 tests/Unit/Giphy.test.ts rename tests/{commit-giphy.config.ts => commit-gif.config.ts} (83%) create mode 100644 tests/gif-picker.config.ts diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index e77451aa6..419d41b26 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -5,7 +5,7 @@ import { useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import { FoldedCommitBody } from '@/components/commit-body'; -import { CommitGiphy } from '@/components/commit-giphy'; +import { CommitGif } from '@/components/commit-gif'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; @@ -367,7 +367,7 @@ export function CommitCard({ {translation.notice} {showImages && } {showImages && commit.gif && ( - + )} {commit.poll && ( (null); const textBox = useRef(null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); - const pickGif = (gif: GiphyAttachment) => { + const pickGif = (gif: GifAttachment) => { attachments.clear(); setPoll(null); stash.updateGif(gif); @@ -267,7 +267,9 @@ function CommitComposerBody({
{preview.isPreviewing && stash.gif !== null && ( - + )} {issue.hint && ( @@ -313,7 +315,7 @@ function CommitComposerBody({ /> )} {!preview.isPreviewing && ( - stash.updateGif(null)} /> @@ -412,7 +414,7 @@ function CommitComposerBody({ className="-ml-2" /> {!isScheduling && ( - event.stopPropagation()} className="absolute right-1.5 bottom-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[11px] font-medium text-foreground backdrop-blur transition hover:bg-background" > - {gif.username - ? `GIF by @${gif.username} on GIPHY` - : 'via GIPHY'} + {credit} ); diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index a30fda47e..7a48bfdee 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -11,8 +11,8 @@ import { ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; -import { GiphyAttachmentPreview } from '@/components/giphy-attachment-preview'; -import { GiphyPickerButton } from '@/components/giphy-picker-button'; +import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; +import { GifPickerButton } from '@/components/gif-picker-button'; import InputError from '@/components/input-error'; import { MentionTextarea } from '@/components/mention-textarea'; import { QuotedCommit } from '@/components/quoted-commit'; @@ -33,7 +33,7 @@ import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; import { signIn } from '@/lib/auth'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; -import { giphyFormFields } from '@/lib/giphy'; +import { gifFormFields } from '@/lib/gif-providers'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, cn, isRejectedSubmission } from '@/lib/utils'; import type { Commit } from '@/types'; @@ -192,7 +192,7 @@ function QuoteComposer({ } transform={(data) => gif !== null - ? { ...data, ...giphyFormFields(gif) } + ? { ...data, ...gifFormFields(gif) } : images.length > 0 ? { ...data, @@ -278,7 +278,7 @@ function QuoteComposer({ onDescribe={attachments.describe} onRemove={attachments.remove} /> - updateGif(null)} /> @@ -303,7 +303,7 @@ function QuoteComposer({ }} className="size-8" /> - { diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 8431c5463..cb2fc2c52 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -15,7 +15,7 @@ import CommitReplyController from '@/actions/App/Http/Controllers/CommitReplyCon import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; import { FoldedCommitBody } from '@/components/commit-body'; -import { CommitGiphy } from '@/components/commit-giphy'; +import { CommitGif } from '@/components/commit-gif'; import { ImageDropOverlay, ImagePickerButton, @@ -33,8 +33,8 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; -import { GiphyAttachmentPreview } from '@/components/giphy-attachment-preview'; -import { GiphyPickerButton } from '@/components/giphy-picker-button'; +import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; +import { GifPickerButton } from '@/components/gif-picker-button'; import { RailDot } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; @@ -66,7 +66,7 @@ import { useUser } from '@/hooks/use-user'; import { signIn } from '@/lib/auth'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; import { commitStateKey } from '@/lib/commit-state'; -import { giphyFormFields } from '@/lib/giphy'; +import { gifFormFields } from '@/lib/gif-providers'; import { hasModKey } from '@/lib/keyboard'; import { characterCount, @@ -1145,13 +1145,14 @@ function ReplyComposer({ images: File[]; image_descriptions: string[]; link_preview_url?: string; - giphy_id?: string; - giphy_url?: string; - giphy_page_url?: string; - giphy_title?: string; - giphy_username?: string; - giphy_width?: number; - giphy_height?: number; + gif_provider?: 'giphy' | 'klipy'; + gif_id?: string; + gif_url?: string; + gif_page_url?: string; + gif_title?: string; + gif_username?: string; + gif_width?: number; + gif_height?: number; }, CreatedReply >({ @@ -1223,7 +1224,7 @@ function ReplyComposer({ body, images: gif === null ? images : [], image_descriptions: gif === null ? descriptions : [], - ...(gif !== null ? giphyFormFields(gif) : {}), + ...(gif !== null ? gifFormFields(gif) : {}), ...(linkCard.isChosen ? { link_preview_url: linkCard.link ?? '' } : {}), @@ -1297,7 +1298,9 @@ function ReplyComposer({ /> )}
- {preview.isPreviewing && gif !== null && } + {preview.isPreviewing && gif !== null && ( + + )} )} {!preview.isPreviewing && ( - updateGif(null)} /> @@ -1342,7 +1345,7 @@ function ReplyComposer({ }} className="size-8" /> - { diff --git a/resources/js/components/giphy-attachment-preview.tsx b/resources/js/components/gif-attachment-preview.tsx similarity index 86% rename from resources/js/components/giphy-attachment-preview.tsx rename to resources/js/components/gif-attachment-preview.tsx index f1155ecb1..a416f1fea 100644 --- a/resources/js/components/giphy-attachment-preview.tsx +++ b/resources/js/components/gif-attachment-preview.tsx @@ -1,11 +1,11 @@ import { X } from 'lucide-react'; -import type { GiphyAttachment } from '@/lib/giphy'; +import type { GifAttachment } from '@/lib/gif-providers'; -export function GiphyAttachmentPreview({ +export function GifAttachmentPreview({ gif, onRemove, }: { - gif: GiphyAttachment | null; + gif: GifAttachment | null; onRemove: () => void; }) { if (gif === null) { @@ -16,7 +16,7 @@ export function GiphyAttachmentPreview({
void; + onPick: (gif: GifAttachment) => void; className?: string; }) { - const { giphy } = usePage().props; + const { gif } = usePage().props; const [open, setOpen] = useState(false); - if (giphy === null) { + if (gif === null) { return null; } @@ -42,8 +41,9 @@ export function GiphyPickerButton({ > - void; - onPick: (gif: GiphyAttachment) => void; + onPick: (gif: GifAttachment) => void; }) { const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); + const [nextCursor, setNextCursor] = useState(null); const [status, setStatus] = useState('loading'); const [isLoadingMore, setIsLoadingMore] = useState(false); const [reloads, setReloads] = useState(0); - const urlFor = (offset: number) => - query.trim() === '' - ? giphyTrendingUrl(apiKey, offset) - : giphySearchUrl(apiKey, query.trim(), offset); - useEffect(() => { if (!open) { return; @@ -52,9 +46,22 @@ export function GiphyPickerDialog({ const timeout = setTimeout( () => { setStatus('loading'); - fetchGifs(urlFor(0), controller.signal) - .then((gifs) => { - setResults(gifs); + const trimmed = query.trim(); + const request = + trimmed === '' + ? provider.trending(apiKey, { + cursor: null, + signal: controller.signal, + }) + : provider.search(apiKey, trimmed, { + cursor: null, + signal: controller.signal, + }); + + request + .then((page) => { + setResults(page.results); + setNextCursor(page.nextCursor); setStatus('ready'); }) .catch((error: unknown) => { @@ -63,7 +70,7 @@ export function GiphyPickerDialog({ } setStatus( - error instanceof GiphyRateLimited + error instanceof GifRateLimited ? 'rate-limited' : 'error', ); @@ -76,24 +83,29 @@ export function GiphyPickerDialog({ clearTimeout(timeout); controller.abort(); }; - }, [open, query, apiKey, reloads]); + }, [open, query, apiKey, reloads, provider]); const loadMore = () => { setIsLoadingMore(true); - fetchGifs(urlFor(results.length)) - .then((gifs) => setResults((current) => [...current, ...gifs])) + const trimmed = query.trim(); + const request = + trimmed === '' + ? provider.trending(apiKey, { cursor: nextCursor }) + : provider.search(apiKey, trimmed, { cursor: nextCursor }); + + request + .then((page) => { + setResults((current) => [...current, ...page.results]); + setNextCursor(page.nextCursor); + }) .catch(() => undefined) .finally(() => setIsLoadingMore(false)); }; - const pick = (result: GiphyResult) => { - const gif = attachmentFromResult(result); - - if (gif !== null) { - onPick(gif); - onOpenChange(false); - setQuery(''); - } + const pick = (result: GifResult) => { + onPick(attachmentFromResult(provider.id, result)); + onOpenChange(false); + setQuery(''); }; return ( @@ -102,19 +114,19 @@ export function GiphyPickerDialog({ Attach a GIF - Search GIPHY and pick a GIF to attach. + Search {provider.label} and pick a GIF to attach. setQuery(event.target.value)} - placeholder="grep giphy…" - aria-label="Search GIPHY" + placeholder={provider.searchPlaceholder} + aria-label={provider.searchPlaceholder} autoComplete="off" spellCheck={false} className="h-9 w-full min-w-0 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="giphy-search-input" + data-test="gif-search-input" /> {status === 'loading' && (

@@ -123,14 +135,14 @@ export function GiphyPickerDialog({ )} {status === 'rate-limited' && (

- You’ve hit your GIPHY key’s search rate limit. Try again - shortly. + You’ve hit your {provider.label} key’s search rate + limit. Try again shortly.

)} {status === 'error' && (

- GIPHY did not answer. + {provider.label} did not answer.

- ); - })} + ), + )} - {results.length % pageSize === 0 && ( + {nextCursor !== null && (
diff --git a/resources/js/hooks/use-stash.ts b/resources/js/hooks/use-stash.ts index e223b921d..dc79e7017 100644 --- a/resources/js/hooks/use-stash.ts +++ b/resources/js/hooks/use-stash.ts @@ -6,8 +6,8 @@ import { revokeObjectUrls, } from '@/components/commit-image-picker'; import { cancelImageRead, cancelImageReads } from '@/lib/draft-image-reads'; +import type { GifAttachment } from '@/lib/gif-providers/types'; import { readStoredGif, writeStoredGif } from '@/lib/gif-storage'; -import type { GiphyAttachment } from '@/lib/giphy'; import type { StoredImages } from '@/lib/image-storage'; import { deleteStoredImages, @@ -22,18 +22,18 @@ export type UseStashReturn = { readonly body: string; readonly images: File[]; readonly descriptions: string[]; - readonly gif: GiphyAttachment | null; + readonly gif: GifAttachment | null; readonly isRestored: boolean; readonly updateBody: (body: string) => void; readonly updateImages: (images: File[], descriptions?: string[]) => void; - readonly updateGif: (gif: GiphyAttachment | null) => void; + readonly updateGif: (gif: GifAttachment | null) => void; readonly drop: () => void; }; const listeners = new Set<() => void>(); const pending = new Map(); const images = new Map(); -const gifs = new Map(); +const gifs = new Map(); const restoredImageKeys = new Set(); const noImages: StoredImages = { images: [], descriptions: [] }; let timeout: ReturnType | undefined; @@ -50,7 +50,7 @@ const read = (key: string): string => const imagesFor = (key: string): StoredImages => images.get(key) ?? noImages; -const gifFor = (key: string): GiphyAttachment | null => { +const gifFor = (key: string): GifAttachment | null => { if (!gifs.has(key)) { gifs.set(key, readStoredGif(key)); } @@ -58,7 +58,7 @@ const gifFor = (key: string): GiphyAttachment | null => { return gifs.get(key) ?? null; }; -const storeGif = (key: string, gif: GiphyAttachment | null): void => { +const storeGif = (key: string, gif: GifAttachment | null): void => { gifs.set(key, gif); writeStoredGif(key, gif); notify(); @@ -278,7 +278,7 @@ export function useStash(userId: string, name: string): UseStashReturn { storeImages(key, next, descriptions); }; - const updateGif = (next: GiphyAttachment | null): void => { + const updateGif = (next: GifAttachment | null): void => { if (next === null && body === '' && draftImages.length === 0) { setIsDraftRestored(false); } diff --git a/resources/js/lib/gif-storage.ts b/resources/js/lib/gif-storage.ts index 82864a89b..5f6b4a919 100644 --- a/resources/js/lib/gif-storage.ts +++ b/resources/js/lib/gif-storage.ts @@ -1,10 +1,10 @@ -import type { GiphyAttachment } from './giphy.ts'; +import type { GifAttachment } from './gif-providers/types.ts'; import { readStorage, writeStorage } from './storage.ts'; const gifKey = (key: string): string => `${key}:gif`; /** A stashed GIF is tiny JSON in localStorage; images are Files in IndexedDB. */ -export function readStoredGif(key: string): GiphyAttachment | null { +export function readStoredGif(key: string): GifAttachment | null { const raw = readStorage(gifKey(key)); if (raw === '') { @@ -12,18 +12,19 @@ export function readStoredGif(key: string): GiphyAttachment | null { } try { - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as Partial; return typeof parsed.id === 'string' && typeof parsed.url === 'string' && - typeof parsed.page_url === 'string' - ? (parsed as GiphyAttachment) + typeof parsed.pageUrl === 'string' && + (parsed.provider === 'giphy' || parsed.provider === 'klipy') + ? (parsed as GifAttachment) : null; } catch { return null; } } -export function writeStoredGif(key: string, gif: GiphyAttachment | null): void { +export function writeStoredGif(key: string, gif: GifAttachment | null): void { writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); } diff --git a/resources/js/lib/giphy.ts b/resources/js/lib/giphy.ts deleted file mode 100644 index 0b784c31f..000000000 --- a/resources/js/lib/giphy.ts +++ /dev/null @@ -1,155 +0,0 @@ -export type GiphyAttachment = { - id: string; - url: string; - page_url: string; - title: string | null; - username: string | null; - width: number; - height: number; -}; - -type GiphyRendition = { url: string; width: string; height: string }; - -export type GiphyResult = { - id: string; - title: string; - username: string; - url: string; - images: Partial< - Record< - | 'downsized_medium' - | 'downsized' - | 'original' - | 'fixed_height_small', - GiphyRendition - > - >; -}; - -export class GiphyRateLimited extends Error { - constructor() { - super('Your GIPHY key is rate limiting searches.'); - } -} - -export const pageSize = 24; - -const baseUrl = 'https://api.giphy.com/v1/gifs'; - -const params = (key: string, offset: number): URLSearchParams => - new URLSearchParams({ - api_key: key, - limit: String(pageSize), - offset: String(offset), - rating: 'g', - }); - -export function giphySearchUrl(key: string, query: string, offset = 0): string { - const search = params(key, offset); - search.set('q', query); - - return `${baseUrl}/search?${search}`; -} - -export function giphyTrendingUrl(key: string, offset = 0): string { - return `${baseUrl}/trending?${params(key, offset)}`; -} - -/** GIPHY requires client-side API calls, so this runs in the browser. */ -export async function fetchGifs( - url: string, - signal?: AbortSignal, -): Promise { - const response = await fetch(url, { signal }); - - if (response.status === 429) { - throw new GiphyRateLimited(); - } - - if (!response.ok) { - throw new Error(`GIPHY request failed: ${response.status}`); - } - - const data = (await response.json()) as { data: GiphyResult[] }; - - return data.data; -} - -export type GiphyKeyValidation = - | 'valid' - | 'invalid' - | 'rate-limited' - | 'unreachable'; - -/** Verifies a pasted key from the browser; a throttled key is still real. */ -export async function validateGiphyKey( - key: string, -): Promise { - let response: Response; - - try { - response = await fetch(giphyTrendingUrl(key)); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} - -/** Small fixed-height rendition for the picker grid. */ -export function previewUrlFor(result: GiphyResult): string | null { - const rendition = - result.images.fixed_height_small ?? - result.images.downsized ?? - result.images.downsized_medium ?? - result.images.original; - - return rendition?.url ?? null; -} - -/** Stored rendition: a .gif around feed size, kept verbatim per GIPHY's terms. */ -export function attachmentFromResult( - result: GiphyResult, -): GiphyAttachment | null { - const rendition = - result.images.downsized_medium ?? - result.images.downsized ?? - result.images.original; - - if (rendition === undefined) { - return null; - } - - return { - id: result.id, - url: rendition.url, - page_url: result.url, - title: result.title === '' ? null : result.title, - username: result.username === '' ? null : result.username, - width: Number.parseInt(rendition.width, 10), - height: Number.parseInt(rendition.height, 10), - }; -} - -/** Hidden fields every composer submits for an attached GIF. */ -export function giphyFormFields( - gif: GiphyAttachment, -): Record { - return { - giphy_id: gif.id, - giphy_url: gif.url, - giphy_page_url: gif.page_url, - giphy_title: gif.title ?? '', - giphy_username: gif.username ?? '', - giphy_width: gif.width, - giphy_height: gif.height, - }; -} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index bfa140d6c..e14475f19 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -48,6 +48,7 @@ export type CommitImage = { }; export type CommitGif = { + provider: 'giphy' | 'klipy'; id: string; url: string; page_url: string; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 0560e72c9..e5773a3b6 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -18,7 +18,7 @@ declare module '@inertiajs/core' { hasUnmoderatedCommits: boolean; sidebarOpen: boolean; showsWhoToFollow: boolean; - giphy: { key: string } | null; + gif: { provider: 'giphy' | 'klipy'; key: string } | null; dimsLanes: boolean; repliesPerPage: number; suggestedUsers?: SuggestedUser[]; diff --git a/tests/Unit/CommitGiphy.test.tsx b/tests/Unit/CommitGif.test.tsx similarity index 76% rename from tests/Unit/CommitGiphy.test.tsx rename to tests/Unit/CommitGif.test.tsx index 6a0814bc8..5e0f26726 100644 --- a/tests/Unit/CommitGiphy.test.tsx +++ b/tests/Unit/CommitGif.test.tsx @@ -2,10 +2,11 @@ import assert from 'node:assert/strict'; import type { ReactElement, SyntheticEvent } from 'react'; import { test } from 'vite-plus/test'; -import { CommitGiphy } from '../../resources/js/components/commit-giphy'; -import type { CommitGif } from '../../resources/js/types/commit'; +import { CommitGif } from '../../resources/js/components/commit-gif'; +import type { CommitGif as CommitGifData } from '../../resources/js/types/commit'; -const gif: CommitGif = { +const gif: CommitGifData = { + provider: 'giphy', id: 'xT9IgzoKnwFNmISR8I', url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', @@ -15,8 +16,8 @@ const gif: CommitGif = { height: 270, }; -function renderedGiphy(overrides: Partial = {}) { - const figure = CommitGiphy({ gif: { ...gif, ...overrides } }); +function renderedGif(overrides: Partial = {}) { + const figure = CommitGif({ gif: { ...gif, ...overrides } }); const [image, attribution] = figure.props.children as [ ReactElement<{ src: string; @@ -32,7 +33,7 @@ function renderedGiphy(overrides: Partial = {}) { } test('hotlinks the giphy url verbatim with its query string intact', () => { - const { image } = renderedGiphy(); + const { image } = renderedGif(); assert.equal( image.props.src, @@ -44,13 +45,13 @@ test('hotlinks the giphy url verbatim with its query string intact', () => { }); test('falls back to a generic alt when giphy has no title', () => { - const { image } = renderedGiphy({ title: null }); + const { image } = renderedGif({ title: null }); assert.equal(image.props.alt, 'Attached GIF'); }); test('credits the giphy uploader with a link to the gif page', () => { - const { attribution } = renderedGiphy(); + const { attribution } = renderedGif(); assert.equal(attribution.props.href, gif.page_url); assert.equal(attribution.props.target, '_blank'); @@ -59,11 +60,17 @@ test('credits the giphy uploader with a link to the gif page', () => { }); test('falls back to the plain giphy credit when the uploader is unknown', () => { - const { attribution } = renderedGiphy({ username: null }); + const { attribution } = renderedGif({ username: null }); assert.equal(attribution.props.children, 'via GIPHY'); }); +test('credits klipy without a giphy-style username', () => { + const { attribution } = renderedGif({ provider: 'klipy', username: null }); + + assert.equal(attribution.props.children, 'via KLIPY'); +}); + test('replaces a failed gif with the site logo without retrying the fallback', () => { let replacements = 0; let source = gif.url; @@ -83,7 +90,7 @@ test('replaces a failed gif with the site logo without retrying the fallback', ( const event = { currentTarget: target, } as unknown as SyntheticEvent; - const { onError } = renderedGiphy().image.props; + const { onError } = renderedGif().image.props; onError(event); assert.equal(source, '/favicon.svg'); diff --git a/tests/Unit/CommitGifRender.test.ts b/tests/Unit/CommitGifRender.test.ts new file mode 100644 index 000000000..fd9a2218e --- /dev/null +++ b/tests/Unit/CommitGifRender.test.ts @@ -0,0 +1,18 @@ +import { execFileSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +await test('renders attached gifs with provider attribution on commit cards', () => { + const cli = fileURLToPath( + new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), + ); + + execFileSync( + process.execPath, + [cli, 'test', 'run', '--config', 'tests/commit-gif.config.ts'], + { + cwd: fileURLToPath(new URL('../..', import.meta.url)), + stdio: 'inherit', + }, + ); +}); diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index de977e029..363a074a1 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -4,17 +4,19 @@ import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { beforeEach, test, vi } from 'vite-plus/test'; import { cancelImageReads } from '../../resources/js/lib/draft-image-reads'; -import type { GiphyAttachment } from '../../resources/js/lib/giphy'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers/types'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); -const gif: GiphyAttachment = { +const gif: GifAttachment = { + provider: 'giphy', id: 'xT9IgzoKnwFNmISR8I', url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', - page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', title: 'Deal With It GIF', username: 'giphy', width: 480, height: 270, + previewUrl: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', }; let state: unknown[] = []; let cursor = 0; @@ -23,14 +25,14 @@ const draft = { body: 'A restored draft https://example.com', images: [] as File[], descriptions: [] as string[], - gif: null as GiphyAttachment | null, + gif: null as GifAttachment | null, isRestored: true, updateBody: vi.fn(), updateImages: vi.fn((images: File[], descriptions: string[]) => { draft.images = images; draft.descriptions = descriptions; }), - updateGif: vi.fn((picked: GiphyAttachment | null) => { + updateGif: vi.fn((picked: GifAttachment | null) => { draft.gif = picked; }), drop: vi.fn(), @@ -76,7 +78,7 @@ vi.doMock('@inertiajs/react', async () => ({ usePage: () => ({ props: { auth: { user, isAdmin: false }, - giphy: { key: 'test' }, + gif: { provider: 'giphy', key: 'test' }, }, }), useHttp: () => http, @@ -96,8 +98,8 @@ const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); -const { GiphyPickerButton } = - await import('../../resources/js/components/giphy-picker-button'); +const { GifPickerButton } = + await import('../../resources/js/components/gif-picker-button'); const { ComposerPreviewBody, PreviewToggle } = await import('../../resources/js/components/composer-preview'); const { MentionTextarea } = @@ -441,7 +443,7 @@ test('previews restored images and descriptions while keeping them attached when }); }); -test('picking a gif clears attached images and any poll and submits the giphy fields', () => { +test('picking a gif clears attached images and any poll and submits the gif fields', () => { draft.images = [image]; draft.descriptions = ['A restored screenshot']; let view = render(); @@ -458,10 +460,10 @@ test('picking a gif clears attached images and any poll and submits the giphy fi view.fields.some((element) => element.type === PollEditor), true, ); - const giphyButton = view.fields.find( - (element) => element.type === GiphyPickerButton, + const gifButton = view.fields.find( + (element) => element.type === GifPickerButton, )!; - (giphyButton.props.onPick as (picked: GiphyAttachment) => void)(gif); + (gifButton.props.onPick as (picked: GifAttachment) => void)(gif); assert.deepEqual(draft.images, []); assert.deepEqual(draft.descriptions, []); assert.equal(draft.gif, gif); @@ -472,15 +474,16 @@ test('picking a gif clears attached images and any poll and submits the giphy fi ); assert.deepEqual(view.form.props.transform({ body: draft.body }), { body: draft.body, - giphy_id: 'xT9IgzoKnwFNmISR8I', - giphy_url: + gif_provider: 'giphy', + gif_id: 'xT9IgzoKnwFNmISR8I', + gif_url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', - giphy_page_url: + gif_page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - giphy_title: 'Deal With It GIF', - giphy_username: 'giphy', - giphy_width: 480, - giphy_height: 270, + gif_title: 'Deal With It GIF', + gif_username: 'giphy', + gif_width: 480, + gif_height: 270, }); } finally { vi.unstubAllGlobals(); diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx new file mode 100644 index 000000000..c6d51e30a --- /dev/null +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -0,0 +1,133 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; +import type { GifProvider } from '../../resources/js/lib/gif-providers/types'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), +})); + +const provider: GifProvider = { + id: 'klipy', + label: 'KLIPY', + searchPlaceholder: 'Search KLIPY', + attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, + search: vi.fn(async () => ({ + results: [ + { + id: 'one', + url: 'https://static.klipy.com/ii/one.gif', + pageUrl: 'https://klipy.com/gifs/one', + title: 'One', + username: null, + width: 100, + height: 100, + previewUrl: 'https://static.klipy.com/ii/one-preview.gif', + }, + ], + nextCursor: null, + })), + trending: vi.fn(async () => ({ results: [], nextCursor: null })), + validateKey: vi.fn(async () => 'valid' as const), +}; + +const { GifPickerDialog } = await import( + '../../resources/js/components/gif-picker-dialog' +); + +type Element = ReactElement>; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(open = true): Element[] { + cursor = 0; + + return elements( + GifPickerDialog({ provider, apiKey: 'k', open, onOpenChange: vi.fn(), onPick: vi.fn() }), + ); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +beforeEach(() => { + state = []; + cursor = 0; + vi.clearAllMocks(); +}); + +test('uses the provider placeholder and attribution', () => { + const fields = render(false); + + assert.equal(field(fields, 'gif-search-input').props.placeholder, 'Search KLIPY'); + assert.ok( + fields.some( + (element) => + element.props.children === 'Powered by KLIPY' || + element.props.href === 'https://klipy.com', + ), + ); +}); + +test('searches through the injected provider', async () => { + render(); + await vi.waitFor(() => { + assert.equal((provider.trending as ReturnType).mock.calls.length, 1); + }); + assert.equal( + (provider.search as ReturnType).mock.calls.length, + 0, + ); +}); diff --git a/tests/Unit/CommitGiphyRender.test.ts b/tests/Unit/GifPickerDialogRender.test.ts similarity index 72% rename from tests/Unit/CommitGiphyRender.test.ts rename to tests/Unit/GifPickerDialogRender.test.ts index 26c7d350a..6faf1b3c5 100644 --- a/tests/Unit/CommitGiphyRender.test.ts +++ b/tests/Unit/GifPickerDialogRender.test.ts @@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; -await test('renders attached gifs with giphy attribution on commit cards', () => { +await test('renders the gif picker for an injected provider', () => { const cli = fileURLToPath( new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), ); execFileSync( process.execPath, - [cli, 'test', 'run', '--config', 'tests/commit-giphy.config.ts'], + [cli, 'test', 'run', '--config', 'tests/gif-picker.config.ts'], { cwd: fileURLToPath(new URL('../..', import.meta.url)), stdio: 'inherit', diff --git a/tests/Unit/GifStorage.test.ts b/tests/Unit/GifStorage.test.ts index 0bfd1a5fd..e5dd6a3ac 100644 --- a/tests/Unit/GifStorage.test.ts +++ b/tests/Unit/GifStorage.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers/types.ts'; const store = new Map(); (globalThis as Record).window = { @@ -14,14 +15,17 @@ const { readStoredGif, writeStoredGif } = await import( '../../resources/js/lib/gif-storage.ts' ); -const gif = { +const gif: GifAttachment = { + provider: 'giphy', id: 'xT9IgzoKnwFNmISR8I', url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', - page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', title: 'Deal With It GIF', username: 'giphy', width: 480, height: 270, + previewUrl: + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc123', }; await test('round-trips a stashed gif', () => { @@ -41,3 +45,11 @@ await test('ignores unreadable stashed values', () => { store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); assert.equal(readStoredGif('stash:u1:reply-2'), null); }); + +await test('rejects a stashed gif with an unknown provider', () => { + store.set( + 'stash:u1:reply-3:gif', + JSON.stringify({ ...gif, provider: 'nope' }), + ); + assert.equal(readStoredGif('stash:u1:reply-3'), null); +}); diff --git a/tests/Unit/Giphy.test.ts b/tests/Unit/Giphy.test.ts deleted file mode 100644 index c4b0e0743..000000000 --- a/tests/Unit/Giphy.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - attachmentFromResult, - giphyFormFields, - giphySearchUrl, - giphyTrendingUrl, - previewUrlFor, - validateGiphyKey, -} from '../../resources/js/lib/giphy.ts'; -import type { GiphyResult } from '../../resources/js/lib/giphy.ts'; - -const result: GiphyResult = { - id: 'xT9IgzoKnwFNmISR8I', - title: 'Deal With It GIF', - username: 'giphy', - url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - images: { - fixed_height_small: { - url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', - width: '178', - height: '100', - }, - downsized_medium: { - url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', - width: '480', - height: '270', - }, - downsized: { - url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', - width: '400', - height: '225', - }, - }, -}; - -await test('builds a search url with the g rating always on', () => { - const url = new URL(giphySearchUrl('key-123', 'deal with it', 24)); - assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); - assert.equal(url.searchParams.get('api_key'), 'key-123'); - assert.equal(url.searchParams.get('q'), 'deal with it'); - assert.equal(url.searchParams.get('rating'), 'g'); - assert.equal(url.searchParams.get('offset'), '24'); -}); - -await test('builds a trending url with the g rating always on', () => { - const url = new URL(giphyTrendingUrl('key-123')); - assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/trending'); - assert.equal(url.searchParams.get('rating'), 'g'); - assert.equal(url.searchParams.get('offset'), '0'); -}); - -await test('picks the downsized_medium gif rendition verbatim', () => { - const gif = attachmentFromResult(result); - assert.deepEqual(gif, { - id: 'xT9IgzoKnwFNmISR8I', - url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', - page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - title: 'Deal With It GIF', - username: 'giphy', - width: 480, - height: 270, - }); -}); - -await test('falls back through downsized to original and bails without any', () => { - const { downsized_medium: _dropped, ...rest } = result.images; - const fallback = attachmentFromResult({ ...result, images: rest }); - assert.equal( - fallback?.url, - 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', - ); - assert.equal(attachmentFromResult({ ...result, images: {} }), null); -}); - -await test('turns empty title and username into null', () => { - const gif = attachmentFromResult({ ...result, title: '', username: '' }); - assert.equal(gif?.title, null); - assert.equal(gif?.username, null); -}); - -await test('uses the small fixed-height rendition for previews', () => { - assert.equal( - previewUrlFor(result), - 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', - ); - assert.equal( - previewUrlFor({ ...result, images: { downsized: result.images.downsized } }), - 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized.gif?cid=abc', - ); - assert.equal(previewUrlFor({ ...result, images: {} }), null); -}); - -await test('shapes the form fields the server expects', () => { - const gif = attachmentFromResult(result); - assert.ok(gif); - assert.deepEqual(giphyFormFields(gif), { - giphy_id: 'xT9IgzoKnwFNmISR8I', - giphy_url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', - giphy_page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - giphy_title: 'Deal With It GIF', - giphy_username: 'giphy', - giphy_width: 480, - giphy_height: 270, - }); -}); - -const originalFetch = globalThis.fetch; - -function stubFetch(result: number | 'network'): string[] { - const calls: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL) => { - calls.push(String(input)); - - if (result === 'network') { - throw new TypeError('fetch failed'); - } - - return new Response('{}', { status: result }); - }) as typeof fetch; - - return calls; -} - -function restoreFetch(): void { - globalThis.fetch = originalFetch; -} - -await test('validates a key against the trending endpoint', async () => { - const calls = stubFetch(200); - - try { - assert.equal(await validateGiphyKey('key-123'), 'valid'); - assert.ok(calls[0]?.includes('api_key=key-123')); - assert.ok(calls[0]?.includes('rating=g')); - } finally { - restoreFetch(); - } -}); - -await test('treats a refused key as invalid', async () => { - for (const status of [401, 403]) { - stubFetch(status); - - try { - assert.equal(await validateGiphyKey('key-123'), 'invalid'); - } finally { - restoreFetch(); - } - } -}); - -await test('treats a throttled key as rate limited but real', async () => { - stubFetch(429); - - try { - assert.equal(await validateGiphyKey('key-123'), 'rate-limited'); - } finally { - restoreFetch(); - } -}); - -await test('treats network failures and unexpected statuses as unreachable', async () => { - stubFetch('network'); - - try { - assert.equal(await validateGiphyKey('key-123'), 'unreachable'); - } finally { - restoreFetch(); - } - - stubFetch(500); - - try { - assert.equal(await validateGiphyKey('key-123'), 'unreachable'); - } finally { - restoreFetch(); - } -}); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index dc7016a50..8e58eb113 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -37,7 +37,7 @@ vi.doMock('react', () => ({ })); vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { auth: { user } } }), + usePage: () => ({ props: { auth: { user }, gif: null } }), })); vi.doMock('../../resources/js/hooks/use-user', () => ({ useUser: () => ({ user }), diff --git a/tests/commit-giphy.config.ts b/tests/commit-gif.config.ts similarity index 83% rename from tests/commit-giphy.config.ts rename to tests/commit-gif.config.ts index 1e8801dd6..6f74d45a8 100644 --- a/tests/commit-giphy.config.ts +++ b/tests/commit-gif.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ }, }, test: { - include: ['tests/Unit/CommitGiphy.test.tsx'], + include: ['tests/Unit/CommitGif.test.tsx'], }, }); diff --git a/tests/gif-picker.config.ts b/tests/gif-picker.config.ts new file mode 100644 index 000000000..040573863 --- /dev/null +++ b/tests/gif-picker.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('../resources/js', import.meta.url)), + }, + }, + test: { + include: ['tests/Unit/GifPickerDialog.test.tsx'], + }, +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index ec078dbfb..ceadc2e57 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -7,7 +7,8 @@ "include": [ "Unit/**/*.test.ts", "Unit/DraftStash.test.tsx", - "Unit/CommitGiphy.test.tsx", + "Unit/CommitGif.test.tsx", + "Unit/GifPickerDialog.test.tsx", "Unit/GifKeySetting.test.tsx", "Unit/ComposerDraft.test.tsx", "Unit/SearchDialogComposition.test.tsx", @@ -15,7 +16,8 @@ "Unit/ImageAttachments.test.tsx", "../resources/js/types/*.d.ts", "draft-images.config.ts", - "commit-giphy.config.ts", + "commit-gif.config.ts", + "gif-picker.config.ts", "gif-key.config.ts" ] } From 398686a187fec974a901bdffb6a0fd7900480437 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:37:01 -0400 Subject: [PATCH 50/83] fix: surface gif errors and harden the picker --- .../js/components/commit-image-picker.tsx | 2 +- resources/js/components/gif-picker-dialog.tsx | 24 ++- tests/Unit/ComposerDraft.test.tsx | 27 +++- tests/Unit/GifPickerDialog.test.tsx | 150 ++++++++++++++---- 4 files changed, 169 insertions(+), 34 deletions(-) diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 2b0942b97..2f08ebc30 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -53,7 +53,7 @@ export function imageErrorFrom( name === 'images' || name.startsWith('images.') || name.startsWith('image_descriptions') || - name.startsWith('giphy_'), + name.startsWith('gif_'), ); return key ? errors[key] : undefined; diff --git a/resources/js/components/gif-picker-dialog.tsx b/resources/js/components/gif-picker-dialog.tsx index 14c204ac7..3bd39f836 100644 --- a/resources/js/components/gif-picker-dialog.tsx +++ b/resources/js/components/gif-picker-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -36,8 +36,11 @@ export function GifPickerDialog({ const [status, setStatus] = useState('loading'); const [isLoadingMore, setIsLoadingMore] = useState(false); const [reloads, setReloads] = useState(0); + const generation = useRef(0); useEffect(() => { + const currentGeneration = ++generation.current; + if (!open) { return; } @@ -45,6 +48,7 @@ export function GifPickerDialog({ const controller = new AbortController(); const timeout = setTimeout( () => { + setIsLoadingMore(false); setStatus('loading'); const trimmed = query.trim(); const request = @@ -60,6 +64,13 @@ export function GifPickerDialog({ request .then((page) => { + if ( + controller.signal.aborted || + currentGeneration !== generation.current + ) { + return; + } + setResults(page.results); setNextCursor(page.nextCursor); setStatus('ready'); @@ -86,6 +97,7 @@ export function GifPickerDialog({ }, [open, query, apiKey, reloads, provider]); const loadMore = () => { + const currentGeneration = generation.current; setIsLoadingMore(true); const trimmed = query.trim(); const request = @@ -95,11 +107,19 @@ export function GifPickerDialog({ request .then((page) => { + if (currentGeneration !== generation.current) { + return; + } + setResults((current) => [...current, ...page.results]); setNextCursor(page.nextCursor); }) .catch(() => undefined) - .finally(() => setIsLoadingMore(false)); + .finally(() => { + if (currentGeneration === generation.current) { + setIsLoadingMore(false); + } + }); }; const pick = (result: GifResult) => { diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index 363a074a1..3d1c6ffa7 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -94,6 +94,8 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ const { CommitComposer } = await import('../../resources/js/components/commit-composer'); +const { default: InputError } = + await import('../../resources/js/components/input-error'); const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = @@ -130,11 +132,11 @@ function elements(node: ReactNode): Element[] { ]; } -function render() { +function render(errors: Record = {}) { cursor = 0; const form = CommitComposer({}); const fields = elements( - form.props.children({ processing: false, errors: {} }), + form.props.children({ processing: false, errors }), ); return { form, fields }; } @@ -200,6 +202,27 @@ test('preserves restored images and link cards and allows scheduling them', () = } }); +test('surfaces gif validation errors and ignores unrelated ones', () => { + const gifError = render({ gif_url: 'The GIF could not be attached.' }); + assert.ok( + gifError.fields.some( + (element) => + element.type === InputError && + element.props.message === 'The GIF could not be attached.', + ), + ); + + const unrelated = render({ unrelated_field: 'Not an attachment error.' }); + assert.equal( + unrelated.fields.some( + (element) => + element.type === InputError && + element.props.message === 'Not an attachment error.', + ), + false, + ); +}); + test('removing restored images enables polls and successful posting resets the poll', () => { draft.images = [image]; draft.descriptions = ['A restored screenshot']; diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index c6d51e30a..c4518fa72 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -39,27 +39,33 @@ vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), })); +const result = { + id: 'one', + url: 'https://static.klipy.com/ii/one.gif', + pageUrl: 'https://klipy.com/gifs/one', + title: 'One', + username: null, + width: 100, + height: 100, + previewUrl: 'https://static.klipy.com/ii/one-preview.gif', +}; + +const search = vi.fn(async () => ({ + results: [result], + nextCursor: null, +})); +const trending = vi.fn(async () => ({ + results: [result], + nextCursor: null, +})); + const provider: GifProvider = { id: 'klipy', - label: 'KLIPY', - searchPlaceholder: 'Search KLIPY', - attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, - search: vi.fn(async () => ({ - results: [ - { - id: 'one', - url: 'https://static.klipy.com/ii/one.gif', - pageUrl: 'https://klipy.com/gifs/one', - title: 'One', - username: null, - width: 100, - height: 100, - previewUrl: 'https://static.klipy.com/ii/one-preview.gif', - }, - ], - nextCursor: null, - })), - trending: vi.fn(async () => ({ results: [], nextCursor: null })), + label: 'FAUX', + searchPlaceholder: 'Find FAUX GIFs', + attribution: { label: 'Powered by FAUX', href: 'https://faux.example' }, + search, + trending, validateKey: vi.fn(async () => 'valid' as const), }; @@ -84,14 +90,24 @@ function elements(node: ReactNode): Element[] { ]; } -function render(open = true): Element[] { +function render(open = true, onPick = vi.fn()): Element[] { cursor = 0; return elements( - GifPickerDialog({ provider, apiKey: 'k', open, onOpenChange: vi.fn(), onPick: vi.fn() }), + GifPickerDialog({ + provider, + apiKey: 'k', + open, + onOpenChange: vi.fn(), + onPick, + }), ); } +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + function field(fields: Element[], testId: string): Element { const found = fields.find( (element) => element.props['data-test'] === testId, @@ -106,17 +122,29 @@ beforeEach(() => { state = []; cursor = 0; vi.clearAllMocks(); + search.mockImplementation(async () => ({ + results: [result], + nextCursor: null, + })); + trending.mockImplementation(async () => ({ + results: [result], + nextCursor: null, + })); }); test('uses the provider placeholder and attribution', () => { const fields = render(false); - assert.equal(field(fields, 'gif-search-input').props.placeholder, 'Search KLIPY'); + assert.equal( + field(fields, 'gif-search-input').props.placeholder, + 'Find FAUX GIFs', + ); + assert.ok( + fields.some((element) => element.props.children === 'Powered by FAUX'), + ); assert.ok( fields.some( - (element) => - element.props.children === 'Powered by KLIPY' || - element.props.href === 'https://klipy.com', + (element) => element.props.href === 'https://faux.example', ), ); }); @@ -124,10 +152,74 @@ test('uses the provider placeholder and attribution', () => { test('searches through the injected provider', async () => { render(); await vi.waitFor(() => { - assert.equal((provider.trending as ReturnType).mock.calls.length, 1); + assert.equal(trending.mock.calls.length, 1); }); - assert.equal( - (provider.search as ReturnType).mock.calls.length, - 0, + assert.equal(search.mock.calls.length, 0); +}); + +test('searches with the typed query', async () => { + const fields = render(); + + ( + field(fields, 'gif-search-input').props.onChange as (event: { + target: { value: string }; + }) => void + )({ target: { value: 'cats' } }); + + render(); + + await vi.waitFor(() => { + assert.equal(search.mock.calls.length, 1); + }); + assert.deepEqual(search.mock.calls[0].slice(0, 2), ['k', 'cats']); +}); + +test('picks the clicked result through the injected provider', async () => { + const onPick = vi.fn(); + render(true, onPick); + await settle(); + + const fields = render(true, onPick); + const button = elements(field(fields, 'gif-results')).find( + (element) => element.type === 'button', ); + + assert.ok(button); + (button.props.onClick as () => void)(); + + assert.deepEqual(onPick.mock.calls[0]?.[0], { + ...result, + provider: 'klipy', + }); +}); + +test('loads more with the cursor from the previous page', async () => { + trending.mockImplementation( + async ( + _key: string, + options: { cursor: string | null }, + ) => + options.cursor === null + ? { results: [result], nextCursor: 'next-page' } + : { + results: [{ ...result, id: 'two' }], + nextCursor: null, + }, + ); + + render(); + await settle(); + + const fields = render(); + const loadMore = fields.find( + (element) => element.props.children === 'Load more', + ); + + assert.ok(loadMore); + (loadMore.props.onClick as () => void)(); + + assert.deepEqual(trending.mock.calls.at(-1), [ + 'k', + { cursor: 'next-page' }, + ]); }); From 848206329a55c87fbee18a2799d79c68b826fe84 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:43:59 -0400 Subject: [PATCH 51/83] chore: sweep legacy giphy naming and satisfy the gates --- tests/Feature/Controllers/CommitControllerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index ab15c2e70..2ce025d46 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -935,7 +935,7 @@ it('uses the attached gif as an open graph image', function (): void { $user = User::factory()->create(['username' => 'octocat', 'name' => 'The Octocat']); - $commit = Commit::factory()->for($user)->withGiphy()->create(['body' => 'Deal with it']); + $commit = Commit::factory()->for($user)->withGif()->create(['body' => 'Deal with it']); $content = (string) $this->get(route('commit.show', [$user, $commit]))->assertOk()->getContent(); @@ -1607,7 +1607,7 @@ }); it('shows the gif in the commit payload', function (): void { - $commit = Commit::factory()->withGiphy()->create(); + $commit = Commit::factory()->withGif()->create(); $this->actingAs($commit->user) ->get(route('commit.show', [$commit->user, $commit])) From e09a84c7a664e96cb97000f1c3d27dd3a7f66805 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 16:59:37 -0400 Subject: [PATCH 52/83] fix: restore adapter fallbacks and harden the gif feature --- resources/js/components/commit-gif.tsx | 2 +- .../js/components/commit-image-picker.tsx | 2 +- resources/js/components/commit-thread.tsx | 3 +- resources/js/components/gif-key-setting.tsx | 2 + resources/js/components/gif-picker-dialog.tsx | 30 ++- resources/js/lib/gif-storage.ts | 4 +- resources/js/pages/user-profile/edit.tsx | 3 +- resources/js/types/commit.ts | 3 +- resources/js/types/global.d.ts | 3 +- .../Controllers/CommitControllerTest.php | 34 +-- .../Controllers/CommitReplyControllerTest.php | 31 ++- tests/Unit/GifKeySetting.test.tsx | 20 ++ tests/Unit/GifPickerDialog.test.tsx | 27 ++- tests/Unit/GifProviders.test.ts | 198 ++++++++++++++++++ tests/Unit/Jobs/ModerateCommitTest.php | 4 + 15 files changed, 329 insertions(+), 37 deletions(-) diff --git a/resources/js/components/commit-gif.tsx b/resources/js/components/commit-gif.tsx index 7d4255090..955460501 100644 --- a/resources/js/components/commit-gif.tsx +++ b/resources/js/components/commit-gif.tsx @@ -10,7 +10,7 @@ export function CommitGif({ gif: CommitGif; className?: string; }) { - const provider = gifProviders[gif.provider]; + const provider = gifProviders[gif.provider] ?? gifProviders.giphy; const credit = gif.username !== null ? `GIF by @${gif.username} on ${provider.label}` diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 2f08ebc30..1455c8e70 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -43,7 +43,7 @@ export function pickImages( } /** - * The first validation error the server gave for the images, whichever image it was about. + * The first validation error the server gave for the attachments, whichever one it was about. */ export function imageErrorFrom( errors: Record, diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index cb2fc2c52..20897c1dd 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -67,6 +67,7 @@ import { signIn } from '@/lib/auth'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; import { commitStateKey } from '@/lib/commit-state'; import { gifFormFields } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; import { hasModKey } from '@/lib/keyboard'; import { characterCount, @@ -1145,7 +1146,7 @@ function ReplyComposer({ images: File[]; image_descriptions: string[]; link_preview_url?: string; - gif_provider?: 'giphy' | 'klipy'; + gif_provider?: GifProviderId; gif_id?: string; gif_url?: string; gif_page_url?: string; diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index ba5aac3c8..f0fbcda28 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -169,6 +169,7 @@ export function GifKeySetting({ : 'outline' } aria-pressed={candidate.id === providerId} + disabled={processing} onClick={() => { setProviderId(candidate.id); setError(null); @@ -188,6 +189,7 @@ export function GifKeySetting({ value={key} onChange={(event) => setKey(event.target.value)} placeholder={`Paste your ${provider.label} API key`} + disabled={processing} autoComplete="off" spellCheck={false} className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" diff --git a/resources/js/components/gif-picker-dialog.tsx b/resources/js/components/gif-picker-dialog.tsx index 3bd39f836..ef715abeb 100644 --- a/resources/js/components/gif-picker-dialog.tsx +++ b/resources/js/components/gif-picker-dialog.tsx @@ -37,14 +37,19 @@ export function GifPickerDialog({ const [isLoadingMore, setIsLoadingMore] = useState(false); const [reloads, setReloads] = useState(0); const generation = useRef(0); + const queryRef = useRef(query); + const loadMoreController = useRef(null); useEffect(() => { const currentGeneration = ++generation.current; + queryRef.current = query; if (!open) { return; } + setNextCursor(null); + const controller = new AbortController(); const timeout = setTimeout( () => { @@ -93,21 +98,35 @@ export function GifPickerDialog({ return () => { clearTimeout(timeout); controller.abort(); + loadMoreController.current?.abort(); }; }, [open, query, apiKey, reloads, provider]); const loadMore = () => { const currentGeneration = generation.current; + const requestedQuery = query; + const controller = new AbortController(); + loadMoreController.current = controller; setIsLoadingMore(true); const trimmed = query.trim(); const request = trimmed === '' - ? provider.trending(apiKey, { cursor: nextCursor }) - : provider.search(apiKey, trimmed, { cursor: nextCursor }); + ? provider.trending(apiKey, { + cursor: nextCursor, + signal: controller.signal, + }) + : provider.search(apiKey, trimmed, { + cursor: nextCursor, + signal: controller.signal, + }); request .then((page) => { - if (currentGeneration !== generation.current) { + if ( + controller.signal.aborted || + currentGeneration !== generation.current || + requestedQuery !== queryRef.current + ) { return; } @@ -116,7 +135,10 @@ export function GifPickerDialog({ }) .catch(() => undefined) .finally(() => { - if (currentGeneration === generation.current) { + if ( + currentGeneration === generation.current && + requestedQuery === queryRef.current + ) { setIsLoadingMore(false); } }); diff --git a/resources/js/lib/gif-storage.ts b/resources/js/lib/gif-storage.ts index 5f6b4a919..6ce4b7cab 100644 --- a/resources/js/lib/gif-storage.ts +++ b/resources/js/lib/gif-storage.ts @@ -1,3 +1,4 @@ +import { gifProviders } from './gif-providers/index.ts'; import type { GifAttachment } from './gif-providers/types.ts'; import { readStorage, writeStorage } from './storage.ts'; @@ -17,7 +18,8 @@ export function readStoredGif(key: string): GifAttachment | null { return typeof parsed.id === 'string' && typeof parsed.url === 'string' && typeof parsed.pageUrl === 'string' && - (parsed.provider === 'giphy' || parsed.provider === 'klipy') + typeof parsed.provider === 'string' && + parsed.provider in gifProviders ? (parsed as GifAttachment) : null; } catch { diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index e79abc5cd..fcb007eda 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -17,6 +17,7 @@ import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import type { GifProviderId } from '@/lib/gif-providers'; import { formatFullDateTime, formatSyncedAgo } from '@/lib/utils'; import { show } from '@/routes/user'; import type { Lane } from '@/types'; @@ -34,7 +35,7 @@ export default function Edit({ automaticLane: number; githubSyncedAt: string | null; gifConnected: boolean; - gifProvider: 'giphy' | 'klipy' | null; + gifProvider: GifProviderId | null; activityGraphUrl: string; profileUrl: string; }) { diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index e14475f19..7cb02b308 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -1,3 +1,4 @@ +import type { GifProviderId } from '@/lib/gif-providers'; import type { User } from '@/types/auth'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; @@ -48,7 +49,7 @@ export type CommitImage = { }; export type CommitGif = { - provider: 'giphy' | 'klipy'; + provider: GifProviderId; id: string; url: string; page_url: string; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index e5773a3b6..fa19f2dcd 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,3 +1,4 @@ +import type { GifProviderId } from '@/lib/gif-providers'; import type { Auth, SuggestedUser } from '@/types/auth'; import type { Commit } from '@/types/commit'; import type { CommittedFlash, DeletedFlash, FlashToast } from '@/types/ui'; @@ -18,7 +19,7 @@ declare module '@inertiajs/core' { hasUnmoderatedCommits: boolean; sidebarOpen: boolean; showsWhoToFollow: boolean; - gif: { provider: 'giphy' | 'klipy'; key: string } | null; + gif: { provider: GifProviderId; key: string } | null; dimsLanes: boolean; repliesPerPage: number; suggestedUsers?: SuggestedUser[]; diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 2ce025d46..7da9d8976 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1489,25 +1489,22 @@ $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); }); -it('rejects a gif without a provider', function (): void { +it('rejects invalid providers', function (array $payload): void { $user = User::factory()->create(); - $response = $this->actingAs($user)->postJson(route('commit.store'), [ + $response = $this->actingAs($user)->postJson(route('commit.store'), $payload); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +})->with([ + 'missing provider' => [fn (): array => [ 'body' => 'No provider', 'gif_id' => 'abc123', 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', 'gif_page_url' => 'https://giphy.com/gifs/abc123', 'gif_width' => 480, 'gif_height' => 270, - ]); - - $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); -}); - -it('rejects a non-string gif provider without erroring', function (): void { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson(route('commit.store'), [ + ]], + 'non-string provider' => [fn (): array => [ 'body' => 'Array provider', 'gif_provider' => ['giphy'], 'gif_id' => 'abc123', @@ -1515,10 +1512,17 @@ 'gif_page_url' => 'https://giphy.com/gifs/abc123', 'gif_width' => 480, 'gif_height' => 270, - ]); - - $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); -}); + ]], + 'unknown provider string' => [fn (): array => [ + 'body' => 'Unknown provider', + 'gif_provider' => 'other', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], +]); it('rejects a gif media url that is not on giphy', function (string $url): void { $user = User::factory()->create(); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index c8b6af00f..dd235f06e 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -989,11 +989,23 @@ expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); }); -it('rejects a non-string gif provider without erroring', function (): void { +it('rejects invalid providers', function (array $payload): void { $user = User::factory()->create(); $parent = Commit::factory()->create(); - $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), [ + $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), $payload); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +})->with([ + 'missing provider' => [fn (): array => [ + 'body' => 'Reply without a provider', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], + 'non-string provider' => [fn (): array => [ 'body' => 'Reply with an array provider', 'gif_provider' => ['giphy'], 'gif_id' => 'abc123', @@ -1001,10 +1013,17 @@ 'gif_page_url' => 'https://giphy.com/gifs/abc123', 'gif_width' => 480, 'gif_height' => 270, - ]); - - $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); -}); + ]], + 'unknown provider string' => [fn (): array => [ + 'body' => 'Reply with an unknown provider', + 'gif_provider' => 'other', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], +]); it('rejects a reply gif url from another provider', function (): void { $user = User::factory()->create(); diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index 0720fb041..2fbc709b1 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -200,6 +200,26 @@ test('names klipy when it refuses the key', async () => { ); }); +test('clears the error when another provider is selected', async () => { + stubFetch(401); + + const fields = typeKey(render(disconnected), 'key-123', disconnected); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.ok(field(render(disconnected), 'gif-key-error')); + + (field(render(disconnected), 'provider-klipy').props.onClick as () => void)(); + + const switched = render(disconnected); + + assert.equal( + switched.some( + (element) => element.props['data-test'] === 'gif-key-error', + ), + false, + ); +}); + test('does not save when the provider cannot be reached', async () => { stubFetch('network'); diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index c4518fa72..d5c4452c4 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -15,7 +15,15 @@ vi.doMock('react', () => ({ useId: () => `preview-${cursor++}`, useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(), - useRef: (initial: unknown) => ({ current: initial }), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, useState: (initial: unknown) => { const index = cursor++; @@ -37,6 +45,7 @@ vi.doMock('react', () => ({ vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ props: { gif: null } }), })); const result = { @@ -72,6 +81,9 @@ const provider: GifProvider = { const { GifPickerDialog } = await import( '../../resources/js/components/gif-picker-dialog' ); +const { GifPickerButton } = await import( + '../../resources/js/components/gif-picker-button' +); type Element = ReactElement>; @@ -218,8 +230,13 @@ test('loads more with the cursor from the previous page', async () => { assert.ok(loadMore); (loadMore.props.onClick as () => void)(); - assert.deepEqual(trending.mock.calls.at(-1), [ - 'k', - { cursor: 'next-page' }, - ]); + const [key, options] = trending.mock.calls.at(-1) ?? []; + + assert.equal(key, 'k'); + assert.equal(options?.cursor, 'next-page'); + assert.ok(options?.signal instanceof AbortSignal); +}); + +test('renders nothing when no gif provider is connected', () => { + assert.equal(GifPickerButton({ hasGif: false, onPick: vi.fn() }), null); }); diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index 80a98f723..f92cc24bc 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -193,6 +193,204 @@ test('adapters drop items without a usable rendition', async () => { } }); +const rendition = (slug: string) => ({ + url: `https://media2.giphy.com/media/${slug}.gif`, + width: '480', + height: '270', +}); + +test('giphy falls back across stored renditions and previews', async () => { + const cases: { + name: string; + images: Record>; + stored: string; + preview: string; + }[] = [ + { + name: 'stored downsized', + images: { downsized: rendition('downsized') }, + stored: 'https://media2.giphy.com/media/downsized.gif', + preview: 'https://media2.giphy.com/media/downsized.gif', + }, + { + name: 'stored original', + images: { original: rendition('original') }, + stored: 'https://media2.giphy.com/media/original.gif', + preview: 'https://media2.giphy.com/media/original.gif', + }, + { + name: 'fixed height small preview', + images: { + fixed_height_small: rendition('small'), + downsized: rendition('downsized'), + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/small.gif', + }, + { + name: 'downsized preview', + images: { + downsized: rendition('downsized'), + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/downsized.gif', + }, + { + name: 'downsized medium preview', + images: { + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/downsized-medium.gif', + }, + { + name: 'original preview', + images: { original: rendition('original') }, + stored: 'https://media2.giphy.com/media/original.gif', + preview: 'https://media2.giphy.com/media/original.gif', + }, + ]; + + for (const { name, images, stored, preview } of cases) { + stubFetch({ + data: [{ + id: 'gif', + title: 'GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/gif', + images, + }], + }); + + try { + const page = await giphy.search('key-123', 'gif', { + cursor: null, + }); + + assert.equal(page.results[0]?.url, stored, name); + assert.equal(page.results[0]?.previewUrl, preview, name); + } finally { + restoreFetch(); + } + } +}); + +test('giphy normalizes empty titles and usernames to null', async () => { + stubFetch({ + data: [{ + id: 'gif', + title: '', + username: '', + url: 'https://giphy.com/gifs/gif', + images: { original: rendition('original') }, + }], + }); + + try { + const page = await giphy.search('key-123', 'gif', { cursor: null }); + + assert.equal(page.results[0]?.title, null); + assert.equal(page.results[0]?.username, null); + } finally { + restoreFetch(); + } +}); + +test('klipy falls back to the content description for its title', async () => { + const cases: { + name: string; + title: string | undefined; + description: string | undefined; + expected: string | null; + }[] = [ + { + name: 'empty title', + title: '', + description: 'hello greeting', + expected: 'hello greeting', + }, + { + name: 'absent title', + title: undefined, + description: 'hello greeting', + expected: 'hello greeting', + }, + { + name: 'empty fallback', + title: '', + description: '', + expected: null, + }, + { + name: 'absent both', + title: undefined, + description: undefined, + expected: null, + }, + ]; + + for (const { name, title, description, expected } of cases) { + stubFetch({ + results: [{ + id: 'hello-hi-662', + title, + content_description: description, + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + dims: [498, 498], + }, + }, + }], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { + cursor: null, + }); + + assert.equal(page.results[0]?.title, expected, name); + } finally { + restoreFetch(); + } + } +}); + +test('klipy reuses the gif url when the tiny preview is missing', async () => { + stubFetch({ + results: [{ + id: 'hello-hi-662', + title: 'Hello', + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + dims: [498, 498], + }, + }, + }], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { cursor: null }); + + assert.equal( + page.results[0]?.previewUrl, + 'https://static.klipy.com/ii/8GCrVAB7.gif', + ); + } finally { + restoreFetch(); + } +}); + test('adapters reject non-429 http failures', async () => { stubFetch({}, 500); try { diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index 433b01ee4..a80570da2 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -448,6 +448,10 @@ function reviewResponses(array $responses = []): Closure Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') && str_contains($request->body(), 'static.klipy.com')); + + // The minors check runs because the commit has an image URL (the gif): + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'responses') + && str_contains($request->body(), 'static.klipy.com')); }); it('keeps unavailable moderation pending and recovers on a later queued attempt', function (bool $recovers): void { From a96a8af86b554f81ed2d802cd35f560b85f434c4 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:25:36 -0400 Subject: [PATCH 53/83] docs: add the client-only gif key storage design --- ...-gif-provider-key-client-storage-design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md diff --git a/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md b/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md new file mode 100644 index 000000000..288c03676 --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md @@ -0,0 +1,93 @@ +# Client-only GIF provider keys — design + +**Date:** 2026-10-19 +**Status:** Approved design, pending implementation plan +**Branch:** `feature/giphy-picker` (GIF feature unreleased) +**Supersedes:** the BYOK key-storage sections of `2026-10-19-gif-providers-design.md` (server-stored `users.gif_key` + `users.gif_provider`). Everything else in that spec — provider interface, schema for commit metadata, validation, picker/rendering — stands. + +## Summary + +The maintainers do not want API keys or secrets stored in the database. The GIF provider key therefore lives **only in the user's browser** (`localStorage`), where it is actually used. The server keeps only public GIF attachment metadata on commits. This removes the users key/provider columns and the entire server-side BYOK plumbing. + +## Approach (decided) + +- **A. `localStorage`, namespaced per user id** — `gif-provider:{userId}` stores `{provider, key}`. Persists across sessions, isolated between accounts on a shared browser, lost only when site data is cleared; inherently per-device. +- **A. `useSyncExternalStore` hook** — `useGifProviderKey(userId)` with a deterministic server snapshot (`null`), a small same-tab emitter, and cross-tab `storage` events. SSR-safe and reactive. + +Rejected: an origin-wide entry (accounts on one browser would share a key), `sessionStorage` (the key would die with the tab), reading localStorage at render behind a `typeof window` guard (server/client hydration mismatch), cookies (would send the secret to the server on every request). + +## Storage contract + +New module `resources/js/lib/gif-provider-key.ts`: + +| Function | Behavior | +|---|---| +| `readGifProviderKey(userId: string): GifProviderKey \| null` | Reads `localStorage['gif-provider:' + userId]`, parses, validates; malformed payloads are treated as absent | +| `writeGifProviderKey(userId, value: GifProviderKey): void` | Serializes `{provider, key}` and notifies same-tab subscribers | +| `clearGifProviderKey(userId): void` | Removes the entry and notifies | +| `subscribeGifProviderKey(userId, listener): () => void` | Same-tab emitter + `window` `storage` events for the namespaced key | + +`GifProviderKey = { provider: GifProviderId; key: string }` (type only in this module). + +Validation rules on read: +- parsed value is an object with string `provider` that is an **own** key of `gifProviders` (`Object.hasOwn` — prototype keys such as `toString` never pass); +- `key` is a string with `trim()` non-empty and length ≤ 255. + +Anything else returns `null` (never throws). Writes happen only from the settings section after browser-side validation. + +Namespacing: the key includes `auth.user.id`, so two accounts on the same browser never see each other's key. Signing out does not clear the entry (same person, same device); a deleted account leaves a harmless orphan entry. + +## Hook + +New `resources/js/hooks/use-gif-provider-key.ts`: + +```ts +export function useGifProviderKey( + userId: string | null, +): GifProviderKey | null; +``` + +- `useSyncExternalStore` with a server snapshot of `null` (SSR renders "not connected" / no picker button — no hydration mismatch). +- The client snapshot reads the module and returns a stable reference while the raw value is unchanged (cache the raw JSON string + parsed object). +- Subscribes to the namespaced same-tab emitter and `storage` events; a key written or cleared in another tab updates the hook. +- `userId === null` (guests) always returns `null` without touching storage. + +Consumers: +- `GifPickerButton` reads the hook with the signed-in user id; renders only when a valid entry exists and passes `gifProviderFor(entry.provider)` + `entry.key` to the dialog. +- `GifKeySetting` reads the hook for its connected state and writes/clears through the module. No Inertia round-trip, no server involvement. + +## Server cleanup (unreleased → delete, no back-compat) + +- Delete `database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php` entirely (no users columns are needed). +- Remove from `app/Models/User.php`: `gif_provider`/`gif_key` docblock entries, `#[Hidden]` entries, `$attributes` defaults, and casts. +- Delete `app/Http/Controllers/GifKeyController.php`, `app/Http/Requests/UpdateGifKeyRequest.php`; remove the `settings/gif` routes and the controller import; regenerate Wayfinder (generated files stay gitignored). +- Remove the `gif` prop from `app/Http/Middleware/HandleInertiaRequests.php`. +- Remove `gifConnected`/`gifProvider` from `UserProfileController::edit`. +- Remove the `gif` entry from `resources/js/types/global.d.ts`. +- Delete `tests/Feature/Controllers/GifKeyControllerTest.php` and `tests/Feature/Middleware/GifPropTest.php`; remove the gif-connection test from `UserProfileControllerTest` and the key-in-export test from `ExportControllerTest`. +- Update the README "GIFs" paragraph: keys are stored only in the browser, never sent to the server, and each device needs its own. +- Commit metadata (`gif_provider` + `gif_*` columns, serialization, API/feed/OG/moderation) is unchanged — public data, not secrets. The server never sees a provider key anywhere. + +## Settings UX + +- The "GIFs" section keeps the provider selector, key field, browser-side validation (200 saves; 401/403 rejected; 429 saved as real-but-throttled; network failure rejected), write-only key, and Update/Change provider/Remove. +- Saving is instant and client-side; no toast is needed. The section states: **"Stored in this browser only — never sent to the server. Each device needs its own key."** +- Connected state ("Connected to GIPHY/KLIPY") comes from the hook; Remove clears the local entry, and the picker button disappears on the next mount. A change in another tab is reflected via the `storage` event. +- The connected state appears after hydration (the server snapshot is null); the disconnected form is the SSR state, which is deterministic. + +## Security notes + +- `localStorage` is readable by any script on the origin — the same exposure class as the composer drafts already stored there. No cookies, so the key is never attached to HTTP requests; no server-side secret exists to leak from the DB or exports. +- The key is read only by the picker dialog and the settings section; it is never logged and never placed in page props. +- The existing draft stash (`lib/gif-storage.ts`) is unrelated to the provider key and stays as is. + +## Testing + +- **New `tests/Unit/GifProviderKey.test.ts`:** round-trip per user; per-user namespacing; malformed JSON; unknown provider; prototype provider (`toString`/`__proto__`) rejected; empty/whitespace/oversized key rejected; clear; same-tab listener notification; `storage`-event subscription (simulated event) triggers the listener. +- **Component:** settings writes/clears `localStorage`, connected state from the hook, invalid/unreachable never write, 429 writes; picker button renders only with a valid stored entry and passes the right provider/key, absent without one. +- **Server:** deleted test files; `UserProfileControllerTest`/`ExportControllerTest` no longer reference the key; full Pest suite green with zero `gif_key`/`gif_provider` references on users. + +## Out of scope + +- Cross-device sync, passphrase-encrypting the stored value, `sessionStorage`, cookies. +- Server-side validation or storage of provider keys of any kind. From a2b9c22707261dc1a4cd84e9f89c728d2b1d996f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:37:38 -0400 Subject: [PATCH 54/83] docs: add the client-only gif key plan --- ...6-10-19-gif-provider-key-client-storage.md | 1182 +++++++++++++++++ 1 file changed, 1182 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md diff --git a/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md b/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md new file mode 100644 index 000000000..9f7468764 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md @@ -0,0 +1,1182 @@ +# Client-Only GIF Provider Keys 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:** Move GIF provider keys out of the database into the user's browser (`localStorage`, namespaced per user), delete all server-side key storage/plumbing, and keep the server storing only public GIF attachment metadata. + +**Architecture:** A small storage module (`resources/js/lib/gif-provider-key.ts`) owns the namespaced localStorage entry plus a same-tab emitter and cross-tab `storage` listener; a `useGifProviderKey` hook exposes it through `useSyncExternalStore` with a deterministic `null` server snapshot. Settings and the picker button consume the hook. The users table loses its provider/key columns and the controller/request/routes/Inertia prop are deleted. Commit metadata (`gif_provider` + `gif_*`) is unchanged. + +**Tech Stack:** Laravel 13 (PHP 8.5, strict types), Inertia 3 + React 19 + TypeScript, Pest 5, node:test for lib tests, Vite+ for component tests. + +**Spec:** `docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md` + +## Global Constraints + +- **No secrets server-side.** Provider keys live only in `localStorage`; they are never sent to the server, never stored in the DB, never in page props, never logged, never in cookies. +- Storage entry: `gif-provider:{userId}` → JSON `{provider, key}`. The provider must be an **own** property of `gifProviders` (`Object.hasOwn`); key: non-empty (trimmed) string, length ≤ 255. Malformed values read as absent. +- Per-user namespacing: two accounts on one browser never share a key. Signing out does not clear it; clearing site data does. Each device needs its own key (documented). +- SSR-safe: `useSyncExternalStore` with `getServerSnapshot` returning `null`; no hydration mismatches. +- The unreleased feature has no back-compat: delete the users columns/migration and all server key plumbing outright. +- Commit metadata (`gif_provider`, `gif_*`) and all read surfaces stay unchanged. +- PHP: `declare(strict_types=1);`; validation only in Form Requests; no protected methods. Pest 100% line coverage; every new PHP branch tested; PHPStan max. No real HTTP in tests. +- Wayfinder: regenerate after route changes (`php artisan wayfinder:generate --with-form`); generated files are gitignored and never committed. +- House rule: `git add` new files immediately after creating them. +- Gates: PHP `composer test:lint`, `composer test:types`, `composer test:type-coverage`, full `php -d memory_limit=-1 vendor/bin/pest --fresh` (CI runs the `--exactly=100.0` coverage leg with pcov). JS: `bun run test:unit`, `bun run test:types`, `bun run test:lint`. + +--- + +### Task 1: Client key storage module and hook + +**Files:** +- Create: `resources/js/lib/gif-provider-key.ts` +- Create: `resources/js/hooks/use-gif-provider-key.ts` +- Test: `tests/Unit/GifProviderKey.test.ts` (create) + +**Interfaces:** +- Produces: `GifProviderKey = { provider: GifProviderId; key: string }`; `readGifProviderKey(userId): GifProviderKey | null` (stable reference while the raw value is unchanged); `writeGifProviderKey(userId, value): void`; `clearGifProviderKey(userId): void`; `subscribeGifProviderKey(userId, listener): () => void`. +- Produces: `useGifProviderKey(userId: string | null): GifProviderKey | null`. + +- [ ] **Step 1: Write the failing storage tests** + +Create `tests/Unit/GifProviderKey.test.ts`: + +```ts +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import { + clearGifProviderKey, + readGifProviderKey, + subscribeGifProviderKey, + writeGifProviderKey, +} from '../../resources/js/lib/gif-provider-key.ts'; + +let store: Map; +let storageHandlers: Array<(event: StorageEvent) => void>; +const originalWindow = globalThis.window; + +function installWindow(): void { + store = new Map(); + storageHandlers = []; + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers.push(handler); + }, + removeEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers = storageHandlers.filter((candidate) => candidate !== handler); + }, + } as unknown as Window & typeof globalThis; +} + +beforeEach(installWindow); + +afterEach(() => { + globalThis.window = originalWindow; +}); + +test('round-trips a key and keeps a stable reference while unchanged', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k-123' }); + + const first = readGifProviderKey('user-1'); + const second = readGifProviderKey('user-1'); + + assert.deepEqual(first, { provider: 'klipy', key: 'k-123' }); + assert.equal(first, second); +}); + +test('namespaces keys per user', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'one' }); + writeGifProviderKey('user-2', { provider: 'klipy', key: 'two' }); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'one' }); + assert.deepEqual(readGifProviderKey('user-2'), { provider: 'klipy', key: 'two' }); + assert.equal(readGifProviderKey('user-3'), null); +}); + +test('treats malformed values as absent', () => { + store.set('gif-provider:user-1', '{not json'); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'unknown', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'toString', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: '__proto__', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: ' ' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'a'.repeat(256) })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 42 })); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('writes the trimmed key and notifies same-tab subscribers', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + writeGifProviderKey('user-1', { provider: 'giphy', key: ' k-123 ' }); + + assert.equal(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'k-123' })); + assert.deepEqual(calls, ['hit']); + + unsubscribe(); + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-456' }); + assert.deepEqual(calls, ['hit']); +}); + +test('clears the entry and notifies', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); + + const calls: string[] = []; + subscribeGifProviderKey('user-1', () => calls.push('hit')); + + clearGifProviderKey('user-1'); + + assert.equal(store.has('gif-provider:user-1'), false); + assert.deepEqual(calls, ['hit']); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('reacts to storage events for its own key only', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + assert.equal(storageHandlers.length, 1); + + storageHandlers[0]!({ key: 'gif-provider:user-2' } as StorageEvent); + assert.deepEqual(calls, []); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'other-tab' })); + storageHandlers[0]!({ key: 'gif-provider:user-1' } as StorageEvent); + + assert.deepEqual(calls, ['hit']); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'other-tab' }); + + unsubscribe(); + assert.equal(storageHandlers.length, 0); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --experimental-strip-types --test tests/Unit/GifProviderKey.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the storage module** + +Create `resources/js/lib/gif-provider-key.ts`: + +```ts +import { gifProviders } from './gif-providers/index.ts'; +import type { GifProviderId } from './gif-providers/types.ts'; +import { readStorage, writeStorage } from './storage.ts'; + +export type GifProviderKey = { + provider: GifProviderId; + key: string; +}; + +type Cached = { raw: string; value: GifProviderKey | null }; + +const cache = new Map(); +const listeners = new Map void>>(); + +const storageKey = (userId: string): string => `gif-provider:${userId}`; + +function parse(raw: string): GifProviderKey | null { + if (raw === '') { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const candidate = parsed as { provider?: unknown; key?: unknown }; + + if ( + typeof candidate.provider !== 'string' || + !Object.hasOwn(gifProviders, candidate.provider) || + typeof candidate.key !== 'string' || + candidate.key.trim() === '' || + candidate.key.length > 255 + ) { + return null; + } + + return { + provider: candidate.provider as GifProviderId, + key: candidate.key.trim(), + }; +} + +export function readGifProviderKey(userId: string): GifProviderKey | null { + const raw = readStorage(storageKey(userId)); + const cached = cache.get(userId); + + if (cached !== undefined && cached.raw === raw) { + return cached.value; + } + + const value = parse(raw); + cache.set(userId, { raw, value }); + + return value; +} + +function notify(userId: string): void { + listeners.get(userId)?.forEach((listener) => listener()); +} + +export function writeGifProviderKey( + userId: string, + value: GifProviderKey, +): void { + writeStorage( + storageKey(userId), + JSON.stringify({ provider: value.provider, key: value.key.trim() }), + ); + cache.delete(userId); + notify(userId); +} + +export function clearGifProviderKey(userId: string): void { + writeStorage(storageKey(userId), ''); + cache.delete(userId); + notify(userId); +} + +export function subscribeGifProviderKey( + userId: string, + listener: () => void, +): () => void { + const key = storageKey(userId); + const subscribed = listeners.get(userId) ?? new Set<() => void>(); + + subscribed.add(listener); + listeners.set(userId, subscribed); + + const onStorage = (event: StorageEvent): void => { + if (event.key !== key) { + return; + } + + cache.delete(userId); + listener(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('storage', onStorage); + } + + return () => { + subscribed.delete(listener); + + if (subscribed.size === 0) { + listeners.delete(userId); + } + + if (typeof window !== 'undefined') { + window.removeEventListener('storage', onStorage); + } + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --experimental-strip-types --test tests/Unit/GifProviderKey.test.ts` +Expected: PASS. + +- [ ] **Step 5: Implement the hook** + +Create `resources/js/hooks/use-gif-provider-key.ts`: + +```ts +import { useSyncExternalStore } from 'react'; +import { + readGifProviderKey, + subscribeGifProviderKey, +} from '@/lib/gif-provider-key'; +import type { GifProviderKey } from '@/lib/gif-provider-key'; + +export function useGifProviderKey( + userId: string | null, +): GifProviderKey | null { + return useSyncExternalStore( + (listener) => + userId === null + ? () => undefined + : subscribeGifProviderKey(userId, listener), + () => (userId === null ? null : readGifProviderKey(userId)), + () => null, + ); +} +``` + +- [ ] **Step 6: Run the JS gates** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS (the new node test runs under `test:unit`; nothing else consumes the hook yet). + +- [ ] **Step 7: Commit** + +```bash +git add resources/js/lib/gif-provider-key.ts resources/js/hooks/use-gif-provider-key.ts tests/Unit/GifProviderKey.test.ts +git commit -m "feat: store the gif provider key in the browser" +``` + +--- + +### Task 2: Client-side settings section + +**Files:** +- Modify: `resources/js/components/gif-key-setting.tsx` (rewrite; no server calls) +- Modify: `resources/js/pages/user-profile/edit.tsx` (no props) +- Modify: `README.md` (browser-only note) +- Test: `tests/Unit/GifKeySetting.test.tsx` (rewrite) + +**Interfaces:** +- Consumes: `useGifProviderKey`, `writeGifProviderKey`, `clearGifProviderKey` (Task 1); `gifProviders`/`gifProviderFor`; `useUser()` for `user.id`. +- Produces: `GifKeySetting()` — no props; renders "GIFs" with the browser-only notice, provider selector, write-only key, Connect/Update, Change provider, Remove; persists to `localStorage` only. + +- [ ] **Step 1: Rewrite the failing settings tests** + +Replace `tests/Unit/GifKeySetting.test.tsx` with: + +```tsx +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-user', () => ({ + useUser: () => ({ user: { id: 'user-1' } }), +})); + +const { GifKeySetting } = await import( + '../../resources/js/components/gif-key-setting' +); +const { readGifProviderKey, writeGifProviderKey } = await import( + '../../resources/js/lib/gif-provider-key' +); + +type Element = ReactElement>; + +let store: Map; +const originalWindow = globalThis.window; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(): Element[] { + cursor = 0; + + return elements(GifKeySetting()); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +function stubFetch(result: number | 'network'): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (result === 'network') { + throw new TypeError('fetch failed'); + } + + return new Response('{}', { status: result }); + }), + ); +} + +function typeKey(fields: Element[], value: string): Element[] { + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value } }); + + return render(); +} + +function selectProvider(fields: Element[], id: 'klipy'): Element[] { + (field(fields, `provider-${id}`).props.onClick as () => void)(); + + return render(); +} + +beforeEach(() => { + state = []; + cursor = 0; + store = new Map(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; +}); + +test('shows the browser-only notice', () => { + const fields = render(); + + assert.ok( + fields.some( + (element) => + typeof element.props.children === 'string' && + element.props.children.includes('Stored in this browser only'), + ), + ); +}); + +test('saves a valid key to local storage after giphy accepts it', async () => { + stubFetch(200); + + let fields = typeKey(render(), ' key-123 '); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'key-123' })); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); + + fields = render(); + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to GIPHY.', + ); +}); + +test('saves the key against the selected provider', async () => { + stubFetch(200); + + let fields = selectProvider(render(), 'klipy'); + fields = typeKey(fields, 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); +}); + +test('saves a rate-limited key because the key itself is real', async () => { + stubFetch(429); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); +}); + +test('rejects a refused key without saving', async () => { + stubFetch(401); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key was rejected by GIPHY.', + ); +}); + +test('names klipy when it refuses the key', async () => { + stubFetch(401); + + let fields = selectProvider(render(), 'klipy'); + fields = typeKey(fields, 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key was rejected by KLIPY.', + ); +}); + +test('clears the error when another provider is selected', async () => { + stubFetch(401); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.ok(field(render(), 'gif-key-error')); + + (field(render(), 'provider-klipy').props.onClick as () => void)(); + + assert.equal( + render().some( + (element) => element.props['data-test'] === 'gif-key-error', + ), + false, + ); +}); + +test('does not save when the provider cannot be reached', async () => { + stubFetch('network'); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + "Couldn't reach GIPHY to verify the key.", + ); +}); + +test('disables connect until a key is typed', () => { + assert.equal(field(render(), 'gif-connect-button').props.disabled, true); +}); + +test('does not save a whitespace-only key', async () => { + stubFetch(200); + + const fields = typeKey(render(), ' '); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('shows the connected provider from local storage', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); + + const fields = render(); + + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to KLIPY.', + ); +}); + +test('removes the stored key', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); + + const fields = render(); + (field(fields, 'gif-remove-button').props.onClick as () => void)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.ok(field(render(), 'gif-key-input')); +}); + +test('replaces the connected provider when another is chosen', async () => { + stubFetch(200); + + writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); + + let fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + + fields = render(); + (field(fields, 'provider-klipy').props.onClick as () => void)(); + + fields = render(); + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value: 'klipy-key' } }); + + fields = render(); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'klipy-key' }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun run test:unit` +Expected: FAIL — the component still requires `gifConnected`/`gifProvider` props and uses Inertia `router`. + +- [ ] **Step 3: Rewrite the component** + +Replace `resources/js/components/gif-key-setting.tsx` with: + +```tsx +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; +import { useUser } from '@/hooks/use-user'; +import { + clearGifProviderKey, + writeGifProviderKey, +} from '@/lib/gif-provider-key'; +import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; + +const helpHref: Record = { + giphy: 'https://developers.giphy.com', + klipy: 'https://partner.klipy.com', +}; + +export function GifKeySetting() { + const { user } = useUser(); + const entry = useGifProviderKey(user.id); + const [providerId, setProviderId] = useState( + entry?.provider ?? 'giphy', + ); + const [key, setKey] = useState(''); + const [editing, setEditing] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const provider = gifProviderFor(providerId); + + const connect = async () => { + if (processing) { + return; + } + + const candidate = key.trim(); + + if (candidate === '') { + return; + } + + setProcessing(true); + setError(null); + + const verdict = await provider.validateKey(candidate); + + if (verdict === 'invalid') { + setError(`That key was rejected by ${provider.label}.`); + setProcessing(false); + + return; + } + + if (verdict === 'unreachable') { + setError(`Couldn't reach ${provider.label} to verify the key.`); + setProcessing(false); + + return; + } + + writeGifProviderKey(user.id, { + provider: providerId, + key: candidate, + }); + setKey(''); + setError(null); + setEditing(false); + setProcessing(false); + }; + + const remove = () => { + clearGifProviderKey(user.id); + setError(null); + setEditing(false); + }; + + return ( +
+

+ GIFs +

+

+ GIF search runs on your own {provider.label} API key, under your + own quota. Get a key at{' '} + + {provider.label} + + , save it here, and every composer gains a GIF picker. One + provider at a time; without a key the picker stays hidden. +

+

+ Stored in this browser only — never sent to the server. Each + device needs its own key. +

+ {entry !== null && !editing ? ( +
+

+ Connected to{' '} + {gifProviderFor(entry.provider).label}. +

+ + +
+ ) : ( +
+
+ {Object.values(gifProviders).map((candidate) => ( + + ))} +
+ +
+ setKey(event.target.value)} + placeholder={`Paste your ${provider.label} API key`} + disabled={processing} + autoComplete="off" + spellCheck={false} + className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" + data-test="gif-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} +
+ ); +} +``` + +- [ ] **Step 4: Stop passing server props on the profile page** + +In `resources/js/pages/user-profile/edit.tsx`: remove `gifConnected` and `gifProvider` from the destructured props and the inline prop type, and render `` (no props). The server may keep sending the page props until Task 4; they are simply unused. + +- [ ] **Step 5: Update the README** + +Replace the README "GIFs" paragraph with: + +```markdown +GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. +``` + +- [ ] **Step 6: Run the JS gates** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add resources/js/components/gif-key-setting.tsx resources/js/pages/user-profile/edit.tsx README.md tests/Unit/GifKeySetting.test.tsx +git commit -m "feat: manage the gif provider key in the browser" +``` + +--- + +### Task 3: Read the stored key in the picker button + +**Files:** +- Modify: `resources/js/components/gif-picker-button.tsx` +- Test: `tests/Unit/GifPickerDialog.test.tsx` (extend; the button shares this config) + +**Interfaces:** +- Consumes: `useGifProviderKey` (Task 1), `usePage().props.auth` (`Profile` has `id`). +- Produces: the button renders only when a valid stored entry exists and passes `gifProviderFor(entry.provider)` + `entry.key` to `GifPickerDialog`. + +- [ ] **Step 1: Update the picker test mocks and write the failing button tests** + +In `tests/Unit/GifPickerDialog.test.tsx`: + +1. Replace the `@inertiajs/react` mock with: + +```ts +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ props: { auth: { user: { id: 'user-1' } } } }), +})); +``` + +2. Add a localStorage/window stub in `beforeEach` (and import the storage module after the mocks): + +```ts +const { writeGifProviderKey } = await import( + '../../resources/js/lib/gif-provider-key' +); + +let store: Map; +``` + +Inside `beforeEach`: + +```ts + store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; +``` + +3. Replace the existing final test (`renders nothing when no gif provider is connected`) with: + +```tsx +test('renders nothing when no gif provider key is stored', () => { + assert.equal(GifPickerButton({ hasGif: false, onPick: vi.fn() }), null); +}); + +test('opens the picker with the stored provider and key', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'stored-key' }); + + const fields = elements( + GifPickerButton({ hasGif: false, onPick: vi.fn() }) as ReactElement, + ); + const dialog = fields.find( + (element) => element.props.apiKey === 'stored-key', + ); + + assert.ok(dialog); + assert.equal(dialog.props.provider?.id, 'klipy'); + assert.equal(dialog.props.open, false); +}); +``` + +(Note: the `provider` fixture at the top of the file is a FAUX adapter with `id: 'klipy'`, but the button resolves the real KLIPY adapter from the registry; assert via the dialog's `apiKey` and `provider.id` as shown.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun run test:unit` +Expected: FAIL — the button still reads `usePage().props.gif`. + +- [ ] **Step 3: Rewrite the button** + +Replace `resources/js/components/gif-picker-button.tsx` with: + +```tsx +import { usePage } from '@inertiajs/react'; +import { ImagePlay } from 'lucide-react'; +import { useState } from 'react'; +import { GifPickerDialog } from '@/components/gif-picker-dialog'; +import { Button } from '@/components/ui/button'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; +import { gifProviderFor } from '@/lib/gif-providers'; +import type { GifAttachment } from '@/lib/gif-providers'; +import { cn } from '@/lib/utils'; + +/** Invisible until the signed-in user stores a GIF provider key in settings. */ +export function GifPickerButton({ + disabled, + hasGif, + onPick, + className, +}: { + disabled?: boolean; + hasGif: boolean; + onPick: (gif: GifAttachment) => void; + className?: string; +}) { + const { auth } = usePage().props; + const entry = useGifProviderKey(auth.user?.id ?? null); + const [open, setOpen] = useState(false); + + if (entry === null) { + return null; + } + + return ( + <> + + + + ); +} +``` + +- [ ] **Step 4: Run the JS gates** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add resources/js/components/gif-picker-button.tsx tests/Unit/GifPickerDialog.test.tsx +git commit -m "feat: read the gif provider key from the browser" +``` + +--- + +### Task 4: Remove server-side key storage and plumbing + +**Files:** +- Delete: `database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php` +- Delete: `app/Http/Controllers/GifKeyController.php`, `app/Http/Requests/UpdateGifKeyRequest.php` +- Delete: `tests/Feature/Controllers/GifKeyControllerTest.php`, `tests/Feature/Middleware/GifPropTest.php` +- Modify: `app/Models/User.php`, `database/factories/UserFactory.php`, `app/Http/Middleware/HandleInertiaRequests.php`, `app/Http/Controllers/UserProfileController.php`, `routes/web.php`, `resources/js/types/global.d.ts` +- Modify: `tests/Feature/Controllers/UserProfileControllerTest.php`, `tests/Feature/Controllers/ExportControllerTest.php`, `tests/Unit/Models/UserTest.php` + +**Interfaces:** +- Consumes: nothing from earlier tasks (this is pure removal). +- Produces: no users key/provider columns, no `settings/gif` routes, no `gif` Inertia prop, no profile-page gif props; commit metadata untouched. + +- [ ] **Step 1: Delete the files** + +```bash +git rm database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php +git rm app/Http/Controllers/GifKeyController.php app/Http/Requests/UpdateGifKeyRequest.php +git rm tests/Feature/Controllers/GifKeyControllerTest.php tests/Feature/Middleware/GifPropTest.php +``` + +Then `php artisan migrate:fresh` (the users columns disappear; the feature is unreleased). + +- [ ] **Step 2: Remove the model and page plumbing** + +1. `app/Models/User.php`: remove `use App\Enums\GifProvider;` (if it has no other use), the two docblock lines `@property-read GifProvider|null $gif_provider` / `@property-read string|null $gif_key`, the `'gif_provider'`/`'gif_key'` entries from `#[Hidden([...])]`, the two `$attributes` defaults, and the `'gif_provider' => GifProvider::class,` / `'gif_key' => 'encrypted',` casts. +2. `database/factories/UserFactory.php`: remove the whole `withGifKey(...)` method and the `use App\Enums\GifProvider;` import (it has no other use). +3. `app/Http/Middleware/HandleInertiaRequests.php`: delete the whole `'gif' => fn (): ?array => (function () use ($request): ?array { ... })(),` entry. +4. `app/Http/Controllers/UserProfileController.php`: delete the `'gifConnected' => ...` and `'gifProvider' => ...` lines. +5. `routes/web.php`: delete the controller import `use App\Http\Controllers\GifKeyController;`, the `// GIF provider...` comment, and the two `settings/gif` route lines. +6. `resources/js/types/global.d.ts`: delete the `import type { GifProviderId } ...` line and the `gif: { provider: GifProviderId; key: string } | null;` prop. + +Regenerate routes: `php artisan wayfinder:generate --with-form` (generated files are gitignored). + +- [ ] **Step 3: Remove the now-invalid tests** + +1. `tests/Feature/Controllers/UserProfileControllerTest.php`: delete the whole `it('renders the connected gif provider', ...)` test (the one with `->with([null, 'giphy', 'klipy'])`). Remove the now-unused `GifProvider` import if present. +2. `tests/Feature/Controllers/ExportControllerTest.php`: delete the whole `it('never exports the gif key', ...)` test. +3. `tests/Unit/Models/UserTest.php`: delete the tests that exercise the removed columns — the encrypted `gif_key` round-trip test, the `gif_provider`/`gif_key` null-default test, and the `gif_key`/`gif_provider` serialization test (search for `gif_key`/`gif_provider` and remove those tests entirely). Keep the pre-existing `to array` and all other tests untouched. + +- [ ] **Step 4: Run the focused servers tests** + +Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php tests/Feature/Controllers/ExportControllerTest.php tests/Unit/Models/UserTest.php` +Expected: PASS. + +Run: `rg -n "gif_key|gif_provider|withGifKey" app database tests resources` and confirm only commit-side `gif_provider` references remain (enum, commit model, requests, factories, serialization, tests) — no users key/provider or `withGifKey` references. + +- [ ] **Step 5: Run the JS gates** + +Run: `bun run test:unit && bun run test:types && bun run test:lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A app database routes resources tests +git commit -m "refactor: drop server-side gif key storage" +``` + +--- + +### Task 5: Sweep and full gates + +**Files:** +- Modify: any leftovers +- Test: full suites + +- [ ] **Step 1: Sweep for server key leftovers** + +Run: + +```bash +rg -n "gif_key|withGifKey|GifKeyController|UpdateGifKeyRequest|gif-key |'gif' =>|gifConnected|gifProvider" app resources tests routes database .env.example README.md +``` + +Expected: no hits except `gif_provider` (commit metadata) and provider-adapter names. Fix anything else. + +- [ ] **Step 2: Rebuild and regenerate** + +```bash +php artisan wayfinder:generate --with-form +bun run build +``` + +- [ ] **Step 3: Run the full gates** + +```bash +composer test:lint +composer test:types +composer test:type-coverage +php -d memory_limit=-1 vendor/bin/pest --fresh +bun run test:unit +bun run test:types +bun run test:lint +``` + +Expected: all PASS. Note in the PR that CI runs the PHP coverage leg (`--coverage --exactly=100.0`), which needs a local driver. + +- [ ] **Step 4: Commit residual fixes (only if any)** + +```bash +git add -A +git commit -m "chore: satisfy the gates for client-only gif keys" +``` From f7f70c831175b0d608db32f6623edb5bb70a82ce Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:39:01 -0400 Subject: [PATCH 55/83] feat: store the gif provider key in the browser --- resources/js/hooks/use-gif-provider-key.ts | 19 +++ resources/js/lib/gif-provider-key.ts | 122 +++++++++++++++++++ tests/Unit/GifProviderKey.test.ts | 129 +++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 resources/js/hooks/use-gif-provider-key.ts create mode 100644 resources/js/lib/gif-provider-key.ts create mode 100644 tests/Unit/GifProviderKey.test.ts diff --git a/resources/js/hooks/use-gif-provider-key.ts b/resources/js/hooks/use-gif-provider-key.ts new file mode 100644 index 000000000..074fcac76 --- /dev/null +++ b/resources/js/hooks/use-gif-provider-key.ts @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from 'react'; +import { + readGifProviderKey, + subscribeGifProviderKey, +} from '@/lib/gif-provider-key'; +import type { GifProviderKey } from '@/lib/gif-provider-key'; + +export function useGifProviderKey( + userId: string | null, +): GifProviderKey | null { + return useSyncExternalStore( + (listener) => + userId === null + ? () => undefined + : subscribeGifProviderKey(userId, listener), + () => (userId === null ? null : readGifProviderKey(userId)), + () => null, + ); +} diff --git a/resources/js/lib/gif-provider-key.ts b/resources/js/lib/gif-provider-key.ts new file mode 100644 index 000000000..9bef54930 --- /dev/null +++ b/resources/js/lib/gif-provider-key.ts @@ -0,0 +1,122 @@ +import { gifProviders } from './gif-providers/index.ts'; +import type { GifProviderId } from './gif-providers/types.ts'; +import { readStorage, writeStorage } from './storage.ts'; + +export type GifProviderKey = { + provider: GifProviderId; + key: string; +}; + +type Cached = { raw: string; value: GifProviderKey | null }; + +const cache = new Map(); +const listeners = new Map void>>(); + +const storageKey = (userId: string): string => `gif-provider:${userId}`; + +function parse(raw: string): GifProviderKey | null { + if (raw === '') { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const candidate = parsed as { provider?: unknown; key?: unknown }; + + if ( + typeof candidate.provider !== 'string' || + !Object.hasOwn(gifProviders, candidate.provider) || + typeof candidate.key !== 'string' || + candidate.key.trim() === '' || + candidate.key.length > 255 + ) { + return null; + } + + return { + provider: candidate.provider as GifProviderId, + key: candidate.key.trim(), + }; +} + +export function readGifProviderKey(userId: string): GifProviderKey | null { + const raw = readStorage(storageKey(userId)); + const cached = cache.get(userId); + + if (cached !== undefined && cached.raw === raw) { + return cached.value; + } + + const value = parse(raw); + cache.set(userId, { raw, value }); + + return value; +} + +function notify(userId: string): void { + listeners.get(userId)?.forEach((listener) => listener()); +} + +export function writeGifProviderKey( + userId: string, + value: GifProviderKey, +): void { + writeStorage( + storageKey(userId), + JSON.stringify({ provider: value.provider, key: value.key.trim() }), + ); + cache.delete(userId); + notify(userId); +} + +export function clearGifProviderKey(userId: string): void { + writeStorage(storageKey(userId), ''); + cache.delete(userId); + notify(userId); +} + +export function subscribeGifProviderKey( + userId: string, + listener: () => void, +): () => void { + const key = storageKey(userId); + const subscribed = listeners.get(userId) ?? new Set<() => void>(); + + subscribed.add(listener); + listeners.set(userId, subscribed); + + const onStorage = (event: StorageEvent): void => { + if (event.key !== key) { + return; + } + + cache.delete(userId); + listener(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('storage', onStorage); + } + + return () => { + subscribed.delete(listener); + + if (subscribed.size === 0) { + listeners.delete(userId); + } + + if (typeof window !== 'undefined') { + window.removeEventListener('storage', onStorage); + } + }; +} diff --git a/tests/Unit/GifProviderKey.test.ts b/tests/Unit/GifProviderKey.test.ts new file mode 100644 index 000000000..c9062b6d9 --- /dev/null +++ b/tests/Unit/GifProviderKey.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import { + clearGifProviderKey, + readGifProviderKey, + subscribeGifProviderKey, + writeGifProviderKey, +} from '../../resources/js/lib/gif-provider-key.ts'; + +let store: Map; +let storageHandlers: Array<(event: StorageEvent) => void>; +const originalWindow = globalThis.window; + +function installWindow(): void { + store = new Map(); + storageHandlers = []; + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers.push(handler); + }, + removeEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers = storageHandlers.filter((candidate) => candidate !== handler); + }, + } as unknown as Window & typeof globalThis; +} + +beforeEach(installWindow); + +afterEach(() => { + globalThis.window = originalWindow; +}); + +test('round-trips a key and keeps a stable reference while unchanged', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k-123' }); + + const first = readGifProviderKey('user-1'); + const second = readGifProviderKey('user-1'); + + assert.deepEqual(first, { provider: 'klipy', key: 'k-123' }); + assert.equal(first, second); +}); + +test('namespaces keys per user', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'one' }); + writeGifProviderKey('user-2', { provider: 'klipy', key: 'two' }); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'one' }); + assert.deepEqual(readGifProviderKey('user-2'), { provider: 'klipy', key: 'two' }); + assert.equal(readGifProviderKey('user-3'), null); +}); + +test('treats malformed values as absent', () => { + store.set('gif-provider:user-1', '{not json'); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'unknown', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'toString', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: '__proto__', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: ' ' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'a'.repeat(256) })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 42 })); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('writes the trimmed key and notifies same-tab subscribers', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + writeGifProviderKey('user-1', { provider: 'giphy', key: ' k-123 ' }); + + assert.equal(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'k-123' })); + assert.deepEqual(calls, ['hit']); + + unsubscribe(); + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-456' }); + assert.deepEqual(calls, ['hit']); +}); + +test('clears the entry and notifies', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); + + const calls: string[] = []; + subscribeGifProviderKey('user-1', () => calls.push('hit')); + + clearGifProviderKey('user-1'); + + assert.equal(store.has('gif-provider:user-1'), false); + assert.deepEqual(calls, ['hit']); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('reacts to storage events for its own key only', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + assert.equal(storageHandlers.length, 1); + + storageHandlers[0]!({ key: 'gif-provider:user-2' } as StorageEvent); + assert.deepEqual(calls, []); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'other-tab' })); + storageHandlers[0]!({ key: 'gif-provider:user-1' } as StorageEvent); + + assert.deepEqual(calls, ['hit']); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'other-tab' }); + + unsubscribe(); + assert.equal(storageHandlers.length, 0); +}); From 114ee7f64347850b0d5f30cdcc74d33b3278d248 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:43:44 -0400 Subject: [PATCH 56/83] feat: manage the gif provider key in the browser --- README.md | 2 +- resources/js/components/gif-key-setting.tsx | 89 ++++---- resources/js/pages/user-profile/edit.tsx | 10 +- tests/Unit/GifKeySetting.test.tsx | 213 +++++++++----------- 4 files changed, 134 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 6e5227ab8..4870972b4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. +GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. ### Verify installation diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index f0fbcda28..76645068c 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -1,12 +1,13 @@ -import { router } from '@inertiajs/react'; import { useState } from 'react'; -import { - destroy, - update, -} from '@/actions/App/Http/Controllers/GifKeyController'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; +import { useUser } from '@/hooks/use-user'; +import { + clearGifProviderKey, + writeGifProviderKey, +} from '@/lib/gif-provider-key'; import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; import type { GifProviderId } from '@/lib/gif-providers'; @@ -15,18 +16,14 @@ const helpHref: Record = { klipy: 'https://partner.klipy.com', }; -export function GifKeySetting({ - gifConnected, - gifProvider, -}: { - gifConnected: boolean; - gifProvider: GifProviderId | null; -}) { +export function GifKeySetting() { + const { user } = useUser(); + const entry = useGifProviderKey(user.id); const [providerId, setProviderId] = useState( - gifProvider ?? 'giphy', + entry?.provider ?? 'giphy', ); const [key, setKey] = useState(''); - const [editing, setEditing] = useState(!gifConnected); + const [editing, setEditing] = useState(false); const [processing, setProcessing] = useState(false); const [error, setError] = useState(null); const provider = gifProviderFor(providerId); @@ -61,40 +58,20 @@ export function GifKeySetting({ return; } - router.put( - update.url(), - { provider: providerId, key: candidate }, - { - preserveScroll: true, - onFinish: () => setProcessing(false), - onError: (errors) => - setError( - errors.key ?? - errors.provider ?? - 'Could not save the key.', - ), - onSuccess: () => { - setKey(''); - setError(null); - setEditing(false); - }, - }, - ); + writeGifProviderKey(user.id, { + provider: providerId, + key: candidate, + }); + setKey(''); + setError(null); + setEditing(false); + setProcessing(false); }; const remove = () => { - if (processing) { - return; - } - + clearGifProviderKey(user.id); setError(null); - - router.delete(destroy.url(), { - preserveScroll: true, - onStart: () => setProcessing(true), - onFinish: () => setProcessing(false), - onError: () => setError('Could not remove the provider.'), - }); + setEditing(false); }; return ( @@ -120,21 +97,29 @@ export function GifKeySetting({ > {provider.label} - , connect it here, and every composer gains a GIF picker. One + , save it here, and every composer gains a GIF picker. One provider at a time; without a key the picker stays hidden.

- {gifConnected && !editing ? ( +

+ Stored in this browser only — never sent to the server. Each + device needs its own key. +

+ {entry !== null && !editing ? (

- Connected to{' '} - {gifProviderFor(gifProvider ?? 'giphy').label}. + Connected to {gifProviderFor(entry.provider).label}.

@@ -202,7 +185,7 @@ export function GifKeySetting({ data-test="gif-connect-button" > {processing ? : null} - {gifConnected ? 'Update' : 'Connect'} + {entry !== null || editing ? 'Update' : 'Connect'}
diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index fcb007eda..237780c64 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -17,7 +17,6 @@ import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import type { GifProviderId } from '@/lib/gif-providers'; import { formatFullDateTime, formatSyncedAgo } from '@/lib/utils'; import { show } from '@/routes/user'; import type { Lane } from '@/types'; @@ -26,16 +25,12 @@ export default function Edit({ preferredLane, automaticLane, githubSyncedAt, - gifConnected, - gifProvider, activityGraphUrl, profileUrl, }: { preferredLane: Lane | null; automaticLane: number; githubSyncedAt: string | null; - gifConnected: boolean; - gifProvider: GifProviderId | null; activityGraphUrl: string; profileUrl: string; }) { @@ -117,10 +112,7 @@ export default function Edit({ - + diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index 2fbc709b1..df007c8a8 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -42,26 +42,21 @@ vi.doMock('react', () => ({ }, })); -const put = vi.fn(); -const destroy = vi.fn(); - -vi.doMock('@inertiajs/react', async () => ({ - ...(await vi.importActual('@inertiajs/react')), - router: { put, delete: destroy }, +vi.doMock('../../resources/js/hooks/use-user', () => ({ + useUser: () => ({ user: { id: 'user-1' } }), })); const { GifKeySetting } = await import( '../../resources/js/components/gif-key-setting' ); +const { readGifProviderKey, writeGifProviderKey } = await import( + '../../resources/js/lib/gif-provider-key' +); type Element = ReactElement>; -type Props = { - gifConnected: boolean; - gifProvider: 'giphy' | 'klipy' | null; -}; -const disconnected: Props = { gifConnected: false, gifProvider: null }; -const connected: Props = { gifConnected: true, gifProvider: 'giphy' }; +let store: Map; +const originalWindow = globalThis.window; function elements(node: ReactNode): Element[] { if (!React.isValidElement(node)) { @@ -78,10 +73,10 @@ function elements(node: ReactNode): Element[] { ]; } -function render(props: Props): Element[] { +function render(): Element[] { cursor = 0; - return elements(GifKeySetting(props)); + return elements(GifKeySetting()); } function field(fields: Element[], testId: string): Element { @@ -107,81 +102,102 @@ function stubFetch(result: number | 'network'): void { ); } -function typeKey(fields: Element[], value: string, props: Props): Element[] { +function typeKey(fields: Element[], value: string): Element[] { (field(fields, 'gif-key-input').props.onChange as (event: { target: { value: string }; - }) => void)({ - target: { value }, - }); + }) => void)({ target: { value } }); - return render(props); + return render(); } function selectProvider(fields: Element[], id: 'klipy'): Element[] { (field(fields, `provider-${id}`).props.onClick as () => void)(); - return render(disconnected); + return render(); } beforeEach(() => { state = []; cursor = 0; + store = new Map(); vi.clearAllMocks(); vi.unstubAllGlobals(); + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; }); -test('saves a valid key after giphy accepts it', async () => { +test('shows the browser-only notice', () => { + const fields = render(); + + assert.ok( + fields.some( + (element) => + typeof element.props.children === 'string' && + element.props.children.includes('Stored in this browser only'), + ), + ); +}); + +test('saves a valid key to local storage after giphy accepts it', async () => { stubFetch(200); - let fields = typeKey(render(disconnected), ' key-123 ', disconnected); + let fields = typeKey(render(), ' key-123 '); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 1); - assert.equal(put.mock.calls[0]?.[0], '/settings/gif'); - assert.deepEqual(put.mock.calls[0]?.[1], { - provider: 'giphy', - key: 'key-123', - }); - - const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; - options.onSuccess(); + assert.deepEqual(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'key-123' })); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); - fields = render(connected); - assert.ok(field(fields, 'gif-connected')); + fields = render(); + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to GIPHY.', + ); }); test('saves the key against the selected provider', async () => { stubFetch(200); - let fields = selectProvider(render(disconnected), 'klipy'); - fields = typeKey(fields, 'key-123', disconnected); + let fields = selectProvider(render(), 'klipy'); + fields = typeKey(fields, 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 1); - assert.deepEqual(put.mock.calls[0]?.[1], { - provider: 'klipy', - key: 'key-123', - }); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); }); test('saves a rate-limited key because the key itself is real', async () => { stubFetch(429); - const fields = typeKey(render(disconnected), 'key-123', disconnected); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 1); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); }); test('rejects a refused key without saving', async () => { stubFetch(401); - const fields = typeKey(render(disconnected), 'key-123', disconnected); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); assert.equal( - field(render(disconnected), 'gif-key-error').props.children, + field(render(), 'gif-key-error').props.children, 'That key was rejected by GIPHY.', ); }); @@ -189,13 +205,13 @@ test('rejects a refused key without saving', async () => { test('names klipy when it refuses the key', async () => { stubFetch(401); - let fields = selectProvider(render(disconnected), 'klipy'); - fields = typeKey(fields, 'key-123', disconnected); + let fields = selectProvider(render(), 'klipy'); + fields = typeKey(fields, 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); assert.equal( - field(render(disconnected), 'gif-key-error').props.children, + field(render(), 'gif-key-error').props.children, 'That key was rejected by KLIPY.', ); }); @@ -203,17 +219,15 @@ test('names klipy when it refuses the key', async () => { test('clears the error when another provider is selected', async () => { stubFetch(401); - const fields = typeKey(render(disconnected), 'key-123', disconnected); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.ok(field(render(disconnected), 'gif-key-error')); - - (field(render(disconnected), 'provider-klipy').props.onClick as () => void)(); + assert.ok(field(render(), 'gif-key-error')); - const switched = render(disconnected); + (field(render(), 'provider-klipy').props.onClick as () => void)(); assert.equal( - switched.some( + render().some( (element) => element.props['data-test'] === 'gif-key-error', ), false, @@ -223,107 +237,72 @@ test('clears the error when another provider is selected', async () => { test('does not save when the provider cannot be reached', async () => { stubFetch('network'); - const fields = typeKey(render(disconnected), 'key-123', disconnected); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); assert.equal( - field(render(disconnected), 'gif-key-error').props.children, + field(render(), 'gif-key-error').props.children, "Couldn't reach GIPHY to verify the key.", ); }); -test('names klipy when it cannot be reached', async () => { - stubFetch('network'); - - let fields = selectProvider(render(disconnected), 'klipy'); - fields = typeKey(fields, 'key-123', disconnected); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.equal(put.mock.calls.length, 0); - assert.equal( - field(render(disconnected), 'gif-key-error').props.children, - "Couldn't reach KLIPY to verify the key.", - ); -}); - test('disables connect until a key is typed', () => { - const fields = render(disconnected); - - assert.equal(field(fields, 'gif-connect-button').props.disabled, true); + assert.equal(field(render(), 'gif-connect-button').props.disabled, true); }); test('does not save a whitespace-only key', async () => { stubFetch(200); - const fields = typeKey(render(disconnected), ' ', disconnected); + const fields = typeKey(render(), ' '); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.equal(put.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); }); -test('surfaces server validation errors', async () => { - stubFetch(200); - - const fields = typeKey(render(disconnected), 'key-123', disconnected); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); +test('shows the connected provider from local storage', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); - const options = put.mock.calls[0]?.[2] as { - onError: (errors: Record) => void; - }; - options.onError({ key: 'The key field is required.' }); + const fields = render(); assert.equal( - field(render(disconnected), 'gif-key-error').props.children, - 'The key field is required.', + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to KLIPY.', ); }); -test('removes a connected key', () => { - const fields = render(connected); +test('removes the stored key', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); + const fields = render(); (field(fields, 'gif-remove-button').props.onClick as () => void)(); - assert.equal(destroy.mock.calls.length, 1); - assert.equal(destroy.mock.calls[0]?.[0], '/settings/gif'); + assert.equal(readGifProviderKey('user-1'), null); + assert.ok(field(render(), 'gif-key-input')); }); test('replaces the connected provider when another is chosen', async () => { stubFetch(200); - let fields = render(connected); + writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); + + let fields = render(); (field(fields, 'change-provider-button').props.onClick as () => void)(); - fields = render(connected); + fields = render(); (field(fields, 'provider-klipy').props.onClick as () => void)(); - fields = render(connected); + fields = render(); (field(fields, 'gif-key-input').props.onChange as (event: { target: { value: string }; }) => void)({ target: { value: 'klipy-key' } }); - fields = render(connected); + fields = render(); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.deepEqual(put.mock.calls[0]?.[1], { - provider: 'klipy', - key: 'klipy-key', - }); - - const options = put.mock.calls[0]?.[2] as { onSuccess: () => void }; - options.onSuccess(); - - fields = render({ gifConnected: true, gifProvider: 'klipy' }); - - assert.equal( - React.Children.toArray( - field(fields, 'gif-connected').props.children as ReactNode, - ) - .map((child) => (typeof child === 'string' ? child : '')) - .join(''), - 'Connected to KLIPY.', - ); - - fields = render(disconnected); - assert.equal(field(fields, 'gif-key-input').props.value, ''); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'klipy-key' }); }); From e39c33f88d504886f49dd8795443780cc7ad4ea7 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:47:33 -0400 Subject: [PATCH 57/83] test: tighten the browser settings coverage --- tests/Unit/GifKeySetting.test.tsx | 32 +++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index df007c8a8..58eb52cc5 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -56,7 +56,6 @@ const { readGifProviderKey, writeGifProviderKey } = await import( type Element = ReactElement>; let store: Map; -const originalWindow = globalThis.window; function elements(node: ReactNode): Element[] { if (!React.isValidElement(node)) { @@ -139,14 +138,15 @@ beforeEach(() => { }); test('shows the browser-only notice', () => { - const fields = render(); + const note = field(render(), 'gif-storage-note'); - assert.ok( - fields.some( - (element) => - typeof element.props.children === 'string' && - element.props.children.includes('Stored in this browser only'), - ), + assert.equal( + React.Children.toArray(note.props.children as ReactNode) + .map((child) => (typeof child === 'string' ? child : '')) + .join('') + .replace(/\s+/g, ' ') + .trim(), + 'Stored in this browser only — never sent to the server. Each device needs its own key.', ); }); @@ -275,6 +275,22 @@ test('shows the connected provider from local storage', () => { ); }); +test('never echoes the stored key in the connected view', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'super-secret-key' }); + + const fields = render(); + assert.ok(field(fields, 'gif-connected')); + + const text = fields + .flatMap((element) => + React.Children.toArray(element.props.children as ReactNode), + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''); + + assert.equal(text.includes('super-secret-key'), false); +}); + test('removes the stored key', () => { writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); From 45960b22c4911164b1f02f16c3eb47b8529257be Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:51:39 -0400 Subject: [PATCH 58/83] feat: read the gif provider key from the browser --- resources/js/components/gif-picker-button.tsx | 12 +++--- tests/Unit/GifPickerDialog.test.tsx | 41 ++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/resources/js/components/gif-picker-button.tsx b/resources/js/components/gif-picker-button.tsx index 69f3da12a..3640eb9d9 100644 --- a/resources/js/components/gif-picker-button.tsx +++ b/resources/js/components/gif-picker-button.tsx @@ -3,11 +3,12 @@ import { ImagePlay } from 'lucide-react'; import { useState } from 'react'; import { GifPickerDialog } from '@/components/gif-picker-dialog'; import { Button } from '@/components/ui/button'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { gifProviderFor } from '@/lib/gif-providers'; import type { GifAttachment } from '@/lib/gif-providers'; import { cn } from '@/lib/utils'; -/** Invisible until the signed-in user connects a GIF provider in settings. */ +/** Invisible until the signed-in user stores a GIF provider key in settings. */ export function GifPickerButton({ disabled, hasGif, @@ -19,10 +20,11 @@ export function GifPickerButton({ onPick: (gif: GifAttachment) => void; className?: string; }) { - const { gif } = usePage().props; + const { auth } = usePage().props; + const entry = useGifProviderKey(auth.user?.id ?? null); const [open, setOpen] = useState(false); - if (gif === null) { + if (entry === null) { return null; } @@ -42,8 +44,8 @@ export function GifPickerButton({ ({ vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { gif: null } }), + usePage: () => ({ props: { auth: { user: { id: 'user-1' } } } }), })); const result = { @@ -84,6 +84,11 @@ const { GifPickerDialog } = await import( const { GifPickerButton } = await import( '../../resources/js/components/gif-picker-button' ); +const { writeGifProviderKey } = await import( + '../../resources/js/lib/gif-provider-key' +); + +let store: Map; type Element = ReactElement>; @@ -133,6 +138,20 @@ function field(fields: Element[], testId: string): Element { beforeEach(() => { state = []; cursor = 0; + store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; vi.clearAllMocks(); search.mockImplementation(async () => ({ results: [result], @@ -237,6 +256,24 @@ test('loads more with the cursor from the previous page', async () => { assert.ok(options?.signal instanceof AbortSignal); }); -test('renders nothing when no gif provider is connected', () => { +test('renders nothing when no gif provider key is stored', () => { assert.equal(GifPickerButton({ hasGif: false, onPick: vi.fn() }), null); }); + +test('opens the picker with the stored provider and key', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'stored-key' }); + + const fields = elements( + GifPickerButton({ hasGif: false, onPick: vi.fn() }) as ReactElement, + ); + const dialog = fields.find( + (element) => element.props.apiKey === 'stored-key', + ); + + assert.ok(dialog); + assert.equal( + (dialog.props.provider as GifProvider | undefined)?.id, + 'klipy', + ); + assert.equal(dialog.props.open, false); +}); From 99ef81095068654408e69f37903f31215fbb5f45 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 17:58:20 -0400 Subject: [PATCH 59/83] refactor: drop server-side gif key storage --- app/Http/Controllers/GifKeyController.php | 43 ---------- .../Controllers/UserProfileController.php | 2 - app/Http/Middleware/HandleInertiaRequests.php | 9 --- app/Http/Requests/UpdateGifKeyRequest.php | 43 ---------- app/Models/User.php | 9 --- database/factories/UserFactory.php | 9 --- ...0_17_133426_add_gif_key_to_users_table.php | 18 ----- resources/js/types/global.d.ts | 2 - routes/web.php | 5 -- .../Controllers/ExportControllerTest.php | 13 ---- .../Controllers/GifKeyControllerTest.php | 78 ------------------- .../Controllers/UserProfileControllerTest.php | 16 ---- tests/Feature/Middleware/GifPropTest.php | 40 ---------- tests/Unit/Models/UserTest.php | 31 -------- 14 files changed, 318 deletions(-) delete mode 100644 app/Http/Controllers/GifKeyController.php delete mode 100644 app/Http/Requests/UpdateGifKeyRequest.php delete mode 100644 database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php delete mode 100644 tests/Feature/Controllers/GifKeyControllerTest.php delete mode 100644 tests/Feature/Middleware/GifPropTest.php diff --git a/app/Http/Controllers/GifKeyController.php b/app/Http/Controllers/GifKeyController.php deleted file mode 100644 index d9165d85c..000000000 --- a/app/Http/Controllers/GifKeyController.php +++ /dev/null @@ -1,43 +0,0 @@ -provider(); - - $user->update([ - 'gif_provider' => $provider, - 'gif_key' => $request->string('key')->toString(), - ]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => $provider->label().' key connected.', - ]); - - return back(); - } - - public function destroy(#[CurrentUser] User $user): RedirectResponse - { - $user->update(['gif_provider' => null, 'gif_key' => null]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIF provider removed.', - ]); - - return back(); - } -} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index ffbced83d..519bdfbb7 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -19,8 +19,6 @@ public function edit(#[CurrentUser] User $user): Response 'preferredLane' => $user->preferredLane(), 'automaticLane' => $user->automaticLane(), 'githubSyncedAt' => $user->github_synced_at?->toIso8601String(), - 'gifConnected' => $user->gif_key !== null, - 'gifProvider' => $user->gif_provider?->value, 'activityGraphUrl' => route('user-activity-graph.show', $user), 'profileUrl' => route('user.show', $user), ]); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 569f36e1e..156d9cfbf 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -48,15 +48,6 @@ public function share(Request $request): array return [ ...parent::share($request), 'name' => config('app.name'), - 'gif' => fn (): ?array => (function () use ($request): ?array { - $user = $request->user(); - - if ($user?->gif_provider === null || $user->gif_key === null || $user->gif_key === '') { - return null; - } - - return ['provider' => $user->gif_provider->value, 'key' => $user->gif_key]; - })(), 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), 'isAdmin' => $isAdmin, diff --git a/app/Http/Requests/UpdateGifKeyRequest.php b/app/Http/Requests/UpdateGifKeyRequest.php deleted file mode 100644 index b9080a9b2..000000000 --- a/app/Http/Requests/UpdateGifKeyRequest.php +++ /dev/null @@ -1,43 +0,0 @@ -input('key'); - - if (is_string($key)) { - $this->merge(['key' => mb_trim($key)]); - } - } - - /** - * @return array> - */ - public function rules(): array - { - return [ - 'provider' => ['required', 'string', Rule::enum(GifProvider::class)], - 'key' => ['required', 'string', 'max:255'], - ]; - } - - public function provider(): GifProvider - { - return GifProvider::from($this->string('provider')->value()); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 940339c8c..6b3a5d984 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Actions\CreateTomFriendship; -use App\Enums\GifProvider; use App\Enums\Log; use App\Enums\RemotesFrom; use Carbon\CarbonInterface; @@ -47,8 +46,6 @@ * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at * @property-read string|null $remember_token - * @property-read GifProvider|null $gif_provider - * @property-read string|null $gif_key * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -72,8 +69,6 @@ 'github_id', 'email', 'remember_token', - 'gif_provider', - 'gif_key', 'preferred_lane', 'lane_color', 'pasteis_mode', @@ -110,8 +105,6 @@ final class User extends Authenticatable 'pasteis_received' => 0, 'pasteis_given_today' => 0, 'pasteis_given_on' => null, - 'gif_provider' => null, - 'gif_key' => null, ]; /** @@ -146,8 +139,6 @@ public function casts(): array 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', 'remember_token' => 'string', - 'gif_provider' => GifProvider::class, - 'gif_key' => 'encrypted', 'github_synced_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index d76ff6142..596c0ce17 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,7 +4,6 @@ namespace Database\Factories; -use App\Enums\GifProvider; use App\Enums\RemotesFrom; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -62,14 +61,6 @@ public function withoutProfile(): self ]); } - public function withGifKey(GifProvider $provider = GifProvider::Giphy, string $key = 'gif-test-key'): self - { - return $this->state(fn (array $attributes): array => [ - 'gif_provider' => $provider->value, - 'gif_key' => $key, - ]); - } - /** * Remotes turned on. Everyone may push by default here, so tests opt into requests on purpose. */ diff --git a/database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php b/database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php deleted file mode 100644 index d531fdca6..000000000 --- a/database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php +++ /dev/null @@ -1,18 +0,0 @@ -string('gif_provider', 16)->nullable(); - $table->text('gif_key')->nullable(); - }); - } -}; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index fa19f2dcd..6c795b860 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,4 +1,3 @@ -import type { GifProviderId } from '@/lib/gif-providers'; import type { Auth, SuggestedUser } from '@/types/auth'; import type { Commit } from '@/types/commit'; import type { CommittedFlash, DeletedFlash, FlashToast } from '@/types/ui'; @@ -19,7 +18,6 @@ declare module '@inertiajs/core' { hasUnmoderatedCommits: boolean; sidebarOpen: boolean; showsWhoToFollow: boolean; - gif: { provider: GifProviderId; key: string } | null; dimsLanes: boolean; repliesPerPage: number; suggestedUsers?: SuggestedUser[]; diff --git a/routes/web.php b/routes/web.php index cb4d23f57..04ddc9435 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,7 +41,6 @@ use App\Http\Controllers\FailedJobController; use App\Http\Controllers\FailedJobRetryController; use App\Http\Controllers\FeatureController; -use App\Http\Controllers\GifKeyController; use App\Http\Controllers\InboxController; use App\Http\Controllers\IssueController; use App\Http\Controllers\LocalSessionController; @@ -391,10 +390,6 @@ Route::put('settings/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('user-pasteis-mode.update'); - // GIF provider... - Route::put('settings/gif', [GifKeyController::class, 'update'])->name('gif-key.update'); - Route::delete('settings/gif', [GifKeyController::class, 'destroy'])->name('gif-key.destroy'); - Route::put('settings/remotes', [UserRemotesController::class, 'update'])->name('user-remotes.update'); // Appearance... diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index d4e629937..bee3ffb85 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -441,19 +441,6 @@ $this->actingAs($user)->get(route('export'))->assertOk(); }); -it('never exports the gif key', function (): void { - $user = User::factory()->withGifKey()->create(); - - $response = $this->actingAs($user)->get(route('export')); - - $response->assertOk(); - - $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - - expect($data['profile'])->not->toHaveKey('gif_key') - ->and($response->streamedContent())->not->toContain('gif-test-key'); -}); - it('exports the messages the user sent, and not the ones they received', function (): void { $user = User::factory()->create(); $partner = User::factory()->create(['username' => 'nunomaduro']); diff --git a/tests/Feature/Controllers/GifKeyControllerTest.php b/tests/Feature/Controllers/GifKeyControllerTest.php deleted file mode 100644 index a805e9782..000000000 --- a/tests/Feature/Controllers/GifKeyControllerTest.php +++ /dev/null @@ -1,78 +0,0 @@ -create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('gif-key.update'), ['provider' => 'giphy', 'key' => ' giphy-test-key ']) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIPHY key connected.', - ]); - - expect($user->refresh()->gif_key)->toBe('giphy-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('gif_key')) - ->not->toContain('giphy-test-key') - ->and($user->refresh()->gif_provider)->toBe(GifProvider::Giphy); -}); - -it('replaces one provider with another atomically', function (): void { - $user = User::factory()->withGifKey(GifProvider::Giphy, 'giphy-key')->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('gif-key.update'), ['provider' => 'klipy', 'key' => 'klipy-key']) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'KLIPY key connected.']); - - expect($user->refresh()) - ->gif_provider->toBe(GifProvider::Klipy) - ->gif_key->toBe('klipy-key'); -}); - -it('removes the key', function (): void { - $user = User::factory()->withGifKey()->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->delete(route('gif-key.destroy')) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', [ - 'type' => 'success', - 'message' => 'GIF provider removed.', - ]); - - expect($user->refresh()) - ->gif_key->toBeNull() - ->gif_provider->toBeNull(); -}); - -it('rejects invalid providers without touching the stored one', function (array $payload): void { - $user = User::factory()->withGifKey()->create(); - $field = in_array($payload['provider'] ?? null, ['giphy', 'klipy'], true) ? 'key' : 'provider'; - - $this->actingAs($user)->putJson(route('gif-key.update'), $payload) - ->assertUnprocessable()->assertJsonValidationErrors($field); - - expect($user->refresh()->gif_provider)->toBe(GifProvider::Giphy); -})->with([ - 'missing provider' => [fn (): array => ['key' => 'x']], - 'unknown provider' => [fn (): array => ['provider' => 'tenor', 'key' => 'x']], - 'missing key' => [fn (): array => ['provider' => 'giphy']], - 'blank key' => [fn (): array => ['provider' => 'giphy', 'key' => ' ']], - 'non-string key' => [fn (): array => ['provider' => 'giphy', 'key' => ['x']]], - 'too long' => [fn (): array => ['provider' => 'giphy', 'key' => str_repeat('a', 256)]], -]); - -it('requires authentication to connect a key', function (): void { - $this->putJson(route('gif-key.update'), ['provider' => 'giphy', 'key' => 'giphy-test-key'])->assertUnauthorized(); -}); - -it('requires authentication to remove a key', function (): void { - $this->deleteJson(route('gif-key.destroy'))->assertUnauthorized(); -}); diff --git a/tests/Feature/Controllers/UserProfileControllerTest.php b/tests/Feature/Controllers/UserProfileControllerTest.php index 5885a396d..cdee3b0e3 100644 --- a/tests/Feature/Controllers/UserProfileControllerTest.php +++ b/tests/Feature/Controllers/UserProfileControllerTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Enums\GifProvider; use App\Models\User; use Illuminate\Support\Facades\Http; @@ -189,18 +188,3 @@ Http::assertNothingSent(); }); - -it('renders the connected gif provider', function (?string $provider): void { - $user = $provider === null - ? User::factory()->create() - : User::factory()->withGifKey(GifProvider::from($provider))->create(); - - $this->actingAs($user) - ->get(route('user-profile.edit')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('gifConnected', $provider !== null) - ->where('gifProvider', $provider) - ->missing('auth.user.gif_key') - ->missing('auth.user.gif_provider')); -})->with([null, 'giphy', 'klipy']); diff --git a/tests/Feature/Middleware/GifPropTest.php b/tests/Feature/Middleware/GifPropTest.php deleted file mode 100644 index 05134a58b..000000000 --- a/tests/Feature/Middleware/GifPropTest.php +++ /dev/null @@ -1,40 +0,0 @@ -withGifKey(GifProvider::Klipy, 'klipy-key')->create(); - - $this->actingAs($user) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page - ->where('gif.provider', 'klipy') - ->where('gif.key', 'klipy-key')); -}); - -it('shares no gif config without a connected provider', function (): void { - $this->actingAs(User::factory()->create()) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); -}); - -it('shares no gif config for guests', function (): void { - $this->get(route('log.show', Log::Trending)) - ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); -}); - -it('shares no gif config when a provider has no key', function (?string $key): void { - $user = User::factory()->create([ - 'gif_provider' => GifProvider::Klipy, - 'gif_key' => $key, - ]); - - $this->actingAs($user) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); -})->with([null, '']); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 3844a23fe..18bf46600 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -241,34 +241,3 @@ ->and($user->blocking->modelKeys())->toBe([$other->id]) ->and($other->blockers->modelKeys())->toBe([$user->id]); }); - -it('stores the gif key encrypted and reads it back', function (): void { - $user = User::factory()->withGifKey(key: 'gif-test-key')->create(); - - expect($user->refresh()->gif_key)->toBe('gif-test-key') - ->and(DB::table('users')->where('id', $user->id)->value('gif_key')) - ->toBeString() - ->not->toBe('gif-test-key') - ->not->toContain('gif-test-key'); -}); - -it('defaults the gif key to null', function (): void { - expect(User::factory()->create()->refresh()->gif_key)->toBeNull(); -}); - -it('defaults the gif provider to null', function (): void { - $user = User::factory()->create()->refresh(); - - expect($user->gif_key)->toBeNull() - ->and($user->gif_provider)->toBeNull() - ->and(DB::table('users')->where('id', $user->id)->value('gif_provider'))->toBeNull(); -}); - -it('serializes no gif key or provider', function (): void { - $user = User::factory()->withGifKey()->create(); - - expect($user->toArray())->not->toHaveKey('gif_key') - ->not->toHaveKey('gif_provider') - ->and($user->jsonSerialize())->not->toHaveKey('gif_key') - ->not->toHaveKey('gif_provider'); -}); From 2273961e13267f770d0f5e21bf82b9f19b93cdbf Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 18:18:02 -0400 Subject: [PATCH 60/83] fix: keep the gif settings in sync with browser storage --- resources/js/components/gif-key-setting.tsx | 51 ++++++++--- resources/js/hooks/use-gif-provider-key.ts | 17 ++-- resources/js/lib/gif-provider-key.ts | 33 +++++++- tests/Unit/GifKeySetting.test.tsx | 93 ++++++++++++++++++++- tests/Unit/GifPickerDialog.test.tsx | 17 ++-- tests/Unit/GifProviderKey.test.ts | 45 +++++++++- 6 files changed, 231 insertions(+), 25 deletions(-) diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index 76645068c..be351d042 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; @@ -26,7 +26,23 @@ export function GifKeySetting() { const [editing, setEditing] = useState(false); const [processing, setProcessing] = useState(false); const [error, setError] = useState(null); - const provider = gifProviderFor(providerId); + const displayProviderId: GifProviderId = + editing || entry === null ? providerId : entry.provider; + const provider = gifProviderFor(displayProviderId); + const lastStored = useRef<{ provider?: GifProviderId; key?: string }>({ + provider: entry?.provider, + key: entry?.key, + }); + + useEffect(() => { + if ( + lastStored.current.provider !== entry?.provider || + lastStored.current.key !== entry?.key + ) { + lastStored.current = { provider: entry?.provider, key: entry?.key }; + setError(null); + } + }, [entry?.provider, entry?.key]); const connect = async () => { if (processing) { @@ -39,29 +55,44 @@ export function GifKeySetting() { return; } + if (candidate.length > 255) { + setError('That key is too long.'); + + return; + } + setProcessing(true); setError(null); - const verdict = await provider.validateKey(candidate); + const selected = gifProviderFor(providerId); + const verdict = await selected.validateKey(candidate); if (verdict === 'invalid') { - setError(`That key was rejected by ${provider.label}.`); + setError(`That key was rejected by ${selected.label}.`); setProcessing(false); return; } if (verdict === 'unreachable') { - setError(`Couldn't reach ${provider.label} to verify the key.`); + setError(`Couldn't reach ${selected.label} to verify the key.`); + setProcessing(false); + + return; + } + + if ( + !writeGifProviderKey(user.id, { + provider: providerId, + key: candidate, + }) + ) { + setError("Couldn't save the key in this browser."); setProcessing(false); return; } - writeGifProviderKey(user.id, { - provider: providerId, - key: candidate, - }); setKey(''); setError(null); setEditing(false); @@ -90,7 +121,7 @@ export function GifKeySetting() { GIF search runs on your own {provider.label} API key, under your own quota. Get a key at{' '} null; + export function useGifProviderKey( userId: string | null, ): GifProviderKey | null { - return useSyncExternalStore( - (listener) => + const subscribe = useMemo( + () => (listener: () => void) => userId === null ? () => undefined : subscribeGifProviderKey(userId, listener), - () => (userId === null ? null : readGifProviderKey(userId)), - () => null, + [userId], + ); + const getSnapshot = useMemo( + () => () => (userId === null ? null : readGifProviderKey(userId)), + [userId], ); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } diff --git a/resources/js/lib/gif-provider-key.ts b/resources/js/lib/gif-provider-key.ts index 9bef54930..5b9604574 100644 --- a/resources/js/lib/gif-provider-key.ts +++ b/resources/js/lib/gif-provider-key.ts @@ -70,13 +70,32 @@ function notify(userId: string): void { export function writeGifProviderKey( userId: string, value: GifProviderKey, -): void { +): boolean { + const key = value.key.trim(); + + if (key === '' || key.length > 255) { + return false; + } + writeStorage( storageKey(userId), - JSON.stringify({ provider: value.provider, key: value.key.trim() }), + JSON.stringify({ provider: value.provider, key }), ); cache.delete(userId); + + const stored = readGifProviderKey(userId); + + if ( + stored === null || + stored.provider !== value.provider || + stored.key !== key + ) { + return false; + } + notify(userId); + + return true; } export function clearGifProviderKey(userId: string): void { @@ -96,7 +115,15 @@ export function subscribeGifProviderKey( listeners.set(userId, subscribed); const onStorage = (event: StorageEvent): void => { - if (event.key !== key) { + if (event.key !== null && event.key !== key) { + return; + } + + if ( + event.storageArea !== undefined && + event.storageArea !== null && + event.storageArea !== window.localStorage + ) { return; } diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index 58eb52cc5..d943f5782 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; + +const originalWindow = globalThis.window; let state: unknown[] = []; let cursor = 0; @@ -137,6 +139,10 @@ beforeEach(() => { } as unknown as Window & typeof globalThis; }); +afterEach(() => { + globalThis.window = originalWindow; +}); + test('shows the browser-only notice', () => { const note = field(render(), 'gif-storage-note'); @@ -260,6 +266,54 @@ test('does not save a whitespace-only key', async () => { assert.equal(readGifProviderKey('user-1'), null); }); +test('rejects an overlong key before verifying it', async () => { + const fetchMock = vi.fn( + async () => new Response('{}', { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const fields = typeKey(render(), 'a'.repeat(256)); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(fetchMock.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key is too long.', + ); +}); + +test('keeps the typed key and edit mode when the browser refuses to save', async () => { + stubFetch(200); + + writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); + + let fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + fields = render(); + + globalThis.window.localStorage.setItem = () => undefined; + + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value: 'new-key' } }); + fields = render(); + + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + fields = render(); + + assert.equal(field(fields, 'gif-key-input').props.value, 'new-key'); + assert.equal( + field(fields, 'gif-key-error').props.children, + "Couldn't save the key in this browser.", + ); + assert.deepEqual(readGifProviderKey('user-1'), { + provider: 'giphy', + key: 'old', + }); +}); + test('shows the connected provider from local storage', () => { writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); @@ -275,6 +329,37 @@ test('shows the connected provider from local storage', () => { ); }); +test('describes the stored provider instead of the default one', () => { + let fields = render(); + + assert.ok( + fields.some( + (element) => element.props.href === 'https://developers.giphy.com', + ), + ); + + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); + + fields = render(); + + const description = fields.find( + (element) => element.props.id === 'gif-key-description', + ); + + assert.ok(description); + assert.ok( + React.Children.toArray(description.props.children as ReactNode) + .map((child) => (typeof child === 'string' ? child : '')) + .join('') + .includes('KLIPY'), + ); + assert.ok( + fields.some( + (element) => element.props.href === 'https://partner.klipy.com', + ), + ); +}); + test('never echoes the stored key in the connected view', () => { writeGifProviderKey('user-1', { provider: 'giphy', key: 'super-secret-key' }); @@ -321,4 +406,10 @@ test('replaces the connected provider when another is chosen', async () => { await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'klipy-key' }); + + fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + fields = render(); + + assert.equal(field(fields, 'gif-key-input').props.value, ''); }); diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index b03b4b96f..704d00312 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -2,9 +2,11 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; import type { GifProvider } from '../../resources/js/lib/gif-providers/types'; +const originalWindow = globalThis.window; + let state: unknown[] = []; let cursor = 0; @@ -87,6 +89,9 @@ const { GifPickerButton } = await import( const { writeGifProviderKey } = await import( '../../resources/js/lib/gif-provider-key' ); +const { gifProviderFor } = await import( + '../../resources/js/lib/gif-providers' +); let store: Map; @@ -163,6 +168,10 @@ beforeEach(() => { })); }); +afterEach(() => { + globalThis.window = originalWindow; +}); + test('uses the provider placeholder and attribution', () => { const fields = render(false); @@ -271,9 +280,7 @@ test('opens the picker with the stored provider and key', () => { ); assert.ok(dialog); - assert.equal( - (dialog.props.provider as GifProvider | undefined)?.id, - 'klipy', - ); + assert.equal(dialog.props.provider, gifProviderFor('klipy')); assert.equal(dialog.props.open, false); + assert.ok(field(fields, 'attach-gif-button')); }); diff --git a/tests/Unit/GifProviderKey.test.ts b/tests/Unit/GifProviderKey.test.ts index c9062b6d9..ea674543e 100644 --- a/tests/Unit/GifProviderKey.test.ts +++ b/tests/Unit/GifProviderKey.test.ts @@ -100,13 +100,41 @@ test('clears the entry and notifies', () => { writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); const calls: string[] = []; - subscribeGifProviderKey('user-1', () => calls.push('hit')); + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); clearGifProviderKey('user-1'); assert.equal(store.has('gif-provider:user-1'), false); assert.deepEqual(calls, ['hit']); assert.equal(readGifProviderKey('user-1'), null); + + unsubscribe(); +}); + +test('refuses invalid keys and reports nothing was written', () => { + assert.equal( + writeGifProviderKey('user-1', { provider: 'giphy', key: ' ' }), + false, + ); + assert.equal( + writeGifProviderKey('user-1', { + provider: 'giphy', + key: 'a'.repeat(256), + }), + false, + ); + assert.equal(store.has('gif-provider:user-1'), false); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('reports failure when storage swallows the write', () => { + globalThis.window.localStorage.setItem = () => undefined; + + assert.equal( + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }), + false, + ); + assert.equal(readGifProviderKey('user-1'), null); }); test('reacts to storage events for its own key only', () => { @@ -127,3 +155,18 @@ test('reacts to storage events for its own key only', () => { unsubscribe(); assert.equal(storageHandlers.length, 0); }); + +test('reacts to a full storage clear from another tab', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); + + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + store.clear(); + storageHandlers[0]!({ key: null } as unknown as StorageEvent); + + assert.deepEqual(calls, ['hit']); + assert.equal(readGifProviderKey('user-1'), null); + + unsubscribe(); +}); From f9bfd86692d9ffc04facacd0816a6197cd0dacb6 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 18:57:05 -0400 Subject: [PATCH 61/83] feat: default the gif settings to klipy --- resources/js/components/gif-key-setting.tsx | 6 +-- resources/js/lib/gif-providers/index.ts | 2 +- tests/Unit/GifKeySetting.test.tsx | 41 +++++++++++++-------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index be351d042..472402733 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -20,7 +20,7 @@ export function GifKeySetting() { const { user } = useUser(); const entry = useGifProviderKey(user.id); const [providerId, setProviderId] = useState( - entry?.provider ?? 'giphy', + entry?.provider ?? 'klipy', ); const [key, setKey] = useState(''); const [editing, setEditing] = useState(false); @@ -128,8 +128,8 @@ export function GifKeySetting() { > {provider.label} - , save it here, and every composer gains a GIF picker. One - provider at a time; without a key the picker stays hidden. + , save it here, and every composer gains a GIF picker. Without a + key the picker stays hidden.

= { - giphy, klipy, + giphy, }; export function gifProviderFor(id: GifProviderId): GifProvider { diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index d943f5782..2302702fd 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -111,7 +111,7 @@ function typeKey(fields: Element[], value: string): Element[] { return render(); } -function selectProvider(fields: Element[], id: 'klipy'): Element[] { +function selectProvider(fields: Element[], id: 'giphy' | 'klipy'): Element[] { (field(fields, `provider-${id}`).props.onClick as () => void)(); return render(); @@ -156,10 +156,21 @@ test('shows the browser-only notice', () => { ); }); +test('lists klipy first', () => { + const labels = render() + .filter((element) => + String(element.props['data-test']).startsWith('provider-'), + ) + .map((element) => element.props.children); + + assert.deepEqual(labels, ['KLIPY', 'GIPHY']); +}); + test('saves a valid key to local storage after giphy accepts it', async () => { stubFetch(200); - let fields = typeKey(render(), ' key-123 '); + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, ' key-123 '); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.deepEqual(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'key-123' })); @@ -176,11 +187,10 @@ test('saves a valid key to local storage after giphy accepts it', async () => { ); }); -test('saves the key against the selected provider', async () => { +test('defaults to klipy when no provider is stored', async () => { stubFetch(200); - let fields = selectProvider(render(), 'klipy'); - fields = typeKey(fields, 'key-123'); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); @@ -192,13 +202,14 @@ test('saves a rate-limited key because the key itself is real', async () => { const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); }); test('rejects a refused key without saving', async () => { stubFetch(401); - const fields = typeKey(render(), 'key-123'); + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.equal(readGifProviderKey('user-1'), null); @@ -211,8 +222,7 @@ test('rejects a refused key without saving', async () => { test('names klipy when it refuses the key', async () => { stubFetch(401); - let fields = selectProvider(render(), 'klipy'); - fields = typeKey(fields, 'key-123'); + const fields = typeKey(render(), 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.equal(readGifProviderKey('user-1'), null); @@ -230,7 +240,7 @@ test('clears the error when another provider is selected', async () => { assert.ok(field(render(), 'gif-key-error')); - (field(render(), 'provider-klipy').props.onClick as () => void)(); + (field(render(), 'provider-giphy').props.onClick as () => void)(); assert.equal( render().some( @@ -243,7 +253,8 @@ test('clears the error when another provider is selected', async () => { test('does not save when the provider cannot be reached', async () => { stubFetch('network'); - const fields = typeKey(render(), 'key-123'); + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, 'key-123'); await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); assert.equal(readGifProviderKey('user-1'), null); @@ -334,11 +345,11 @@ test('describes the stored provider instead of the default one', () => { assert.ok( fields.some( - (element) => element.props.href === 'https://developers.giphy.com', + (element) => element.props.href === 'https://partner.klipy.com', ), ); - writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); fields = render(); @@ -351,11 +362,11 @@ test('describes the stored provider instead of the default one', () => { React.Children.toArray(description.props.children as ReactNode) .map((child) => (typeof child === 'string' ? child : '')) .join('') - .includes('KLIPY'), + .includes('GIPHY'), ); assert.ok( fields.some( - (element) => element.props.href === 'https://partner.klipy.com', + (element) => element.props.href === 'https://developers.giphy.com', ), ); }); From 8966c6b9636e05fe569e180622c56d7ef9ebe5b2 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 19:03:16 -0400 Subject: [PATCH 62/83] fix: tighten the gif settings copy --- resources/js/components/gif-key-setting.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx index 472402733..c7b88f711 100644 --- a/resources/js/components/gif-key-setting.tsx +++ b/resources/js/components/gif-key-setting.tsx @@ -127,9 +127,8 @@ export function GifKeySetting() { className="underline underline-offset-4" > {provider.label} - - , save it here, and every composer gains a GIF picker. Without a - key the picker stays hidden. + {' '} + and save it here. Without a key, the picker stays hidden.

Date: Mon, 19 Oct 2026 19:06:19 -0400 Subject: [PATCH 63/83] chore: remove the superpowers planning docs --- ...6-10-19-gif-provider-key-client-storage.md | 1182 -------- .../plans/2026-10-19-gif-providers.md | 2468 ----------------- ...-gif-provider-key-client-storage-design.md | 93 - .../specs/2026-10-19-gif-providers-design.md | 219 -- 4 files changed, 3962 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md delete mode 100644 docs/superpowers/plans/2026-10-19-gif-providers.md delete mode 100644 docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md delete mode 100644 docs/superpowers/specs/2026-10-19-gif-providers-design.md diff --git a/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md b/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md deleted file mode 100644 index 9f7468764..000000000 --- a/docs/superpowers/plans/2026-10-19-gif-provider-key-client-storage.md +++ /dev/null @@ -1,1182 +0,0 @@ -# Client-Only GIF Provider Keys 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:** Move GIF provider keys out of the database into the user's browser (`localStorage`, namespaced per user), delete all server-side key storage/plumbing, and keep the server storing only public GIF attachment metadata. - -**Architecture:** A small storage module (`resources/js/lib/gif-provider-key.ts`) owns the namespaced localStorage entry plus a same-tab emitter and cross-tab `storage` listener; a `useGifProviderKey` hook exposes it through `useSyncExternalStore` with a deterministic `null` server snapshot. Settings and the picker button consume the hook. The users table loses its provider/key columns and the controller/request/routes/Inertia prop are deleted. Commit metadata (`gif_provider` + `gif_*`) is unchanged. - -**Tech Stack:** Laravel 13 (PHP 8.5, strict types), Inertia 3 + React 19 + TypeScript, Pest 5, node:test for lib tests, Vite+ for component tests. - -**Spec:** `docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md` - -## Global Constraints - -- **No secrets server-side.** Provider keys live only in `localStorage`; they are never sent to the server, never stored in the DB, never in page props, never logged, never in cookies. -- Storage entry: `gif-provider:{userId}` → JSON `{provider, key}`. The provider must be an **own** property of `gifProviders` (`Object.hasOwn`); key: non-empty (trimmed) string, length ≤ 255. Malformed values read as absent. -- Per-user namespacing: two accounts on one browser never share a key. Signing out does not clear it; clearing site data does. Each device needs its own key (documented). -- SSR-safe: `useSyncExternalStore` with `getServerSnapshot` returning `null`; no hydration mismatches. -- The unreleased feature has no back-compat: delete the users columns/migration and all server key plumbing outright. -- Commit metadata (`gif_provider`, `gif_*`) and all read surfaces stay unchanged. -- PHP: `declare(strict_types=1);`; validation only in Form Requests; no protected methods. Pest 100% line coverage; every new PHP branch tested; PHPStan max. No real HTTP in tests. -- Wayfinder: regenerate after route changes (`php artisan wayfinder:generate --with-form`); generated files are gitignored and never committed. -- House rule: `git add` new files immediately after creating them. -- Gates: PHP `composer test:lint`, `composer test:types`, `composer test:type-coverage`, full `php -d memory_limit=-1 vendor/bin/pest --fresh` (CI runs the `--exactly=100.0` coverage leg with pcov). JS: `bun run test:unit`, `bun run test:types`, `bun run test:lint`. - ---- - -### Task 1: Client key storage module and hook - -**Files:** -- Create: `resources/js/lib/gif-provider-key.ts` -- Create: `resources/js/hooks/use-gif-provider-key.ts` -- Test: `tests/Unit/GifProviderKey.test.ts` (create) - -**Interfaces:** -- Produces: `GifProviderKey = { provider: GifProviderId; key: string }`; `readGifProviderKey(userId): GifProviderKey | null` (stable reference while the raw value is unchanged); `writeGifProviderKey(userId, value): void`; `clearGifProviderKey(userId): void`; `subscribeGifProviderKey(userId, listener): () => void`. -- Produces: `useGifProviderKey(userId: string | null): GifProviderKey | null`. - -- [ ] **Step 1: Write the failing storage tests** - -Create `tests/Unit/GifProviderKey.test.ts`: - -```ts -import assert from 'node:assert/strict'; -import { afterEach, beforeEach, test } from 'node:test'; -import { - clearGifProviderKey, - readGifProviderKey, - subscribeGifProviderKey, - writeGifProviderKey, -} from '../../resources/js/lib/gif-provider-key.ts'; - -let store: Map; -let storageHandlers: Array<(event: StorageEvent) => void>; -const originalWindow = globalThis.window; - -function installWindow(): void { - store = new Map(); - storageHandlers = []; - - globalThis.window = { - localStorage: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - key: (index: number) => [...store.keys()][index] ?? null, - get length() { - return store.size; - }, - }, - addEventListener: (_type: string, handler: (event: StorageEvent) => void) => { - storageHandlers.push(handler); - }, - removeEventListener: (_type: string, handler: (event: StorageEvent) => void) => { - storageHandlers = storageHandlers.filter((candidate) => candidate !== handler); - }, - } as unknown as Window & typeof globalThis; -} - -beforeEach(installWindow); - -afterEach(() => { - globalThis.window = originalWindow; -}); - -test('round-trips a key and keeps a stable reference while unchanged', () => { - writeGifProviderKey('user-1', { provider: 'klipy', key: 'k-123' }); - - const first = readGifProviderKey('user-1'); - const second = readGifProviderKey('user-1'); - - assert.deepEqual(first, { provider: 'klipy', key: 'k-123' }); - assert.equal(first, second); -}); - -test('namespaces keys per user', () => { - writeGifProviderKey('user-1', { provider: 'giphy', key: 'one' }); - writeGifProviderKey('user-2', { provider: 'klipy', key: 'two' }); - - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'one' }); - assert.deepEqual(readGifProviderKey('user-2'), { provider: 'klipy', key: 'two' }); - assert.equal(readGifProviderKey('user-3'), null); -}); - -test('treats malformed values as absent', () => { - store.set('gif-provider:user-1', '{not json'); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'unknown', key: 'x' })); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'toString', key: 'x' })); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: '__proto__', key: 'x' })); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: ' ' })); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'a'.repeat(256) })); - assert.equal(readGifProviderKey('user-1'), null); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 42 })); - assert.equal(readGifProviderKey('user-1'), null); -}); - -test('writes the trimmed key and notifies same-tab subscribers', () => { - const calls: string[] = []; - const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); - - writeGifProviderKey('user-1', { provider: 'giphy', key: ' k-123 ' }); - - assert.equal(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'k-123' })); - assert.deepEqual(calls, ['hit']); - - unsubscribe(); - writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-456' }); - assert.deepEqual(calls, ['hit']); -}); - -test('clears the entry and notifies', () => { - writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); - - const calls: string[] = []; - subscribeGifProviderKey('user-1', () => calls.push('hit')); - - clearGifProviderKey('user-1'); - - assert.equal(store.has('gif-provider:user-1'), false); - assert.deepEqual(calls, ['hit']); - assert.equal(readGifProviderKey('user-1'), null); -}); - -test('reacts to storage events for its own key only', () => { - const calls: string[] = []; - const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); - - assert.equal(storageHandlers.length, 1); - - storageHandlers[0]!({ key: 'gif-provider:user-2' } as StorageEvent); - assert.deepEqual(calls, []); - - store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'other-tab' })); - storageHandlers[0]!({ key: 'gif-provider:user-1' } as StorageEvent); - - assert.deepEqual(calls, ['hit']); - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'other-tab' }); - - unsubscribe(); - assert.equal(storageHandlers.length, 0); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `node --experimental-strip-types --test tests/Unit/GifProviderKey.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement the storage module** - -Create `resources/js/lib/gif-provider-key.ts`: - -```ts -import { gifProviders } from './gif-providers/index.ts'; -import type { GifProviderId } from './gif-providers/types.ts'; -import { readStorage, writeStorage } from './storage.ts'; - -export type GifProviderKey = { - provider: GifProviderId; - key: string; -}; - -type Cached = { raw: string; value: GifProviderKey | null }; - -const cache = new Map(); -const listeners = new Map void>>(); - -const storageKey = (userId: string): string => `gif-provider:${userId}`; - -function parse(raw: string): GifProviderKey | null { - if (raw === '') { - return null; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (typeof parsed !== 'object' || parsed === null) { - return null; - } - - const candidate = parsed as { provider?: unknown; key?: unknown }; - - if ( - typeof candidate.provider !== 'string' || - !Object.hasOwn(gifProviders, candidate.provider) || - typeof candidate.key !== 'string' || - candidate.key.trim() === '' || - candidate.key.length > 255 - ) { - return null; - } - - return { - provider: candidate.provider as GifProviderId, - key: candidate.key.trim(), - }; -} - -export function readGifProviderKey(userId: string): GifProviderKey | null { - const raw = readStorage(storageKey(userId)); - const cached = cache.get(userId); - - if (cached !== undefined && cached.raw === raw) { - return cached.value; - } - - const value = parse(raw); - cache.set(userId, { raw, value }); - - return value; -} - -function notify(userId: string): void { - listeners.get(userId)?.forEach((listener) => listener()); -} - -export function writeGifProviderKey( - userId: string, - value: GifProviderKey, -): void { - writeStorage( - storageKey(userId), - JSON.stringify({ provider: value.provider, key: value.key.trim() }), - ); - cache.delete(userId); - notify(userId); -} - -export function clearGifProviderKey(userId: string): void { - writeStorage(storageKey(userId), ''); - cache.delete(userId); - notify(userId); -} - -export function subscribeGifProviderKey( - userId: string, - listener: () => void, -): () => void { - const key = storageKey(userId); - const subscribed = listeners.get(userId) ?? new Set<() => void>(); - - subscribed.add(listener); - listeners.set(userId, subscribed); - - const onStorage = (event: StorageEvent): void => { - if (event.key !== key) { - return; - } - - cache.delete(userId); - listener(); - }; - - if (typeof window !== 'undefined') { - window.addEventListener('storage', onStorage); - } - - return () => { - subscribed.delete(listener); - - if (subscribed.size === 0) { - listeners.delete(userId); - } - - if (typeof window !== 'undefined') { - window.removeEventListener('storage', onStorage); - } - }; -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `node --experimental-strip-types --test tests/Unit/GifProviderKey.test.ts` -Expected: PASS. - -- [ ] **Step 5: Implement the hook** - -Create `resources/js/hooks/use-gif-provider-key.ts`: - -```ts -import { useSyncExternalStore } from 'react'; -import { - readGifProviderKey, - subscribeGifProviderKey, -} from '@/lib/gif-provider-key'; -import type { GifProviderKey } from '@/lib/gif-provider-key'; - -export function useGifProviderKey( - userId: string | null, -): GifProviderKey | null { - return useSyncExternalStore( - (listener) => - userId === null - ? () => undefined - : subscribeGifProviderKey(userId, listener), - () => (userId === null ? null : readGifProviderKey(userId)), - () => null, - ); -} -``` - -- [ ] **Step 6: Run the JS gates** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS (the new node test runs under `test:unit`; nothing else consumes the hook yet). - -- [ ] **Step 7: Commit** - -```bash -git add resources/js/lib/gif-provider-key.ts resources/js/hooks/use-gif-provider-key.ts tests/Unit/GifProviderKey.test.ts -git commit -m "feat: store the gif provider key in the browser" -``` - ---- - -### Task 2: Client-side settings section - -**Files:** -- Modify: `resources/js/components/gif-key-setting.tsx` (rewrite; no server calls) -- Modify: `resources/js/pages/user-profile/edit.tsx` (no props) -- Modify: `README.md` (browser-only note) -- Test: `tests/Unit/GifKeySetting.test.tsx` (rewrite) - -**Interfaces:** -- Consumes: `useGifProviderKey`, `writeGifProviderKey`, `clearGifProviderKey` (Task 1); `gifProviders`/`gifProviderFor`; `useUser()` for `user.id`. -- Produces: `GifKeySetting()` — no props; renders "GIFs" with the browser-only notice, provider selector, write-only key, Connect/Update, Change provider, Remove; persists to `localStorage` only. - -- [ ] **Step 1: Rewrite the failing settings tests** - -Replace `tests/Unit/GifKeySetting.test.tsx` with: - -```tsx -/// -import assert from 'node:assert/strict'; -import * as React from 'react'; -import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; - -let state: unknown[] = []; -let cursor = 0; - -vi.doMock('react', () => ({ - ...React, - useEffect: (effect: () => void) => effect(), - useMemo: (factory: () => unknown) => factory(), - useId: () => `preview-${cursor++}`, - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => - snapshot(), - useRef: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = { current: initial }; - } - - return state[index]; - }, - useState: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = typeof initial === 'function' ? initial() : initial; - } - - return [ - state[index], - (value: unknown) => { - state[index] = - typeof value === 'function' - ? (value as (current: unknown) => unknown)(state[index]) - : value; - }, - ]; - }, -})); - -vi.doMock('../../resources/js/hooks/use-user', () => ({ - useUser: () => ({ user: { id: 'user-1' } }), -})); - -const { GifKeySetting } = await import( - '../../resources/js/components/gif-key-setting' -); -const { readGifProviderKey, writeGifProviderKey } = await import( - '../../resources/js/lib/gif-provider-key' -); - -type Element = ReactElement>; - -let store: Map; -const originalWindow = globalThis.window; - -function elements(node: ReactNode): Element[] { - if (!React.isValidElement(node)) { - return []; - } - - const element = node as Element; - - return [ - element, - ...React.Children.toArray(element.props.children as ReactNode).flatMap( - elements, - ), - ]; -} - -function render(): Element[] { - cursor = 0; - - return elements(GifKeySetting()); -} - -function field(fields: Element[], testId: string): Element { - const found = fields.find( - (element) => element.props['data-test'] === testId, - ); - - assert.ok(found, `no element with data-test="${testId}"`); - - return found; -} - -function stubFetch(result: number | 'network'): void { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - if (result === 'network') { - throw new TypeError('fetch failed'); - } - - return new Response('{}', { status: result }); - }), - ); -} - -function typeKey(fields: Element[], value: string): Element[] { - (field(fields, 'gif-key-input').props.onChange as (event: { - target: { value: string }; - }) => void)({ target: { value } }); - - return render(); -} - -function selectProvider(fields: Element[], id: 'klipy'): Element[] { - (field(fields, `provider-${id}`).props.onClick as () => void)(); - - return render(); -} - -beforeEach(() => { - state = []; - cursor = 0; - store = new Map(); - vi.clearAllMocks(); - vi.unstubAllGlobals(); - - globalThis.window = { - localStorage: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - key: (index: number) => [...store.keys()][index] ?? null, - get length() { - return store.size; - }, - }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as unknown as Window & typeof globalThis; -}); - -test('shows the browser-only notice', () => { - const fields = render(); - - assert.ok( - fields.some( - (element) => - typeof element.props.children === 'string' && - element.props.children.includes('Stored in this browser only'), - ), - ); -}); - -test('saves a valid key to local storage after giphy accepts it', async () => { - stubFetch(200); - - let fields = typeKey(render(), ' key-123 '); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.deepEqual(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'key-123' })); - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); - - fields = render(); - assert.equal( - React.Children.toArray( - field(fields, 'gif-connected').props.children as ReactNode, - ) - .map((child) => (typeof child === 'string' ? child : '')) - .join(''), - 'Connected to GIPHY.', - ); -}); - -test('saves the key against the selected provider', async () => { - stubFetch(200); - - let fields = selectProvider(render(), 'klipy'); - fields = typeKey(fields, 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); -}); - -test('saves a rate-limited key because the key itself is real', async () => { - stubFetch(429); - - const fields = typeKey(render(), 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); -}); - -test('rejects a refused key without saving', async () => { - stubFetch(401); - - const fields = typeKey(render(), 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.equal(readGifProviderKey('user-1'), null); - assert.equal( - field(render(), 'gif-key-error').props.children, - 'That key was rejected by GIPHY.', - ); -}); - -test('names klipy when it refuses the key', async () => { - stubFetch(401); - - let fields = selectProvider(render(), 'klipy'); - fields = typeKey(fields, 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.equal(readGifProviderKey('user-1'), null); - assert.equal( - field(render(), 'gif-key-error').props.children, - 'That key was rejected by KLIPY.', - ); -}); - -test('clears the error when another provider is selected', async () => { - stubFetch(401); - - const fields = typeKey(render(), 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.ok(field(render(), 'gif-key-error')); - - (field(render(), 'provider-klipy').props.onClick as () => void)(); - - assert.equal( - render().some( - (element) => element.props['data-test'] === 'gif-key-error', - ), - false, - ); -}); - -test('does not save when the provider cannot be reached', async () => { - stubFetch('network'); - - const fields = typeKey(render(), 'key-123'); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.equal(readGifProviderKey('user-1'), null); - assert.equal( - field(render(), 'gif-key-error').props.children, - "Couldn't reach GIPHY to verify the key.", - ); -}); - -test('disables connect until a key is typed', () => { - assert.equal(field(render(), 'gif-connect-button').props.disabled, true); -}); - -test('does not save a whitespace-only key', async () => { - stubFetch(200); - - const fields = typeKey(render(), ' '); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.equal(readGifProviderKey('user-1'), null); -}); - -test('shows the connected provider from local storage', () => { - writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); - - const fields = render(); - - assert.equal( - React.Children.toArray( - field(fields, 'gif-connected').props.children as ReactNode, - ) - .map((child) => (typeof child === 'string' ? child : '')) - .join(''), - 'Connected to KLIPY.', - ); -}); - -test('removes the stored key', () => { - writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); - - const fields = render(); - (field(fields, 'gif-remove-button').props.onClick as () => void)(); - - assert.equal(readGifProviderKey('user-1'), null); - assert.ok(field(render(), 'gif-key-input')); -}); - -test('replaces the connected provider when another is chosen', async () => { - stubFetch(200); - - writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); - - let fields = render(); - (field(fields, 'change-provider-button').props.onClick as () => void)(); - - fields = render(); - (field(fields, 'provider-klipy').props.onClick as () => void)(); - - fields = render(); - (field(fields, 'gif-key-input').props.onChange as (event: { - target: { value: string }; - }) => void)({ target: { value: 'klipy-key' } }); - - fields = render(); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'klipy-key' }); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `bun run test:unit` -Expected: FAIL — the component still requires `gifConnected`/`gifProvider` props and uses Inertia `router`. - -- [ ] **Step 3: Rewrite the component** - -Replace `resources/js/components/gif-key-setting.tsx` with: - -```tsx -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Spinner } from '@/components/ui/spinner'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; -import { useUser } from '@/hooks/use-user'; -import { - clearGifProviderKey, - writeGifProviderKey, -} from '@/lib/gif-provider-key'; -import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; -import type { GifProviderId } from '@/lib/gif-providers'; - -const helpHref: Record = { - giphy: 'https://developers.giphy.com', - klipy: 'https://partner.klipy.com', -}; - -export function GifKeySetting() { - const { user } = useUser(); - const entry = useGifProviderKey(user.id); - const [providerId, setProviderId] = useState( - entry?.provider ?? 'giphy', - ); - const [key, setKey] = useState(''); - const [editing, setEditing] = useState(false); - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(null); - const provider = gifProviderFor(providerId); - - const connect = async () => { - if (processing) { - return; - } - - const candidate = key.trim(); - - if (candidate === '') { - return; - } - - setProcessing(true); - setError(null); - - const verdict = await provider.validateKey(candidate); - - if (verdict === 'invalid') { - setError(`That key was rejected by ${provider.label}.`); - setProcessing(false); - - return; - } - - if (verdict === 'unreachable') { - setError(`Couldn't reach ${provider.label} to verify the key.`); - setProcessing(false); - - return; - } - - writeGifProviderKey(user.id, { - provider: providerId, - key: candidate, - }); - setKey(''); - setError(null); - setEditing(false); - setProcessing(false); - }; - - const remove = () => { - clearGifProviderKey(user.id); - setError(null); - setEditing(false); - }; - - return ( -

-

- GIFs -

-

- GIF search runs on your own {provider.label} API key, under your - own quota. Get a key at{' '} - - {provider.label} - - , save it here, and every composer gains a GIF picker. One - provider at a time; without a key the picker stays hidden. -

-

- Stored in this browser only — never sent to the server. Each - device needs its own key. -

- {entry !== null && !editing ? ( -
-

- Connected to{' '} - {gifProviderFor(entry.provider).label}. -

- - -
- ) : ( -
-
- {Object.values(gifProviders).map((candidate) => ( - - ))} -
- -
- setKey(event.target.value)} - placeholder={`Paste your ${provider.label} API key`} - disabled={processing} - autoComplete="off" - spellCheck={false} - className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="gif-key-input" - /> - -
-
- )} - {error !== null && ( -

- {error} -

- )} -
- ); -} -``` - -- [ ] **Step 4: Stop passing server props on the profile page** - -In `resources/js/pages/user-profile/edit.tsx`: remove `gifConnected` and `gifProvider` from the destructured props and the inline prop type, and render `` (no props). The server may keep sending the page props until Task 4; they are simply unused. - -- [ ] **Step 5: Update the README** - -Replace the README "GIFs" paragraph with: - -```markdown -GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. -``` - -- [ ] **Step 6: Run the JS gates** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add resources/js/components/gif-key-setting.tsx resources/js/pages/user-profile/edit.tsx README.md tests/Unit/GifKeySetting.test.tsx -git commit -m "feat: manage the gif provider key in the browser" -``` - ---- - -### Task 3: Read the stored key in the picker button - -**Files:** -- Modify: `resources/js/components/gif-picker-button.tsx` -- Test: `tests/Unit/GifPickerDialog.test.tsx` (extend; the button shares this config) - -**Interfaces:** -- Consumes: `useGifProviderKey` (Task 1), `usePage().props.auth` (`Profile` has `id`). -- Produces: the button renders only when a valid stored entry exists and passes `gifProviderFor(entry.provider)` + `entry.key` to `GifPickerDialog`. - -- [ ] **Step 1: Update the picker test mocks and write the failing button tests** - -In `tests/Unit/GifPickerDialog.test.tsx`: - -1. Replace the `@inertiajs/react` mock with: - -```ts -vi.doMock('@inertiajs/react', async () => ({ - ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { auth: { user: { id: 'user-1' } } } }), -})); -``` - -2. Add a localStorage/window stub in `beforeEach` (and import the storage module after the mocks): - -```ts -const { writeGifProviderKey } = await import( - '../../resources/js/lib/gif-provider-key' -); - -let store: Map; -``` - -Inside `beforeEach`: - -```ts - store = new Map(); - globalThis.window = { - localStorage: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - key: (index: number) => [...store.keys()][index] ?? null, - get length() { - return store.size; - }, - }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as unknown as Window & typeof globalThis; -``` - -3. Replace the existing final test (`renders nothing when no gif provider is connected`) with: - -```tsx -test('renders nothing when no gif provider key is stored', () => { - assert.equal(GifPickerButton({ hasGif: false, onPick: vi.fn() }), null); -}); - -test('opens the picker with the stored provider and key', () => { - writeGifProviderKey('user-1', { provider: 'klipy', key: 'stored-key' }); - - const fields = elements( - GifPickerButton({ hasGif: false, onPick: vi.fn() }) as ReactElement, - ); - const dialog = fields.find( - (element) => element.props.apiKey === 'stored-key', - ); - - assert.ok(dialog); - assert.equal(dialog.props.provider?.id, 'klipy'); - assert.equal(dialog.props.open, false); -}); -``` - -(Note: the `provider` fixture at the top of the file is a FAUX adapter with `id: 'klipy'`, but the button resolves the real KLIPY adapter from the registry; assert via the dialog's `apiKey` and `provider.id` as shown.) - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `bun run test:unit` -Expected: FAIL — the button still reads `usePage().props.gif`. - -- [ ] **Step 3: Rewrite the button** - -Replace `resources/js/components/gif-picker-button.tsx` with: - -```tsx -import { usePage } from '@inertiajs/react'; -import { ImagePlay } from 'lucide-react'; -import { useState } from 'react'; -import { GifPickerDialog } from '@/components/gif-picker-dialog'; -import { Button } from '@/components/ui/button'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; -import { gifProviderFor } from '@/lib/gif-providers'; -import type { GifAttachment } from '@/lib/gif-providers'; -import { cn } from '@/lib/utils'; - -/** Invisible until the signed-in user stores a GIF provider key in settings. */ -export function GifPickerButton({ - disabled, - hasGif, - onPick, - className, -}: { - disabled?: boolean; - hasGif: boolean; - onPick: (gif: GifAttachment) => void; - className?: string; -}) { - const { auth } = usePage().props; - const entry = useGifProviderKey(auth.user?.id ?? null); - const [open, setOpen] = useState(false); - - if (entry === null) { - return null; - } - - return ( - <> - - - - ); -} -``` - -- [ ] **Step 4: Run the JS gates** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add resources/js/components/gif-picker-button.tsx tests/Unit/GifPickerDialog.test.tsx -git commit -m "feat: read the gif provider key from the browser" -``` - ---- - -### Task 4: Remove server-side key storage and plumbing - -**Files:** -- Delete: `database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php` -- Delete: `app/Http/Controllers/GifKeyController.php`, `app/Http/Requests/UpdateGifKeyRequest.php` -- Delete: `tests/Feature/Controllers/GifKeyControllerTest.php`, `tests/Feature/Middleware/GifPropTest.php` -- Modify: `app/Models/User.php`, `database/factories/UserFactory.php`, `app/Http/Middleware/HandleInertiaRequests.php`, `app/Http/Controllers/UserProfileController.php`, `routes/web.php`, `resources/js/types/global.d.ts` -- Modify: `tests/Feature/Controllers/UserProfileControllerTest.php`, `tests/Feature/Controllers/ExportControllerTest.php`, `tests/Unit/Models/UserTest.php` - -**Interfaces:** -- Consumes: nothing from earlier tasks (this is pure removal). -- Produces: no users key/provider columns, no `settings/gif` routes, no `gif` Inertia prop, no profile-page gif props; commit metadata untouched. - -- [ ] **Step 1: Delete the files** - -```bash -git rm database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php -git rm app/Http/Controllers/GifKeyController.php app/Http/Requests/UpdateGifKeyRequest.php -git rm tests/Feature/Controllers/GifKeyControllerTest.php tests/Feature/Middleware/GifPropTest.php -``` - -Then `php artisan migrate:fresh` (the users columns disappear; the feature is unreleased). - -- [ ] **Step 2: Remove the model and page plumbing** - -1. `app/Models/User.php`: remove `use App\Enums\GifProvider;` (if it has no other use), the two docblock lines `@property-read GifProvider|null $gif_provider` / `@property-read string|null $gif_key`, the `'gif_provider'`/`'gif_key'` entries from `#[Hidden([...])]`, the two `$attributes` defaults, and the `'gif_provider' => GifProvider::class,` / `'gif_key' => 'encrypted',` casts. -2. `database/factories/UserFactory.php`: remove the whole `withGifKey(...)` method and the `use App\Enums\GifProvider;` import (it has no other use). -3. `app/Http/Middleware/HandleInertiaRequests.php`: delete the whole `'gif' => fn (): ?array => (function () use ($request): ?array { ... })(),` entry. -4. `app/Http/Controllers/UserProfileController.php`: delete the `'gifConnected' => ...` and `'gifProvider' => ...` lines. -5. `routes/web.php`: delete the controller import `use App\Http\Controllers\GifKeyController;`, the `// GIF provider...` comment, and the two `settings/gif` route lines. -6. `resources/js/types/global.d.ts`: delete the `import type { GifProviderId } ...` line and the `gif: { provider: GifProviderId; key: string } | null;` prop. - -Regenerate routes: `php artisan wayfinder:generate --with-form` (generated files are gitignored). - -- [ ] **Step 3: Remove the now-invalid tests** - -1. `tests/Feature/Controllers/UserProfileControllerTest.php`: delete the whole `it('renders the connected gif provider', ...)` test (the one with `->with([null, 'giphy', 'klipy'])`). Remove the now-unused `GifProvider` import if present. -2. `tests/Feature/Controllers/ExportControllerTest.php`: delete the whole `it('never exports the gif key', ...)` test. -3. `tests/Unit/Models/UserTest.php`: delete the tests that exercise the removed columns — the encrypted `gif_key` round-trip test, the `gif_provider`/`gif_key` null-default test, and the `gif_key`/`gif_provider` serialization test (search for `gif_key`/`gif_provider` and remove those tests entirely). Keep the pre-existing `to array` and all other tests untouched. - -- [ ] **Step 4: Run the focused servers tests** - -Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php tests/Feature/Controllers/ExportControllerTest.php tests/Unit/Models/UserTest.php` -Expected: PASS. - -Run: `rg -n "gif_key|gif_provider|withGifKey" app database tests resources` and confirm only commit-side `gif_provider` references remain (enum, commit model, requests, factories, serialization, tests) — no users key/provider or `withGifKey` references. - -- [ ] **Step 5: Run the JS gates** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add -A app database routes resources tests -git commit -m "refactor: drop server-side gif key storage" -``` - ---- - -### Task 5: Sweep and full gates - -**Files:** -- Modify: any leftovers -- Test: full suites - -- [ ] **Step 1: Sweep for server key leftovers** - -Run: - -```bash -rg -n "gif_key|withGifKey|GifKeyController|UpdateGifKeyRequest|gif-key |'gif' =>|gifConnected|gifProvider" app resources tests routes database .env.example README.md -``` - -Expected: no hits except `gif_provider` (commit metadata) and provider-adapter names. Fix anything else. - -- [ ] **Step 2: Rebuild and regenerate** - -```bash -php artisan wayfinder:generate --with-form -bun run build -``` - -- [ ] **Step 3: Run the full gates** - -```bash -composer test:lint -composer test:types -composer test:type-coverage -php -d memory_limit=-1 vendor/bin/pest --fresh -bun run test:unit -bun run test:types -bun run test:lint -``` - -Expected: all PASS. Note in the PR that CI runs the PHP coverage leg (`--coverage --exactly=100.0`), which needs a local driver. - -- [ ] **Step 4: Commit residual fixes (only if any)** - -```bash -git add -A -git commit -m "chore: satisfy the gates for client-only gif keys" -``` diff --git a/docs/superpowers/plans/2026-10-19-gif-providers.md b/docs/superpowers/plans/2026-10-19-gif-providers.md deleted file mode 100644 index 7e45992b7..000000000 --- a/docs/superpowers/plans/2026-10-19-gif-providers.md +++ /dev/null @@ -1,2468 +0,0 @@ -# Multi-Provider GIF Picker 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:** Generalize the GIPHY-only GIF picker into one active, bring-your-own-key provider chosen from GIPHY or KLIPY, behind a client `GifProvider` interface and a server `GifProvider` enum. - -**Architecture:** Provider-specific behavior lives in `resources/js/lib/gif-providers/*` (one adapter + one registry) and `App\Enums\GifProvider` (ids, labels, host allowlists). Everywhere else — picker UI, composer wiring, schema, serialization, feeds, OG metadata, moderation — uses generic `gif_*` fields and a `provider` discriminator. One active provider per user; connecting another replaces it. The feature is unreleased, so migrations are rewritten in place with no back-compat. - -**Tech Stack:** Laravel 13 (PHP 8.5, strict types), Inertia 3 + React 19 + TypeScript, Pest 5, node:test for lib tests, Vite+ for component tests. - -**Spec:** `docs/superpowers/specs/2026-10-19-gif-providers-design.md` - -## Global Constraints - -- PHP: `declare(strict_types=1);` in every file; classes `final`; actions `final readonly`; validation/authorization only in Form Requests (`tests/Unit/ArchTest.php` enforces). No `protected` methods (strict arch preset) — Form Request `prepareForValidation` is `public`. -- Tests: Pest with exactly 100.0% line coverage and 100% type coverage; PHPStan max. Every new PHP branch needs a test. -- No real HTTP in tests: `Http::preventStrayRequests()` is global; frontend tests stub `fetch`. -- **No server-side provider calls, ever.** The browser calls GIPHY/KLIPY directly; the server only stores and validates shape. Keys are validated in the browser before saving. -- GIPHY terms: `rating=g` always; media/page URLs stored and rendered verbatim; "Powered By GIPHY" mark in the picker; contributor attribution on cards. -- KLIPY terms: search placeholder must be exactly `Search KLIPY`; `Powered by KLIPY` mark in the picker; `contentfilter=high` on every call; URLs verbatim; KLIPY results are never blended with another provider (one active provider guarantees this). -- Naming: copy uses `GIPHY` / `KLIPY`; stored ids are `giphy` / `klipy`. -- The feature is unreleased: no backfills, no legacy aliases. After rewriting migrations, reset the local dev DB (`php artisan migrate:fresh`). -- Wayfinder: `resources/js/actions`, `resources/js/routes` are gitignored; after route/controller changes run `php artisan wayfinder:generate --with-form` and never commit generated files. -- House rule: `git add` new files immediately after creating them. -- Gates: PHP `composer test:lint`, `composer test:types`, `composer test:type-coverage`, full `php -d memory_limit=-1 vendor/bin/pest --fresh` (CI runs the `--exactly=100.0` coverage leg with pcov). JS: `bun run test:unit`, `bun run test:types`, `bun run test:lint`. - ---- - -### Task 1: Provider enum, schema rewrite, model and factory - -**Files:** -- Create: `app/Enums/GifProvider.php` -- Rename+rewrite: `database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php` → `..._add_gif_to_commits_table.php` -- Rename+rewrite: `database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php` → `..._add_gif_key_to_users_table.php` -- Modify: `app/Models/Commit.php`, `app/Models/User.php`, `app/Queries/CommitBuilder.php` -- Modify: `database/factories/CommitFactory.php`, `database/factories/UserFactory.php` -- Test: `tests/Unit/Enums/GifProviderTest.php` (create), `tests/Unit/Models/CommitTest.php`, `tests/Unit/Models/UserTest.php` - -**Interfaces:** -- Produces: `App\Enums\GifProvider` (cases `Giphy`, `Klipy`; `label(): string`, `mediaHostPatterns(): list`, `pageHostPatterns(): list`). -- Produces: Commit columns `gif_provider`, `gif_id`, `gif_url`, `gif_page_url`, `gif_title`, `gif_username`, `gif_width`, `gif_height`; `Commit::gif()` returns `{provider, id, url, page_url, title, username, width, height}|null`; `User` columns `gif_provider`, `gif_key`; `CommitFactory::withGif(GifProvider $provider = GifProvider::Giphy)`, `UserFactory::withGifKey(GifProvider $provider = GifProvider::Giphy, string $key = 'gif-test-key')`. - -- [ ] **Step 1: Write the failing enum test** - -Create `tests/Unit/Enums/GifProviderTest.php`: - -```php -label())->toBe('GIPHY') - ->and(GifProvider::Klipy->label())->toBe('KLIPY'); -}); - -it('accepts only its own media hosts', function (GifProvider $provider, string $host, bool $allowed): void { - $matches = array_filter( - $provider->mediaHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ); - - expect($matches !== [])->toBe($allowed); -})->with([ - 'giphy media' => [GifProvider::Giphy, 'media2.giphy.com', true], - 'giphy api host' => [GifProvider::Giphy, 'api.giphy.com', false], - 'klipy static' => [GifProvider::Klipy, 'static.klipy.com', true], - 'klipy numbered static' => [GifProvider::Klipy, 'static2.klipy.com', true], - 'klipy api host' => [GifProvider::Klipy, 'api.klipy.com', false], -]); - -it('accepts only its own page hosts', function (GifProvider $provider, string $host, bool $allowed): void { - $matches = array_filter( - $provider->pageHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ); - - expect($matches !== [])->toBe($allowed); -})->with([ - 'giphy page' => [GifProvider::Giphy, 'giphy.com', true], - 'giphy www page' => [GifProvider::Giphy, 'www.giphy.com', true], - 'klipy page' => [GifProvider::Klipy, 'klipy.com', true], - 'klipy www page' => [GifProvider::Klipy, 'www.klipy.com', true], - 'giphy page against klipy' => [GifProvider::Klipy, 'giphy.com', false], -]); -``` - -Update `tests/Unit/Models/CommitTest.php`: rename every `giphy_*` fixture key to `gif_*`, call `Commit::factory()->withGif()` instead of `withGiphy()`, and every expected `gif` payload gains `'provider' => 'giphy'` as the FIRST key. Add: - -```php -it('serializes the klipy provider too', function (): void { - $commit = Commit::factory()->withGif(GifProvider::Klipy)->create(); - - expect($commit->toArray()['gif'])->toMatchArray([ - 'provider' => 'klipy', - 'id' => 'hello-hi-662', - 'url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', - 'page_url' => 'https://klipy.com/gifs/hello-hi-662', - 'username' => null, - ]); -}); -``` - -Update `tests/Unit/Models/UserTest.php`: replace `withGiphyKey(...)` with `withGifKey(...)`; the encrypted round-trip test asserts `gif_key`; serialization tests assert `gif_key` and `gif_provider` are absent; add: - -```php -it('defaults the gif provider to null', function (): void { - $user = User::factory()->create()->refresh(); - - expect($user->gif_key)->toBeNull() - ->and($user->gif_provider)->toBeNull() - ->and(DB::table('users')->where('id', $user->id)->value('gif_provider'))->toBeNull(); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `php artisan test tests/Unit/Enums/GifProviderTest.php tests/Unit/Models/CommitTest.php tests/Unit/Models/UserTest.php` -Expected: FAIL — class `App\Enums\GifProvider` not found; factories lack `withGif`/`withGifKey`. - -- [ ] **Step 3: Create the enum** - -Create `app/Enums/GifProvider.php`: - -```php - 'GIPHY', - self::Klipy => 'KLIPY', - }; - } - - /** - * @return list - */ - public function mediaHostPatterns(): array - { - return match ($this) { - self::Giphy => ['/^media\d*\.giphy\.com$/'], - self::Klipy => ['/^static\d*\.klipy\.com$/'], - }; - } - - /** - * @return list - */ - public function pageHostPatterns(): array - { - return match ($this) { - self::Giphy => ['/^(www\.)?giphy\.com$/'], - self::Klipy => ['/^(www\.)?klipy\.com$/'], - }; - } -} -``` - -- [ ] **Step 4: Rewrite the migrations in place** - -```bash -git mv database/migrations/2026_10_17_024509_add_giphy_to_commits_table.php database/migrations/2026_10_17_024509_add_gif_to_commits_table.php -git mv database/migrations/2026_10_17_133426_add_giphy_key_to_users_table.php database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php -``` - -Replace the commits migration body with: - -```php - public function up(): void - { - Schema::table('commits', function (Blueprint $table): void { - $table->string('gif_provider', 16)->nullable(); - $table->string('gif_id', 64)->nullable(); - $table->text('gif_url')->nullable(); - $table->text('gif_page_url')->nullable(); - $table->string('gif_title', 300)->nullable(); - $table->string('gif_username', 100)->nullable(); - $table->unsignedSmallInteger('gif_width')->nullable(); - $table->unsignedSmallInteger('gif_height')->nullable(); - }); - } -``` - -Replace the users migration body with: - -```php - public function up(): void - { - Schema::table('users', function (Blueprint $table): void { - $table->string('gif_provider', 16)->nullable(); - $table->text('gif_key')->nullable(); - }); - } -``` - -Then reset the local dev database (the feature is unreleased): `php artisan migrate:fresh`. - -- [ ] **Step 5: Update the models and builders** - -In `app/Models/Commit.php`: - -1. Docblock: rename the seven `@property-read ... $giphy_*` entries to `$gif_*` and add `@property-read GifProvider|null $gif_provider` (import `App\Enums\GifProvider`). -2. `#[Hidden([...])]`: rename the seven `giphy_*` entries to `gif_*` and add `'gif_provider'`. -3. `casts()`: rename the seven entries to `gif_*` and add `'gif_provider' => GifProvider::class,`. -4. `gif()` becomes: - -```php - public function gif(): ?array - { - if ($this->gif_id === null || $this->gif_url === null || $this->gif_page_url === null || $this->gif_provider === null) { - return null; - } - - return [ - 'provider' => $this->gif_provider->value, - 'id' => $this->gif_id, - 'url' => $this->gif_url, - 'page_url' => $this->gif_page_url, - 'title' => $this->gif_title, - 'username' => $this->gif_username, - 'width' => $this->gif_width, - 'height' => $this->gif_height, - ]; - } -``` - -In `app/Models/User.php`: - -1. Docblock: replace `@property-read string|null $giphy_key` with `@property-read string|null $gif_key` and add `@property-read GifProvider|null $gif_provider` (import the enum). -2. `#[Hidden([...])]`: replace `'giphy_key'` with `'gif_key'` and add `'gif_provider'`. -3. `$attributes` defaults: replace `'giphy_key' => null,` with `'gif_key' => null,` and add `'gif_provider' => null,`. -4. `casts()`: replace `'giphy_key' => 'encrypted',` with `'gif_key' => 'encrypted',` and add `'gif_provider' => GifProvider::class,`. - -In `app/Queries/CommitBuilder.php`, replace the last column line of `COLUMNS_BUT_EMBEDDING`: - -```php - 'gif_provider', 'gif_id', 'gif_url', 'gif_page_url', 'gif_title', 'gif_username', 'gif_width', 'gif_height', -``` - -- [ ] **Step 6: Update the factories** - -In `database/factories/CommitFactory.php`, replace `withGiphy()` with: - -```php - public function withGif(GifProvider $provider = GifProvider::Giphy): self - { - $attributes = match ($provider) { - GifProvider::Giphy => [ - 'gif_id' => 'xT9IgzoKnwFNmISR8I', - 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', - 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'gif_title' => 'Deal With It GIF', - 'gif_username' => 'giphy', - 'gif_width' => 480, - 'gif_height' => 270, - ], - GifProvider::Klipy => [ - 'gif_id' => 'hello-hi-662', - 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', - 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', - 'gif_title' => 'Hello', - 'gif_username' => null, - 'gif_width' => 498, - 'gif_height' => 498, - ], - }; - - return $this->state(fn (array $attributes): array => [ - 'gif_provider' => $provider->value, - ...$attributes, - ]); - } -``` - -(Import `App\Enums\GifProvider` in both factories.) - -In `database/factories/UserFactory.php`, replace `withGiphyKey()` with: - -```php - public function withGifKey(GifProvider $provider = GifProvider::Giphy, string $key = 'gif-test-key'): self - { - return $this->state(fn (array $attributes): array => [ - 'gif_provider' => $provider->value, - 'gif_key' => $key, - ]); - } -``` - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `php artisan test tests/Unit/Enums/GifProviderTest.php tests/Unit/Models/CommitTest.php tests/Unit/Models/UserTest.php` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add app/Enums/GifProvider.php database/migrations database/factories app/Models/Commit.php app/Models/User.php app/Queries/CommitBuilder.php tests/Unit/Enums/GifProviderTest.php tests/Unit/Models/CommitTest.php tests/Unit/Models/UserTest.php -git commit -m "feat: generalize gif storage across providers" -``` - ---- - -### Task 2: Provider-aware validation, DTO, and commit pipeline - -**Files:** -- Rename+rewrite: `app/Dto/Giphy.php` → `app/Dto/Gif.php` -- Rename+rewrite: `app/Rules/GiphyMediaUrl.php` → `app/Rules/GifMediaUrl.php` -- Create: `app/Rules/GifPageUrl.php` -- Modify: `app/Http/Requests/CreateCommitRequest.php`, `app/Http/Requests/CreateCommitReplyRequest.php` -- Modify: `app/Actions/CreateCommit.php`, `app/Http/Controllers/CommitController.php`, `app/Http/Controllers/CommitReplyController.php` -- Test: `tests/Unit/Rules/GifMediaUrlTest.php` (create), `tests/Unit/Rules/GifPageUrlTest.php` (create), `tests/Feature/Controllers/CommitControllerTest.php`, `tests/Feature/Controllers/CommitReplyControllerTest.php` - -**Interfaces:** -- Consumes: `App\Enums\GifProvider`, `gif_*` columns, `CommitFactory::withGif()`. -- Produces: `App\Dto\Gif` constructor `(GifProvider $provider, string $id, string $url, string $pageUrl, ?string $title, ?string $username, int $width, int $height)`; `toColumns()` emits `gif_provider` + `gif_*`. -- Produces: `CreateCommitRequest::gif(): ?Gif` (and same on `CreateCommitReplyRequest`); `CreateCommit::handle(..., ?Gif $gif = null)`. - -- [ ] **Step 1: Write the failing rule tests** - -Create `tests/Unit/Rules/GifMediaUrlTest.php`: - -```php - $url], ['url' => [new GifMediaUrl($provider)]]); - - expect($validator->passes())->toBeTrue(); -})->with([ - 'giphy' => [GifProvider::Giphy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'], - 'klipy' => [GifProvider::Klipy, 'https://static2.klipy.com/ii/935d7ab9/14/af/8GCrVAB7.gif'], -]); - -it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl($provider)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'giphy url for klipy' => [GifProvider::Klipy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], - 'klipy url for giphy' => [GifProvider::Giphy, 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif'], -]); - -it('rejects unsafe or malformed media urls', function (mixed $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl(GifProvider::Klipy)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'not https' => ['http://static.klipy.com/ii/x.gif'], - 'not a gif' => ['https://static.klipy.com/ii/x.webp'], - 'with port' => ['https://static.klipy.com:8443/ii/x.gif'], - 'with userinfo' => ['https://user:pass@static.klipy.com/ii/x.gif'], - 'dot segment' => ['https://static.klipy.com/ii/../x.gif'], - 'array value' => [['nope']], -]); -``` - -Create `tests/Unit/Rules/GifPageUrlTest.php`: - -```php - $url], ['url' => [new GifPageUrl($provider)]]); - - expect($validator->passes())->toBeTrue(); -})->with([ - 'giphy' => [GifProvider::Giphy, 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I'], - 'klipy' => [GifProvider::Klipy, 'https://klipy.com/gifs/hello-hi-662'], -]); - -it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifPageUrl($provider)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], - 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], -]); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `php artisan test tests/Unit/Rules/GifMediaUrlTest.php tests/Unit/Rules/GifPageUrlTest.php` -Expected: FAIL — classes do not exist. - -- [ ] **Step 3: Implement the rules and DTO** - -```bash -git mv app/Rules/GiphyMediaUrl.php app/Rules/GifMediaUrl.php -git mv app/Dto/Giphy.php app/Dto/Gif.php -``` - -Replace `app/Rules/GifMediaUrl.php` with: - -```php -provider->mediaHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ) !== [] - && ! isset($parts['fragment']) - && ! isset($parts['port']) - && ! isset($parts['user']) - && ! isset($parts['pass']) - && preg_match('#^/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 - && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1; - - if (! $isValid) { - $fail(__('The GIF could not be attached.')); - } - } -} -``` - -Create `app/Rules/GifPageUrl.php`: - -```php -provider->pageHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ) !== [] - && ! isset($parts['port']) - && ! isset($parts['user']) - && ! isset($parts['pass']); - - if (! $isValid) { - $fail(__('The GIF could not be attached.')); - } - } -} -``` - -Replace `app/Dto/Gif.php` with: - -```php - - */ - public function toColumns(): array - { - return [ - 'gif_provider' => $this->provider->value, - 'gif_id' => $this->id, - 'gif_url' => $this->url, - 'gif_page_url' => $this->pageUrl, - 'gif_title' => $this->title, - 'gif_username' => $this->username, - 'gif_width' => $this->width, - 'gif_height' => $this->height, - ]; - } -} -``` - -- [ ] **Step 4: Update the form requests** - -In both `CreateCommitRequest` and `CreateCommitReplyRequest`: - -1. Replace the `use App\Dto\Giphy;` / `use App\Rules\GiphyMediaUrl;` imports with `use App\Dto\Gif;` / `use App\Enums\GifProvider;` / `use App\Rules\GifMediaUrl;` / `use App\Rules\GifPageUrl;` (and make sure `use Illuminate\Validation\Rule;` is imported). -2. In the request's `rules()` (or `attachmentRules()` if one exists), leave the existing image/poll rules untouched, define the provider once immediately before the returned array, and replace only the seven `giphy_*` lines with the `gif_*` lines below: - -```php - $provider = GifProvider::tryFrom((string) $this->input('gif_provider')) ?? GifProvider::Giphy; - - // ...the request's existing return [ ... ] keeps its non-GIF rules; replace the gif lines with: - - 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], - 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9]+$/', 'prohibits:images,poll', 'required_with:gif_url'], - 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], - 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifPageUrl($provider)], - 'gif_title' => ['nullable', 'string', 'max:300'], - 'gif_username' => ['nullable', 'string', 'max:100'], - 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], - 'gif_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], -``` - -3. Replace the `giphy()` accessor with: - -```php - public function gif(): ?Gif - { - if (! $this->filled('gif_id')) { - return null; - } - - return new Gif( - GifProvider::from((string) $this->input('gif_provider')), - $this->string('gif_id')->value(), - $this->string('gif_url')->value(), - $this->string('gif_page_url')->value(), - $this->filled('gif_title') ? $this->string('gif_title')->value() : null, - $this->filled('gif_username') ? $this->string('gif_username')->value() : null, - $this->integer('gif_width'), - $this->integer('gif_height'), - ); - } -``` - -- [ ] **Step 5: Update the action and controllers** - -`app/Actions/CreateCommit.php`: `use App\Dto\Gif;`; signature `?Gif $gif = null`; spread stays `...$gif?->toColumns() ?? []`. - -`app/Http/Controllers/CommitController.php` and `CommitReplyController.php`: pass `gif: $request->gif()`. - -- [ ] **Step 6: Update the feature tests** - -In `tests/Feature/Controllers/CommitControllerTest.php` and `CommitReplyControllerTest.php`: rename every `giphy_*` payload key to `gif_*`, add `'gif_provider' => 'giphy'` to each valid GIF payload, and rename assertions `giphy_id` → `gif_id`. Replace the GIPHY-only fixture URL `https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif` usage consistently. Add to `CommitControllerTest`: - -```php -it('accepts a klipy gif', function (): void { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post(route('commit.store'), [ - 'body' => 'Klipy time', - 'gif_provider' => 'klipy', - 'gif_id' => 'hello-hi-662', - 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif', - 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', - 'gif_width' => 498, - 'gif_height' => 498, - ]); - - $response->assertSessionHasNoErrors(); - - expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); -}); - -it('rejects a gif url from another provider', function (): void { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson(route('commit.store'), [ - 'body' => 'Mismatch', - 'gif_provider' => 'klipy', - 'gif_id' => 'abc123', - 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', - 'gif_page_url' => 'https://klipy.com/gifs/abc123', - 'gif_width' => 480, - 'gif_height' => 270, - ]); - - $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); -}); - -it('rejects a gif without a provider', function (): void { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->postJson(route('commit.store'), [ - 'body' => 'No provider', - 'gif_id' => 'abc123', - 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', - 'gif_page_url' => 'https://giphy.com/gifs/abc123', - 'gif_width' => 480, - 'gif_height' => 270, - ]); - - $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); -}); -``` - -Mirror the klipy acceptance + mismatch cases in `CommitReplyControllerTest` (the `gif_provider` field is part of its attachment set too). - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `php artisan test tests/Unit/Rules tests/Feature/Controllers/CommitControllerTest.php tests/Feature/Controllers/CommitReplyControllerTest.php` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add app/Dto/Gif.php app/Rules/GifMediaUrl.php app/Rules/GifPageUrl.php app/Http/Requests/CreateCommitRequest.php app/Http/Requests/CreateCommitReplyRequest.php app/Actions/CreateCommit.php app/Http/Controllers/CommitController.php app/Http/Controllers/CommitReplyController.php tests/Unit/Rules tests/Feature/Controllers/CommitControllerTest.php tests/Feature/Controllers/CommitReplyControllerTest.php -git commit -m "feat: validate gifs against the selected provider" -``` - ---- - -### Task 3: Provider-aware read surfaces (API, feed, OG, moderation) - -**Files:** -- Modify: `app/Services/PageMetadata.php`, `app/Actions/CreateUserFeed.php`, `app/Actions/UpdateCommitAiDecision.php` -- Test: `tests/Feature/Controllers/Api/CommitControllerTest.php`, `tests/Feature/Controllers/Api/BookmarkControllerTest.php`, `tests/Feature/Controllers/Api/LogControllerTest.php`, `tests/Feature/Controllers/Api/NotificationControllerTest.php`, `tests/Feature/Controllers/UserFeedControllerTest.php`, `tests/Unit/Jobs/ModerateCommitTest.php`, `tests/Feature/Controllers/CommitOpenGraphImageControllerTest.php` - -**Interfaces:** -- Consumes: `Commit::gif()` including `provider`, `gif_*` attributes. -- Produces: API `gif` object with `provider`; feed/OG/moderation read the renamed columns. No shape changes beyond `provider`. - -- [ ] **Step 1: Rename the column references** - -Apply these exact replacements: - -- `app/Services/PageMetadata.php`: `$commit->giphy_url` → `$commit->gif_url`; `$commit->giphy_width` → `$commit->gif_width`; `$commit->giphy_height` → `$commit->gif_height`; `$commit->giphy_title` → `$commit->gif_title`. -- `app/Actions/CreateUserFeed.php`: `$commit->giphy_url` → `$commit->gif_url`. -- `app/Actions/UpdateCommitAiDecision.php`: `$commit->giphy_url` → `$commit->gif_url`. - -- [ ] **Step 2: Update the tests to the new shape** - -- API tests (`CommitControllerTest`, `BookmarkControllerTest`, `LogControllerTest`, `NotificationControllerTest`): every expected `gif` array or JSON object gains `'provider' => 'giphy'` (or `"provider": "giphy"` inside JSON strings) as the first key, and factory calls `withGiphy()` become `withGif()`. -- `UserFeedControllerTest`, `ModerateCommitTest`, `CommitOpenGraphImageControllerTest`: update `withGiphy()` → `withGif()`; replace any `giphy_*` column writes with `gif_*` and add `gif_provider` where rows are created by hand. The giphy URL assertions keep the URL values (they are unchanged fixtures). -- Add one KLIPY moderation assertion in `ModerateCommitTest`: - -```php -it('moderates an attached klipy gif by its url', function (): void { - Http::fake([ - 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), - 'api.openai.com/v1/responses' => reviewResponses(), - ]); - - $commit = Commit::factory()->pending()->withGif(GifProvider::Klipy)->create(); - - dispatch_sync(new ModerateCommit($commit)); - - Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') - && str_contains($request->body(), 'static.klipy.com')); -}); -``` - -- [ ] **Step 3: Run the tests** - -Run: `php artisan test tests/Feature/Controllers/Api tests/Feature/Controllers/UserFeedControllerTest.php tests/Unit/Jobs/ModerateCommitTest.php tests/Feature/Controllers/CommitOpenGraphImageControllerTest.php` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add app/Services/PageMetadata.php app/Actions/CreateUserFeed.php app/Actions/UpdateCommitAiDecision.php tests/Feature/Controllers/Api tests/Feature/Controllers/UserFeedControllerTest.php tests/Unit/Jobs/ModerateCommitTest.php tests/Feature/Controllers/CommitOpenGraphImageControllerTest.php -git commit -m "feat: expose the gif provider on every read surface" -``` - ---- - -### Task 4: One-active-provider BYOK on the server - -**Files:** -- Rename+rewrite: `app/Http/Controllers/GiphyKeyController.php` → `GifKeyController.php` -- Rename+rewrite: `app/Http/Requests/UpdateGiphyKeyRequest.php` → `UpdateGifKeyRequest.php` -- Modify: `app/Http/Middleware/HandleInertiaRequests.php`, `app/Http/Controllers/UserProfileController.php`, `routes/web.php` -- Test: `tests/Feature/Controllers/GifKeyControllerTest.php` (renamed), `tests/Feature/Middleware/GifPropTest.php` (renamed), `tests/Feature/Controllers/UserProfileControllerTest.php`, `tests/Feature/Controllers/ExportControllerTest.php` - -**Interfaces:** -- Consumes: `App\Enums\GifProvider`, `users.gif_provider`/`gif_key`. -- Produces: `PUT settings/gif` (`gif-key.update`) with `{provider, key}`; `DELETE settings/gif` (`gif-key.destroy`); Inertia prop `gif: { provider: 'giphy'|'klipy', key: string } | null`; profile-page props `gifConnected: bool`, `gifProvider: 'giphy'|'klipy'|null`. - -- [ ] **Step 1: Rename the tests and write the failing expectations** - -```bash -git mv tests/Feature/Controllers/GiphyKeyControllerTest.php tests/Feature/Controllers/GifKeyControllerTest.php -git mv tests/Feature/Middleware/HandleInertiaRequestsGiphyTest.php tests/Feature/Middleware/GifPropTest.php -``` - -In `GifKeyControllerTest.php`: rename route calls `giphy-key.update` → `gif-key.update`, `giphy-key.destroy` → `gif-key.destroy`; `withGiphyKey()` → `withGifKey()`; payloads `['key' => ...]` become `['provider' => 'giphy', 'key' => ...]`; `giphy_key` assertions become `gif_key`; expected toasts become `'GIPHY key connected.'` / `'GIF provider removed.'`. Replace the dataset cases with: - -```php -it('replaces one provider with another atomically', function (): void { - $user = User::factory()->withGifKey(GifProvider::Giphy, 'giphy-key')->create(); - - $this->actingAs($user)->fromRoute('user-profile.edit') - ->put(route('gif-key.update'), ['provider' => 'klipy', 'key' => 'klipy-key']) - ->assertRedirectToRoute('user-profile.edit') - ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'KLIPY key connected.']); - - expect($user->refresh()) - ->gif_provider->toBe(GifProvider::Klipy) - ->gif_key->toBe('klipy-key'); -}); - -it('rejects invalid providers without touching the stored one', function (array $payload): void { - $user = User::factory()->withGifKey()->create(); - $field = in_array($payload['provider'] ?? null, ['giphy', 'klipy'], true) ? 'key' : 'provider'; - - $this->actingAs($user)->putJson(route('gif-key.update'), $payload) - ->assertUnprocessable()->assertJsonValidationErrors($field); - - expect($user->refresh()->gif_provider)->toBe(GifProvider::Giphy); -})->with([ - 'missing provider' => [fn (): array => ['key' => 'x']], - 'unknown provider' => [fn (): array => ['provider' => 'tenor', 'key' => 'x']], - 'missing key' => [fn (): array => ['provider' => 'giphy']], - 'blank key' => [fn (): array => ['provider' => 'giphy', 'key' => ' ']], - 'non-string key' => [fn (): array => ['provider' => 'giphy', 'key' => ['x']]], - 'too long' => [fn (): array => ['provider' => 'giphy', 'key' => str_repeat('a', 256)]], -]); -``` - -The encryption-at-rest test keeps its shape but targets `gif_key` and sends the provider. The remove test asserts both `gif_key` and `gif_provider` become null and the toast is `'GIF provider removed.'`. - -In `GifPropTest.php` (import `App\Enums\GifProvider`): the prop is now `gif` with the shape `{provider, key}`: - -```php -it('shares the connected provider and key with its owner', function (): void { - $user = User::factory()->withGifKey(GifProvider::Klipy, 'klipy-key')->create(); - - $this->actingAs($user) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page - ->where('gif.provider', 'klipy') - ->where('gif.key', 'klipy-key')); -}); - -it('shares no gif config without a connected provider', function (): void { - $this->actingAs(User::factory()->create()) - ->get(route('log.show', Log::Following)) - ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); -}); - -it('shares no gif config for guests', function (): void { - $this->get(route('log.show', Log::Trending)) - ->assertInertia(fn (Assert $page): Assert => $page->where('gif', null)); -}); -``` - -In `UserProfileControllerTest.php`, rename the giphy test to assert `gifConnected` and `gifProvider`: - -```php -it('renders the connected gif provider', function (?string $provider): void { - $user = $provider === null - ? User::factory()->create() - : User::factory()->withGifKey(GifProvider::from($provider))->create(); - - $this->actingAs($user) - ->get(route('user-profile.edit')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('gifConnected', $provider !== null) - ->where('gifProvider', $provider) - ->missing('auth.user.gif_key') - ->missing('auth.user.gif_provider')); -})->with([null, 'giphy', 'klipy']); -``` - -In `ExportControllerTest.php`, rename the test to `never exports the gif key` and use `withGifKey()`, asserting `profile` has no `gif_key` and the body does not contain the key. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `php artisan test tests/Feature/Controllers/GifKeyControllerTest.php tests/Feature/Middleware/GifPropTest.php tests/Feature/Controllers/UserProfileControllerTest.php tests/Feature/Controllers/ExportControllerTest.php` -Expected: FAIL — routes `gif-key.*` not defined, props `gif`/`gifConnected` missing. - -- [ ] **Step 3: Implement the controller, request, routes, middleware, and page props** - -```bash -git mv app/Http/Controllers/GiphyKeyController.php app/Http/Controllers/GifKeyController.php -git mv app/Http/Requests/UpdateGiphyKeyRequest.php app/Http/Requests/UpdateGifKeyRequest.php -``` - -`UpdateGifKeyRequest`: rules become: - -```php - public function rules(): array - { - return [ - 'provider' => ['required', 'string', Rule::enum(GifProvider::class)], - 'key' => ['required', 'string', 'max:255'], - ]; - } - - public function provider(): GifProvider - { - return GifProvider::from((string) $this->input('provider')); - } -``` - -(`prepareForValidation` keeps the `mb_trim` on `key`; import `App\Enums\GifProvider` and `Illuminate\Validation\Rule`.) - -`GifKeyController`: - -```php -final readonly class GifKeyController -{ - public function update(UpdateGifKeyRequest $request, #[CurrentUser] User $user): RedirectResponse - { - $provider = $request->provider(); - - $user->update([ - 'gif_provider' => $provider, - 'gif_key' => $request->string('key')->toString(), - ]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => $provider->label().' key connected.', - ]); - - return back(); - } - - public function destroy(#[CurrentUser] User $user): RedirectResponse - { - $user->update(['gif_provider' => null, 'gif_key' => null]); - - Inertia::flash('toast', [ - 'type' => 'success', - 'message' => 'GIF provider removed.', - ]); - - return back(); - } -} -``` - -`routes/web.php`: replace the giphy block with: - -```php - // GIF provider... - Route::put('settings/gif', [GifKeyController::class, 'update'])->name('gif-key.update'); - Route::delete('settings/gif', [GifKeyController::class, 'destroy'])->name('gif-key.destroy'); -``` - -(Update the controller import; regenerate TypeScript routes: `php artisan wayfinder:generate --with-form`.) - -`HandleInertiaRequests`: replace the `giphy` prop with: - -```php - 'gif' => fn (): ?array => (function () use ($request): ?array { - $user = $request->user(); - - if ($user?->gif_provider === null || $user->gif_key === null || $user->gif_key === '') { - return null; - } - - return ['provider' => $user->gif_provider->value, 'key' => $user->gif_key]; - })(), -``` - -`UserProfileController::edit` gains: - -```php - 'gifConnected' => $user->gif_key !== null, - 'gifProvider' => $user->gif_provider?->value, -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `php artisan test tests/Feature/Controllers/GifKeyControllerTest.php tests/Feature/Middleware/GifPropTest.php tests/Feature/Controllers/UserProfileControllerTest.php tests/Feature/Controllers/ExportControllerTest.php` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add app/Http/Controllers/GifKeyController.php app/Http/Requests/UpdateGifKeyRequest.php app/Http/Middleware/HandleInertiaRequests.php app/Http/Controllers/UserProfileController.php routes/web.php tests/Feature/Controllers/GifKeyControllerTest.php tests/Feature/Middleware/GifPropTest.php tests/Feature/Controllers/UserProfileControllerTest.php tests/Feature/Controllers/ExportControllerTest.php -git commit -m "feat: store one active gif provider per user" -``` - ---- - -### Task 5: Client provider interface, adapters, and storage - -**Files:** -- Create: `resources/js/lib/gif-providers/types.ts`, `giphy.ts`, `klipy.ts`, `index.ts` -- Delete: `resources/js/lib/giphy.ts` -- Modify: `resources/js/lib/gif-storage.ts`, `resources/js/types/global.d.ts`, `resources/js/types/commit.ts` -- Test: `tests/Unit/GifProviders.test.ts` (create), `tests/Unit/GifStorage.test.ts` (update); delete `tests/Unit/Giphy.test.ts` - -**Interfaces:** -- Produces: `GifProviderId`, `GifResult`, `GifAttachment = GifResult & { provider }`, `GifSearchPage {results, nextCursor}`, `GifKeyValidation`, `GifProvider`, `GifRateLimited` (all in `types.ts`). -- Produces: `resources/js/lib/gif-providers/index.ts` — `gifProviders: Record`, `attachmentFromResult(id, result): GifAttachment`, `gifFormFields(gif): Record` (emits `gif_provider` + `gif_*`). -- Produces: page prop type `gif: { provider: GifProviderId; key: string } | null`; `CommitGif` type gains `provider: GifProviderId`. - -- [ ] **Step 1: Write the failing contract tests** - -Create `tests/Unit/GifProviders.test.ts`: - -```ts -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - attachmentFromResult, - gifFormFields, -} from '../../resources/js/lib/gif-providers/index.ts'; -import { giphy } from '../../resources/js/lib/gif-providers/giphy.ts'; -import { klipy } from '../../resources/js/lib/gif-providers/klipy.ts'; -import { GifRateLimited } from '../../resources/js/lib/gif-providers/types.ts'; - -const originalFetch = globalThis.fetch; - -function stubFetch(payload: unknown, status = 200): string[] { - const calls: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL) => { - calls.push(String(input)); - - return new Response(JSON.stringify(payload), { status }); - }) as typeof fetch; - - return calls; -} - -function restoreFetch(): void { - globalThis.fetch = originalFetch; -} - -const giphyPayload = { - data: [{ - id: 'xT9IgzoKnwFNmISR8I', - title: 'Deal With It GIF', - username: 'giphy', - url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - images: { - fixed_height_small: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', width: '178', height: '100' }, - downsized_medium: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', width: '480', height: '270' }, - }, - }], -}; - -const klipyPayload = { - results: [{ - id: 'hello-hi-662', - title: 'Hello', - content_description: 'hello greeting', - itemurl: 'https://klipy.com/gifs/hello-hi-662', - media_formats: { - gif: { url: 'https://static.klipy.com/ii/8GCrVAB7.gif', dims: [498, 498] }, - tinygif: { url: 'https://static.klipy.com/ii/y6iepZM7.gif', dims: [220, 220] }, - }, - }], - next: 'cursor-2', -}; - -test('giphy builds search and trending urls with the g rating', async () => { - const searchCalls = stubFetch(giphyPayload); - try { - const page = await giphy.search('key-123', 'deal with it', { cursor: null }); - const url = new URL(searchCalls[0]!); - assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); - assert.equal(url.searchParams.get('api_key'), 'key-123'); - assert.equal(url.searchParams.get('q'), 'deal with it'); - assert.equal(url.searchParams.get('rating'), 'g'); - assert.equal(url.searchParams.get('offset'), '0'); - assert.equal(page.results[0]?.url, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc'); - assert.equal(page.results[0]?.previewUrl, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc'); - assert.equal(page.nextCursor, null); - } finally { - restoreFetch(); - } - - const trendingCalls = stubFetch(giphyPayload); - try { - await giphy.trending('key-123', { cursor: '24' }); - assert.ok(trendingCalls[0]!.includes('/v1/gifs/trending?')); - assert.ok(trendingCalls[0]!.includes('offset=24')); - } finally { - restoreFetch(); - } -}); - -test('klipy builds tenor-compatible urls and maps the next cursor', async () => { - const calls = stubFetch(klipyPayload); - try { - const page = await klipy.search('k-key', 'hello', { cursor: null }); - const url = new URL(calls[0]!); - assert.equal(url.origin + url.pathname, 'https://api.klipy.com/v2/search'); - assert.equal(url.searchParams.get('key'), 'k-key'); - assert.equal(url.searchParams.get('q'), 'hello'); - assert.equal(url.searchParams.get('contentfilter'), 'high'); - assert.equal(page.results[0]?.pageUrl, 'https://klipy.com/gifs/hello-hi-662'); - assert.equal(page.results[0]?.username, null); - assert.equal(page.results[0]?.previewUrl, 'https://static.klipy.com/ii/y6iepZM7.gif'); - assert.equal(page.nextCursor, 'cursor-2'); - } finally { - restoreFetch(); - } - - const trendingCalls = stubFetch({ results: [], next: '' }); - try { - const page = await klipy.trending('k-key', { cursor: null }); - assert.ok(trendingCalls[0]!.includes('/v2/featured?')); - assert.equal(page.nextCursor, null); - } finally { - restoreFetch(); - } -}); - -test('adapters map throttling and key verdicts to the shared shapes', async () => { - stubFetch({}, 429); - try { - await assert.rejects(() => giphy.trending('k', { cursor: null }), GifRateLimited); - assert.equal(await giphy.validateKey('k'), 'rate-limited'); - assert.equal(await klipy.validateKey('k'), 'rate-limited'); - } finally { - restoreFetch(); - } - - for (const status of [401, 403]) { - stubFetch({}, status); - try { - assert.equal(await giphy.validateKey('k'), 'invalid'); - assert.equal(await klipy.validateKey('k'), 'invalid'); - } finally { - restoreFetch(); - } - } - - stubFetch({}, 200); - try { - assert.equal(await giphy.validateKey('k'), 'valid'); - assert.equal(await klipy.validateKey('k'), 'valid'); - } finally { - restoreFetch(); - } - - globalThis.fetch = (async () => { - throw new TypeError('network'); - }) as typeof fetch; - try { - assert.equal(await giphy.validateKey('k'), 'unreachable'); - assert.equal(await klipy.validateKey('k'), 'unreachable'); - } finally { - restoreFetch(); - } -}); - -test('attachments carry the provider and shape the hidden form fields', () => { - const gif = attachmentFromResult('klipy', { - id: 'hello-hi-662', - url: 'https://static.klipy.com/ii/8GCrVAB7.gif', - pageUrl: 'https://klipy.com/gifs/hello-hi-662', - title: 'Hello', - username: null, - width: 498, - height: 498, - previewUrl: 'https://static.klipy.com/ii/y6iepZM7.gif', - }); - - assert.deepEqual(gifFormFields(gif), { - gif_provider: 'klipy', - gif_id: 'hello-hi-662', - gif_url: 'https://static.klipy.com/ii/8GCrVAB7.gif', - gif_page_url: 'https://klipy.com/gifs/hello-hi-662', - gif_title: 'Hello', - gif_username: '', - gif_width: 498, - gif_height: 498, - }); -}); -``` - -Update `tests/Unit/GifStorage.test.ts`: replace the `GiphyAttachment` import with `GifAttachment` from `types.ts`; fixture gains `provider: 'giphy'` and `previewUrl`; add a rejection case for an unknown provider (`provider: 'nope'` must read as null). Delete `tests/Unit/Giphy.test.ts` (`git rm`). - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `bun run test:unit` -Expected: FAIL — `lib/gif-providers/*` does not exist. - -- [ ] **Step 3: Implement the provider modules** - -Create `resources/js/lib/gif-providers/types.ts`: - -```ts -export type GifProviderId = 'giphy' | 'klipy'; - -export type GifResult = { - id: string; - url: string; - pageUrl: string; - title: string | null; - username: string | null; - width: number; - height: number; - previewUrl: string | null; -}; - -export type GifAttachment = GifResult & { provider: GifProviderId }; - -export type GifSearchPage = { - results: GifResult[]; - nextCursor: string | null; -}; - -export type GifKeyValidation = - | 'valid' - | 'invalid' - | 'rate-limited' - | 'unreachable'; - -export class GifRateLimited extends Error { - constructor() { - super('The GIF provider is rate limiting searches.'); - } -} - -export interface GifProvider { - readonly id: GifProviderId; - readonly label: string; - readonly searchPlaceholder: string; - readonly attribution: { readonly label: string; readonly href: string }; - search( - key: string, - query: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - trending( - key: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - validateKey(key: string): Promise; -} -``` - -Create `resources/js/lib/gif-providers/giphy.ts`: - -```ts -import type { - GifKeyValidation, - GifProvider, - GifResult, - GifSearchPage, -} from './types.ts'; -import { GifRateLimited } from './types.ts'; - -type GiphyRendition = { url: string; width: string; height: string }; - -type GiphyItem = { - id: string; - title: string; - username: string; - url: string; - images: Partial< - Record< - 'downsized_medium' | 'downsized' | 'original' | 'fixed_height_small', - GiphyRendition - > - >; -}; - -const pageSize = 24; -const baseUrl = 'https://api.giphy.com/v1/gifs'; - -const params = (key: string, offset: string | null): URLSearchParams => - new URLSearchParams({ - api_key: key, - limit: String(pageSize), - offset: offset ?? '0', - rating: 'g', - }); - -const normalize = (item: GiphyItem): GifResult | null => { - const stored = - item.images.downsized_medium ?? - item.images.downsized ?? - item.images.original; - - if (stored === undefined) { - return null; - } - - const preview = - item.images.fixed_height_small ?? - item.images.downsized ?? - item.images.downsized_medium ?? - item.images.original; - - return { - id: item.id, - url: stored.url, - pageUrl: item.url, - title: item.title === '' ? null : item.title, - username: item.username === '' ? null : item.username, - width: Number.parseInt(stored.width, 10), - height: Number.parseInt(stored.height, 10), - previewUrl: preview?.url ?? null, - }; -}; - -async function page( - url: string, - options: { cursor: string | null; signal?: AbortSignal }, -): Promise { - const response = await fetch(url, { signal: options.signal }); - - if (response.status === 429) { - throw new GifRateLimited(); - } - - if (!response.ok) { - throw new Error(`GIPHY request failed: ${response.status}`); - } - - const data = (await response.json()) as { data: GiphyItem[] }; - const results = data.data - .map(normalize) - .filter((result): result is GifResult => result !== null); - - return { - results, - nextCursor: - data.data.length < pageSize - ? null - : String(Number.parseInt(options.cursor ?? '0', 10) + pageSize), - }; -} - -async function validateKey(key: string): Promise { - const search = new URLSearchParams({ - api_key: key, - limit: '1', - offset: '0', - rating: 'g', - }); - - let response: Response; - - try { - response = await fetch(`${baseUrl}/trending?${search}`); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} - -export const giphy: GifProvider = { - id: 'giphy', - label: 'GIPHY', - searchPlaceholder: 'Search GIPHY', - attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }, - search: (key, query, options) => { - const search = params(key, options.cursor); - search.set('q', query); - - return page(`${baseUrl}/search?${search}`, options); - }, - trending: (key, options) => - page(`${baseUrl}/trending?${params(key, options.cursor)}`, options), - validateKey, -}; -``` - -Create `resources/js/lib/gif-providers/klipy.ts`: - -```ts -import type { - GifKeyValidation, - GifProvider, - GifResult, - GifSearchPage, -} from './types.ts'; -import { GifRateLimited } from './types.ts'; - -type KlipyMedia = { url: string; dims: [number, number] }; - -type KlipyItem = { - id: string; - title?: string; - content_description?: string; - itemurl: string; - media_formats?: { gif?: KlipyMedia; tinygif?: KlipyMedia }; -}; - -type KlipyResponse = { results?: KlipyItem[]; next?: string }; - -const pageSize = 24; -const baseUrl = 'https://api.klipy.com/v2'; - -const params = (key: string, cursor: string | null): URLSearchParams => { - const search = new URLSearchParams({ - key, - limit: String(pageSize), - contentfilter: 'high', - }); - - if (cursor !== null) { - search.set('pos', cursor); - } - - return search; -}; - -const normalize = (item: KlipyItem): GifResult | null => { - const gif = item.media_formats?.gif; - - if (gif === undefined) { - return null; - } - - return { - id: item.id, - url: gif.url, - pageUrl: item.itemurl, - title: item.title || item.content_description || null, - username: null, - width: gif.dims[0], - height: gif.dims[1], - previewUrl: item.media_formats?.tinygif?.url ?? gif.url, - }; -}; - -async function page( - url: string, - options: { cursor: string | null; signal?: AbortSignal }, -): Promise { - const response = await fetch(url, { signal: options.signal }); - - if (response.status === 429) { - throw new GifRateLimited(); - } - - if (!response.ok) { - throw new Error(`KLIPY request failed: ${response.status}`); - } - - const data = (await response.json()) as KlipyResponse; - const results = (data.results ?? []) - .map(normalize) - .filter((result): result is GifResult => result !== null); - - return { results, nextCursor: data.next === '' ? null : (data.next ?? null) }; -} - -async function validateKey(key: string): Promise { - const search = new URLSearchParams({ key, limit: '8', contentfilter: 'high' }); - - let response: Response; - - try { - response = await fetch(`${baseUrl}/featured?${search}`); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} - -export const klipy: GifProvider = { - id: 'klipy', - label: 'KLIPY', - searchPlaceholder: 'Search KLIPY', - attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, - search: (key, query, options) => { - const search = params(key, options.cursor); - search.set('q', query); - - return page(`${baseUrl}/search?${search}`, options); - }, - trending: (key, options) => - page(`${baseUrl}/featured?${params(key, options.cursor)}`, options), - validateKey, -}; -``` - -Create `resources/js/lib/gif-providers/index.ts`: - -```ts -import { giphy } from './giphy.ts'; -import { klipy } from './klipy.ts'; -import type { - GifAttachment, - GifProvider, - GifProviderId, - GifResult, -} from './types.ts'; - -export * from './types.ts'; - -export const gifProviders: Record = { - giphy, - klipy, -}; - -export function gifProviderFor(id: GifProviderId): GifProvider { - return gifProviders[id]; -} - -export function attachmentFromResult( - id: GifProviderId, - result: GifResult, -): GifAttachment { - return { ...result, provider: id }; -} - -export function gifFormFields( - gif: GifAttachment, -): Record { - return { - gif_provider: gif.provider, - gif_id: gif.id, - gif_url: gif.url, - gif_page_url: gif.pageUrl, - gif_title: gif.title ?? '', - gif_username: gif.username ?? '', - gif_width: gif.width, - gif_height: gif.height, - }; -} -``` - -`git rm resources/js/lib/giphy.ts`. - -- [ ] **Step 4: Update storage and shared types** - -`resources/js/lib/gif-storage.ts`: import `GifAttachment` from `./gif-providers/types.ts`; validation adds `(parsed.provider === 'giphy' || parsed.provider === 'klipy')`. - -`resources/js/types/global.d.ts`: replace `giphy: { key: string } | null;` with: - -```ts - gif: { provider: 'giphy' | 'klipy'; key: string } | null; -``` - -`resources/js/types/commit.ts`: `CommitGif` gains `provider: 'giphy' | 'klipy';` as its first field. - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `bun run test:unit && bun run test:types` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add resources/js/lib/gif-providers resources/js/lib/gif-storage.ts resources/js/types/global.d.ts resources/js/types/commit.ts tests/Unit/GifProviders.test.ts tests/Unit/GifStorage.test.ts -git rm resources/js/lib/giphy.ts tests/Unit/Giphy.test.ts -git commit -m "feat: add the gif provider adapters and registry" -``` - ---- - -### Task 6: Provider-aware picker, composer wiring, and rendering - -**Files:** -- Rename+rewrite: `resources/js/components/giphy-picker-dialog.tsx` → `gif-picker-dialog.tsx` -- Rename+rewrite: `resources/js/components/giphy-picker-button.tsx` → `gif-picker-button.tsx` -- Rename+rewrite: `resources/js/components/giphy-attachment-preview.tsx` → `gif-attachment-preview.tsx` -- Rename+rewrite: `resources/js/components/commit-giphy.tsx` → `commit-gif.tsx` -- Modify: `resources/js/components/commit-composer.tsx`, `commit-quote-button.tsx`, `commit-thread.tsx`, `resources/js/hooks/use-stash.ts` -- Test: rename `tests/Unit/CommitGiphy.test.tsx` → `CommitGif.test.tsx`, `tests/Unit/CommitGiphyRender.test.ts` → `CommitGifRender.test.ts`, `tests/commit-giphy.config.ts` → `tests/commit-gif.config.ts`; create `tests/Unit/GifPickerDialog.test.tsx`, `tests/gif-picker.config.ts`, `tests/Unit/GifPickerDialogRender.test.ts`; update `tests/Unit/ComposerDraft.test.tsx`, `tests/Unit/QuoteDraft.test.tsx`, `tests/Unit/DraftStash.test.tsx`, `tests/Unit/GifStorage.test.ts`, `tests/tsconfig.json` - -**Interfaces:** -- Consumes: `gif` page prop `{provider, key}|null`, `lib/gif-providers/*`, `gifFormFields`, `GifAttachment`. -- Produces: `GifPickerButton`, `GifPickerDialog({ provider, apiKey, open, onOpenChange, onPick })`, `GifAttachmentPreview`, `CommitGif({ gif })`. - -- [ ] **Step 1: Rename the component files and their imports** - -```bash -git mv resources/js/components/giphy-picker-dialog.tsx resources/js/components/gif-picker-dialog.tsx -git mv resources/js/components/giphy-picker-button.tsx resources/js/components/gif-picker-button.tsx -git mv resources/js/components/giphy-attachment-preview.tsx resources/js/components/gif-attachment-preview.tsx -git mv resources/js/components/commit-giphy.tsx resources/js/components/commit-gif.tsx -``` - -Update every import in `commit-composer.tsx`, `commit-quote-button.tsx`, `commit-thread.tsx`, `gif-picker-button.tsx` accordingly. - -- [ ] **Step 2: Write the failing picker component test** - -Create `tests/gif-picker.config.ts`: - -```ts -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('../resources/js', import.meta.url)), - }, - }, - test: { - include: ['tests/Unit/GifPickerDialog.test.tsx'], - }, -}); -``` - -Create `tests/Unit/GifPickerDialog.test.tsx`: - -```tsx -/// -import assert from 'node:assert/strict'; -import * as React from 'react'; -import type { ReactElement, ReactNode } from 'react'; -import { beforeEach, test, vi } from 'vite-plus/test'; -import type { GifProvider } from '../../resources/js/lib/gif-providers/types'; - -let state: unknown[] = []; -let cursor = 0; - -vi.doMock('react', () => ({ - ...React, - useEffect: (effect: () => void) => effect(), - useMemo: (factory: () => unknown) => factory(), - useId: () => `preview-${cursor++}`, - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => - snapshot(), - useRef: (initial: unknown) => ({ current: initial }), - useState: (initial: unknown) => { - const index = cursor++; - - if (!(index in state)) { - state[index] = typeof initial === 'function' ? initial() : initial; - } - - return [ - state[index], - (value: unknown) => { - state[index] = - typeof value === 'function' - ? (value as (current: unknown) => unknown)(state[index]) - : value; - }, - ]; - }, -})); - -vi.doMock('@inertiajs/react', async () => ({ - ...(await vi.importActual('@inertiajs/react')), -})); - -const provider: GifProvider = { - id: 'klipy', - label: 'KLIPY', - searchPlaceholder: 'Search KLIPY', - attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, - search: vi.fn(async () => ({ - results: [ - { - id: 'one', - url: 'https://static.klipy.com/ii/one.gif', - pageUrl: 'https://klipy.com/gifs/one', - title: 'One', - username: null, - width: 100, - height: 100, - previewUrl: 'https://static.klipy.com/ii/one-preview.gif', - }, - ], - nextCursor: null, - })), - trending: vi.fn(async () => ({ results: [], nextCursor: null })), - validateKey: vi.fn(async () => 'valid' as const), -}; - -const { GifPickerDialog } = await import( - '../../resources/js/components/gif-picker-dialog' -); - -type Element = ReactElement>; - -function elements(node: ReactNode): Element[] { - if (!React.isValidElement(node)) { - return []; - } - - const element = node as Element; - - return [ - element, - ...React.Children.toArray(element.props.children as ReactNode).flatMap( - elements, - ), - ]; -} - -function render(open = true): Element[] { - cursor = 0; - - return elements( - GifPickerDialog({ provider, apiKey: 'k', open, onOpenChange: vi.fn(), onPick: vi.fn() }), - ); -} - -function field(fields: Element[], testId: string): Element { - const found = fields.find( - (element) => element.props['data-test'] === testId, - ); - - assert.ok(found, `no element with data-test="${testId}"`); - - return found; -} - -beforeEach(() => { - state = []; - cursor = 0; - vi.clearAllMocks(); -}); - -test('uses the provider placeholder and attribution', () => { - const fields = render(); - - assert.equal(field(fields, 'gif-search-input').props.placeholder, 'Search KLIPY'); - assert.ok( - fields.some( - (element) => - element.props.children === 'Powered by KLIPY' || - element.props.href === 'https://klipy.com', - ), - ); -}); - -test('searches through the injected provider', async () => { - render(); - await vi.waitFor(() => { - assert.equal((provider.trending as ReturnType).mock.calls.length, 1); - }); - assert.equal(provider.search.mock.calls.length, 0); -}); -``` - -Create `tests/Unit/GifPickerDialogRender.test.ts`: - -```ts -import { execFileSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -await test('renders the gif picker for an injected provider', () => { - const cli = fileURLToPath( - new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), - ); - - execFileSync( - process.execPath, - [cli, 'test', 'run', '--config', 'tests/gif-picker.config.ts'], - { - cwd: fileURLToPath(new URL('../..', import.meta.url)), - stdio: 'inherit', - }, - ); -}); -``` - -- [ ] **Step 3: Run the picker test to verify it fails** - -Run: `bun run test:unit` -Expected: FAIL — `GifPickerDialog` does not exist. - -- [ ] **Step 4: Implement the components** - -Replace `resources/js/components/gif-picker-dialog.tsx` with: - -```tsx -import { useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { attachmentFromResult } from '@/lib/gif-providers'; -import type { - GifAttachment, - GifProvider, - GifResult, -} from '@/lib/gif-providers'; -import { GifRateLimited } from '@/lib/gif-providers'; - -type Status = 'loading' | 'ready' | 'error' | 'rate-limited'; - -export function GifPickerDialog({ - provider, - apiKey, - open, - onOpenChange, - onPick, -}: { - provider: GifProvider; - apiKey: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onPick: (gif: GifAttachment) => void; -}) { - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [status, setStatus] = useState('loading'); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [reloads, setReloads] = useState(0); - - useEffect(() => { - if (!open) { - return; - } - - const controller = new AbortController(); - const timeout = setTimeout( - () => { - setStatus('loading'); - const trimmed = query.trim(); - const request = - trimmed === '' - ? provider.trending(apiKey, { - cursor: null, - signal: controller.signal, - }) - : provider.search(apiKey, trimmed, { - cursor: null, - signal: controller.signal, - }); - - request - .then((page) => { - setResults(page.results); - setNextCursor(page.nextCursor); - setStatus('ready'); - }) - .catch((error: unknown) => { - if (controller.signal.aborted) { - return; - } - - setStatus( - error instanceof GifRateLimited - ? 'rate-limited' - : 'error', - ); - }); - }, - query === '' ? 0 : 300, - ); - - return () => { - clearTimeout(timeout); - controller.abort(); - }; - }, [open, query, apiKey, reloads, provider]); - - const loadMore = () => { - setIsLoadingMore(true); - const trimmed = query.trim(); - const request = - trimmed === '' - ? provider.trending(apiKey, { cursor: nextCursor }) - : provider.search(apiKey, trimmed, { cursor: nextCursor }); - - request - .then((page) => { - setResults((current) => [...current, ...page.results]); - setNextCursor(page.nextCursor); - }) - .catch(() => undefined) - .finally(() => setIsLoadingMore(false)); - }; - - const pick = (result: GifResult) => { - onPick(attachmentFromResult(provider.id, result)); - onOpenChange(false); - setQuery(''); - }; - - return ( - - - - Attach a GIF - - Search {provider.label} and pick a GIF to attach. - - - setQuery(event.target.value)} - placeholder={provider.searchPlaceholder} - aria-label={provider.searchPlaceholder} - autoComplete="off" - spellCheck={false} - className="h-9 w-full min-w-0 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="gif-search-input" - /> - {status === 'loading' && ( -

- Loading… -

- )} - {status === 'rate-limited' && ( -

- You’ve hit your {provider.label} key’s search rate limit. - Try again shortly. -

- )} - {status === 'error' && ( -
-

- {provider.label} did not answer. -

- -
- )} - {status === 'ready' && results.length === 0 && ( -

- No GIFs matched. /dev/null remains empty. -

- )} - {status === 'ready' && results.length > 0 && ( - <> -
    - {results.map((result) => - result.previewUrl === null ? null : ( -
  • - -
  • - ), - )} -
- {nextCursor !== null && ( - - )} - - )} - -
-
- ); -} -``` - -Replace `resources/js/components/gif-picker-button.tsx` with: - -```tsx -import { usePage } from '@inertiajs/react'; -import { ImagePlay } from 'lucide-react'; -import { useState } from 'react'; -import { GifPickerDialog } from '@/components/gif-picker-dialog'; -import { Button } from '@/components/ui/button'; -import { gifProviderFor } from '@/lib/gif-providers'; -import type { GifAttachment } from '@/lib/gif-providers'; -import { cn } from '@/lib/utils'; - -/** Invisible until the signed-in user connects a GIF provider in settings. */ -export function GifPickerButton({ - disabled, - hasGif, - onPick, - className, -}: { - disabled?: boolean; - hasGif: boolean; - onPick: (gif: GifAttachment) => void; - className?: string; -}) { - const { gif } = usePage().props; - const [open, setOpen] = useState(false); - - if (gif === null) { - return null; - } - - return ( - <> - - - - ); -} -``` - -`gif-attachment-preview.tsx`: rename the component, type, and data-test only: - -```tsx -import { X } from 'lucide-react'; -import type { GifAttachment } from '@/lib/gif-providers'; - -export function GifAttachmentPreview({ - gif, - onRemove, -}: { - gif: GifAttachment | null; - onRemove: () => void; -}) { - if (gif === null) { - return null; - } - - return ( -
-
- {gif.title - - - GIF - -
-
- ); -} -``` - -Replace `resources/js/components/commit-gif.tsx` attribution: - -```tsx -const provider = gifProviders[gif.provider]; -const credit = - gif.username !== null - ? `GIF by @${gif.username} on ${provider.label}` - : `via ${provider.label}`; -``` - -and use `credit` inside the attribution link (body otherwise unchanged except `data-test="commit-gif"`). - -- [ ] **Step 5: Update the composer, quote, thread, and stash** - -Apply these replacements in `commit-composer.tsx`, `commit-quote-button.tsx`, `commit-thread.tsx`: - -- imports: `GiphyAttachmentPreview` → `GifAttachmentPreview` (path `gif-attachment-preview`); `GiphyPickerButton` → `GifPickerButton`; `CommitGiphy` → `CommitGif` (path `commit-gif`); `giphyFormFields` → `gifFormFields` from `@/lib/gif-providers`; `GiphyAttachment` → `GifAttachment` from `@/lib/gif-providers`. -- JSX component names updated accordingly (``, ``, ``). -- `commit-thread.tsx`: replace the inline gif field type block (`giphy_id?`, …, `giphy_height?`) with `gif_provider?: 'giphy' | 'klipy';` plus `gif_id?`, `gif_url?`, `gif_page_url?`, `gif_title?`, `gif_username?`, `gif_width?`, `gif_height?`. -- `use-stash.ts`: import `GifAttachment` from `@/lib/gif-providers/types` (type-only); replace `GiphyAttachment` references with `GifAttachment` (the `updateGif` API name stays). - -- [ ] **Step 6: Update the tests and configs** - -- `git mv tests/Unit/CommitGiphy.test.tsx tests/Unit/CommitGif.test.tsx`; `git mv tests/Unit/CommitGiphyRender.test.ts tests/Unit/CommitGifRender.test.ts`; `git mv tests/commit-giphy.config.ts tests/commit-gif.config.ts`. -- In `CommitGif.test.tsx`: rename imports/component (`CommitGif` from `commit-gif`); fixture `CommitGif` gains `provider: 'giphy'`; add two attribution cases: - -```tsx -test('credits klipy without a giphy-style username', () => { - const { attribution } = renderedGif({ provider: 'klipy', username: null }); - - assert.equal(attribution.props.children, 'via KLIPY'); -}); -``` - -(Adjust `renderedGif` so the fixture type carries `provider`.) -- `CommitGifRender.test.ts`: config path becomes `tests/commit-gif.config.ts`; update the config include to `tests/Unit/CommitGif.test.tsx`. -- `ComposerDraft.test.tsx` / `QuoteDraft.test.tsx` / `DraftStash.test.tsx`: replace `GiphyAttachment` with `GifAttachment` (+ `provider: 'giphy'` in fixtures) and every expected form field `giphy_*` with `gif_*`, including `gif_provider: 'giphy'`. -- `tests/tsconfig.json`: replace `Unit/CommitGiphy.test.tsx` with `Unit/CommitGif.test.tsx`; replace `commit-giphy.config.ts` with `commit-gif.config.ts`; add `Unit/GifPickerDialog.test.tsx` and `gif-picker.config.ts`. -- Add `'tests/Unit/GifPickerDialog.test.tsx'` to `tests/draft-images.config.ts`? No — the new picker test belongs to `tests/gif-picker.config.ts` only (wrapper runs it from `test:unit`). - -- [ ] **Step 7: Run the tests** - -Run: `bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add resources/js/components resources/js/hooks/use-stash.ts resources/js/lib/gif-providers tests/Unit tests/commit-gif.config.ts tests/gif-picker.config.ts tests/tsconfig.json -git commit -m "feat: run the gif picker through the provider interface" -``` - ---- - -### Task 7: The "GIFs" settings section - -**Files:** -- Rename+rewrite: `resources/js/components/giphy-key-setting.tsx` → `gif-key-setting.tsx` -- Modify: `resources/js/pages/user-profile/edit.tsx`, `README.md` -- Test: rename `tests/Unit/GiphyKeySetting.test.tsx` → `tests/Unit/GifKeySetting.test.tsx`, `tests/Unit/GiphyKeySettingRender.test.ts` → `tests/Unit/GifKeySettingRender.test.ts`, `tests/giphy-key.config.ts` → `tests/gif-key.config.ts`; update `tests/tsconfig.json` - -**Interfaces:** -- Consumes: generated `GifKeyController` actions (`update.url()`, `destroy.url()` → `/settings/gif`), `gifProviders`/`gifProviderFor`, page props `gifConnected: boolean`, `gifProvider: 'giphy' | 'klipy' | null`. -- Produces: `GifKeySetting({ gifConnected, gifProvider })` rendered on the profile page as section "GIFs". - -- [ ] **Step 1: Rename the test and write the failing expectations** - -```bash -git mv resources/js/components/giphy-key-setting.tsx resources/js/components/gif-key-setting.tsx -git mv tests/Unit/GiphyKeySetting.test.tsx tests/Unit/GifKeySetting.test.tsx -git mv tests/Unit/GiphyKeySettingRender.test.ts tests/Unit/GifKeySettingRender.test.ts -git mv tests/giphy-key.config.ts tests/gif-key.config.ts -``` - -In `tests/gif-key.config.ts` include `tests/Unit/GifKeySetting.test.tsx`. In `GifKeySettingRender.test.ts` use `--config tests/gif-key.config.ts` and the new test name. - -Update `GifKeySetting.test.tsx`: -- Mock `@inertiajs/react` router as before; the component is invoked with `{ gifConnected: false, gifProvider: null }` or `{ gifConnected: true, gifProvider: 'giphy' }`. -- The connect flow sends `{ provider: 'giphy', key: 'key-123' }`; after selecting KLIPY (click the provider button with `data-test="provider-klipy"`) the flow sends `{ provider: 'klipy', key: 'key-123' }`. -- The validation error copy becomes provider-agnostic: `'That key was rejected by KLIPY.'` when KLIPY is selected, `'That key was rejected by GIPHY.'` for GIPHY; unreachable similarly. -- Add: - -```tsx -test('replaces the connected provider when another is chosen', async () => { - stubFetch(200); - - let fields = render({ gifConnected: true, gifProvider: 'giphy' }); - (field(fields, 'change-provider-button').props.onClick as () => void)(); - - fields = render({ gifConnected: true, gifProvider: 'giphy' }); - (field(fields, 'provider-klipy').props.onClick as () => void)(); - - fields = render({ gifConnected: true, gifProvider: 'giphy' }); - field(fields, 'gif-key-input').props.onChange({ target: { value: 'klipy-key' } }); - - fields = render({ gifConnected: true, gifProvider: 'giphy' }); - await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); - - assert.deepEqual(put.mock.calls[0]?.[1], { provider: 'klipy', key: 'klipy-key' }); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `bun run test:unit` -Expected: FAIL — `GifKeySetting` does not exist. - -- [ ] **Step 3: Implement the settings component** - -Replace `resources/js/components/gif-key-setting.tsx` with: - -```tsx -import { router } from '@inertiajs/react'; -import { useState } from 'react'; -import { - destroy, - update, -} from '@/actions/App/Http/Controllers/GifKeyController'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Spinner } from '@/components/ui/spinner'; -import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; -import type { GifProviderId } from '@/lib/gif-providers'; - -const helpHref: Record = { - giphy: 'https://developers.giphy.com', - klipy: 'https://partner.klipy.com', -}; - -export function GifKeySetting({ - gifConnected, - gifProvider, -}: { - gifConnected: boolean; - gifProvider: GifProviderId | null; -}) { - const [providerId, setProviderId] = useState( - gifProvider ?? 'giphy', - ); - const [key, setKey] = useState(''); - const [editing, setEditing] = useState(!gifConnected); - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(null); - const provider = gifProviderFor(providerId); - - const connect = async () => { - if (processing) { - return; - } - - const candidate = key.trim(); - - if (candidate === '') { - return; - } - - setProcessing(true); - setError(null); - - const verdict = await provider.validateKey(candidate); - - if (verdict === 'invalid') { - setError(`That key was rejected by ${provider.label}.`); - setProcessing(false); - - return; - } - - if (verdict === 'unreachable') { - setError(`Couldn't reach ${provider.label} to verify the key.`); - setProcessing(false); - - return; - } - - router.put( - update.url(), - { provider: providerId, key: candidate }, - { - preserveScroll: true, - onFinish: () => setProcessing(false), - onError: (errors) => - setError( - errors.key ?? - errors.provider ?? - 'Could not save the key.', - ), - onSuccess: () => { - setKey(''); - setError(null); - setEditing(false); - }, - }, - ); - }; - - const remove = () => { - if (processing) { - return; - } - - setError(null); - - router.delete(destroy.url(), { - preserveScroll: true, - onStart: () => setProcessing(true), - onFinish: () => setProcessing(false), - onError: () => setError('Could not remove the provider.'), - }); - }; - - return ( -
-

- GIFs -

-

- GIF search runs on your own {provider.label} API key, under your - own quota. Get a key at{' '} - - {provider.label} - - , connect it here, and every composer gains a GIF picker. One - provider at a time; without a key the picker stays hidden. -

- {gifConnected && !editing ? ( -
-

- Connected to {gifProviderFor(gifProvider ?? 'giphy').label}. -

- - -
- ) : ( -
-
- {Object.values(gifProviders).map((candidate) => ( - - ))} -
- -
- setKey(event.target.value)} - placeholder={`Paste your ${provider.label} API key`} - autoComplete="off" - spellCheck={false} - className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="gif-key-input" - /> - -
-
- )} - {error !== null && ( -

- {error} -

- )} -
- ); -} -``` - -- [ ] **Step 4: Wire the profile page and README** - -`resources/js/pages/user-profile/edit.tsx`: import `GifKeySetting`; page props gain `gifProvider: 'giphy' | 'klipy' | null`; render `` after ``. - -`README.md` GIFs paragraph: replace the GIPHY-only text with: - -```markdown -GIF search is bring-your-own-key: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. -``` - -- [ ] **Step 5: Update the test configs** - -- `tests/tsconfig.json`: replace `Unit/GiphyKeySetting.test.tsx` with `Unit/GifKeySetting.test.tsx`; replace `giphy-key.config.ts` with `gif-key.config.ts`. -- Delete remaining references to `giphy-key.config.ts` (e.g. any include lists). - -- [ ] **Step 6: Run everything** - -Run: `php artisan test tests/Feature/Controllers/UserProfileControllerTest.php && bun run test:unit && bun run test:types && bun run test:lint` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add resources/js/components/gif-key-setting.tsx resources/js/pages/user-profile/edit.tsx README.md tests/Unit/GifKeySetting.test.tsx tests/Unit/GifKeySettingRender.test.ts tests/gif-key.config.ts tests/tsconfig.json -git commit -m "feat: choose the gif provider in profile settings" -``` - ---- - -### Task 8: Sweep, regenerate, and full gates - -**Files:** -- Modify: any file still using legacy `giphy*` naming (verify with the search below) -- Test: full suites - -**Interfaces:** -- Consumes: everything above. -- Produces: a branch with no stale GIPHY-only naming beyond the provider adapter/labels, green gates. - -- [ ] **Step 1: Sweep for leftovers** - -Run: - -```bash -rg -n "giphy|Giphy|GIPHY" app resources tests routes database README.md -``` - -Expected remaining matches only: -- `app/Enums/GifProvider.php` (GIPHY case/labels/hosts) -- `resources/js/lib/gif-providers/giphy.ts` (adapter) -- `resources/js/components/gif-key-setting.tsx` / `gif-picker-dialog.tsx` (labels/help copy come from adapters) -- test fixtures for the GIPHY adapter and provider labels. - -Rename anything else (`GiphyUploader`, `giphyConnected`, `withGiphy`, `giphy-key.*` route names, etc.) to the gif equivalents. - -- [ ] **Step 2: Regenerate generated routes and assets** - -```bash -php artisan wayfinder:generate --with-form -bun run build -``` - -- [ ] **Step 3: Run the full gates** - -```bash -composer test:lint -composer test:types -composer test:type-coverage -php -d memory_limit=-1 vendor/bin/pest --fresh -bun run test:unit -bun run test:types -bun run test:lint -``` - -Expected: all PASS. On machines without a PHP coverage driver, run the Pest suite without `--coverage` (CI enforces `--exactly=100.0`); note it in the PR. - -- [ ] **Step 4: Commit residual fixes (only if any)** - -```bash -git add -A -git commit -m "chore: sweep legacy giphy naming and satisfy the gates" -``` diff --git a/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md b/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md deleted file mode 100644 index 288c03676..000000000 --- a/docs/superpowers/specs/2026-10-19-gif-provider-key-client-storage-design.md +++ /dev/null @@ -1,93 +0,0 @@ -# Client-only GIF provider keys — design - -**Date:** 2026-10-19 -**Status:** Approved design, pending implementation plan -**Branch:** `feature/giphy-picker` (GIF feature unreleased) -**Supersedes:** the BYOK key-storage sections of `2026-10-19-gif-providers-design.md` (server-stored `users.gif_key` + `users.gif_provider`). Everything else in that spec — provider interface, schema for commit metadata, validation, picker/rendering — stands. - -## Summary - -The maintainers do not want API keys or secrets stored in the database. The GIF provider key therefore lives **only in the user's browser** (`localStorage`), where it is actually used. The server keeps only public GIF attachment metadata on commits. This removes the users key/provider columns and the entire server-side BYOK plumbing. - -## Approach (decided) - -- **A. `localStorage`, namespaced per user id** — `gif-provider:{userId}` stores `{provider, key}`. Persists across sessions, isolated between accounts on a shared browser, lost only when site data is cleared; inherently per-device. -- **A. `useSyncExternalStore` hook** — `useGifProviderKey(userId)` with a deterministic server snapshot (`null`), a small same-tab emitter, and cross-tab `storage` events. SSR-safe and reactive. - -Rejected: an origin-wide entry (accounts on one browser would share a key), `sessionStorage` (the key would die with the tab), reading localStorage at render behind a `typeof window` guard (server/client hydration mismatch), cookies (would send the secret to the server on every request). - -## Storage contract - -New module `resources/js/lib/gif-provider-key.ts`: - -| Function | Behavior | -|---|---| -| `readGifProviderKey(userId: string): GifProviderKey \| null` | Reads `localStorage['gif-provider:' + userId]`, parses, validates; malformed payloads are treated as absent | -| `writeGifProviderKey(userId, value: GifProviderKey): void` | Serializes `{provider, key}` and notifies same-tab subscribers | -| `clearGifProviderKey(userId): void` | Removes the entry and notifies | -| `subscribeGifProviderKey(userId, listener): () => void` | Same-tab emitter + `window` `storage` events for the namespaced key | - -`GifProviderKey = { provider: GifProviderId; key: string }` (type only in this module). - -Validation rules on read: -- parsed value is an object with string `provider` that is an **own** key of `gifProviders` (`Object.hasOwn` — prototype keys such as `toString` never pass); -- `key` is a string with `trim()` non-empty and length ≤ 255. - -Anything else returns `null` (never throws). Writes happen only from the settings section after browser-side validation. - -Namespacing: the key includes `auth.user.id`, so two accounts on the same browser never see each other's key. Signing out does not clear the entry (same person, same device); a deleted account leaves a harmless orphan entry. - -## Hook - -New `resources/js/hooks/use-gif-provider-key.ts`: - -```ts -export function useGifProviderKey( - userId: string | null, -): GifProviderKey | null; -``` - -- `useSyncExternalStore` with a server snapshot of `null` (SSR renders "not connected" / no picker button — no hydration mismatch). -- The client snapshot reads the module and returns a stable reference while the raw value is unchanged (cache the raw JSON string + parsed object). -- Subscribes to the namespaced same-tab emitter and `storage` events; a key written or cleared in another tab updates the hook. -- `userId === null` (guests) always returns `null` without touching storage. - -Consumers: -- `GifPickerButton` reads the hook with the signed-in user id; renders only when a valid entry exists and passes `gifProviderFor(entry.provider)` + `entry.key` to the dialog. -- `GifKeySetting` reads the hook for its connected state and writes/clears through the module. No Inertia round-trip, no server involvement. - -## Server cleanup (unreleased → delete, no back-compat) - -- Delete `database/migrations/2026_10_17_133426_add_gif_key_to_users_table.php` entirely (no users columns are needed). -- Remove from `app/Models/User.php`: `gif_provider`/`gif_key` docblock entries, `#[Hidden]` entries, `$attributes` defaults, and casts. -- Delete `app/Http/Controllers/GifKeyController.php`, `app/Http/Requests/UpdateGifKeyRequest.php`; remove the `settings/gif` routes and the controller import; regenerate Wayfinder (generated files stay gitignored). -- Remove the `gif` prop from `app/Http/Middleware/HandleInertiaRequests.php`. -- Remove `gifConnected`/`gifProvider` from `UserProfileController::edit`. -- Remove the `gif` entry from `resources/js/types/global.d.ts`. -- Delete `tests/Feature/Controllers/GifKeyControllerTest.php` and `tests/Feature/Middleware/GifPropTest.php`; remove the gif-connection test from `UserProfileControllerTest` and the key-in-export test from `ExportControllerTest`. -- Update the README "GIFs" paragraph: keys are stored only in the browser, never sent to the server, and each device needs its own. -- Commit metadata (`gif_provider` + `gif_*` columns, serialization, API/feed/OG/moderation) is unchanged — public data, not secrets. The server never sees a provider key anywhere. - -## Settings UX - -- The "GIFs" section keeps the provider selector, key field, browser-side validation (200 saves; 401/403 rejected; 429 saved as real-but-throttled; network failure rejected), write-only key, and Update/Change provider/Remove. -- Saving is instant and client-side; no toast is needed. The section states: **"Stored in this browser only — never sent to the server. Each device needs its own key."** -- Connected state ("Connected to GIPHY/KLIPY") comes from the hook; Remove clears the local entry, and the picker button disappears on the next mount. A change in another tab is reflected via the `storage` event. -- The connected state appears after hydration (the server snapshot is null); the disconnected form is the SSR state, which is deterministic. - -## Security notes - -- `localStorage` is readable by any script on the origin — the same exposure class as the composer drafts already stored there. No cookies, so the key is never attached to HTTP requests; no server-side secret exists to leak from the DB or exports. -- The key is read only by the picker dialog and the settings section; it is never logged and never placed in page props. -- The existing draft stash (`lib/gif-storage.ts`) is unrelated to the provider key and stays as is. - -## Testing - -- **New `tests/Unit/GifProviderKey.test.ts`:** round-trip per user; per-user namespacing; malformed JSON; unknown provider; prototype provider (`toString`/`__proto__`) rejected; empty/whitespace/oversized key rejected; clear; same-tab listener notification; `storage`-event subscription (simulated event) triggers the listener. -- **Component:** settings writes/clears `localStorage`, connected state from the hook, invalid/unreachable never write, 429 writes; picker button renders only with a valid stored entry and passes the right provider/key, absent without one. -- **Server:** deleted test files; `UserProfileControllerTest`/`ExportControllerTest` no longer reference the key; full Pest suite green with zero `gif_key`/`gif_provider` references on users. - -## Out of scope - -- Cross-device sync, passphrase-encrypting the stored value, `sessionStorage`, cookies. -- Server-side validation or storage of provider keys of any kind. diff --git a/docs/superpowers/specs/2026-10-19-gif-providers-design.md b/docs/superpowers/specs/2026-10-19-gif-providers-design.md deleted file mode 100644 index 4980b7683..000000000 --- a/docs/superpowers/specs/2026-10-19-gif-providers-design.md +++ /dev/null @@ -1,219 +0,0 @@ -# Multi-provider GIF picker — design - -**Date:** 2026-10-19 -**Status:** Approved design, pending implementation plan -**Branch:** `feature/giphy-picker` (the GIF feature is not shipped, so no backward compatibility is required) - -## Summary - -Generalize the GIPHY-only GIF picker into a provider-agnostic feature. Users connect **one -active GIF provider** (GIPHY or KLIPY) with their own API key; the picker searches that -provider from the browser. Adding another provider later is one client adapter plus one -enum case. - -The provider-specific knowledge lives behind two seams: a client-side `GifProvider` -interface and a server-side `GifProvider` enum (host allowlists, labels). The shared -picker, composer wiring, storage, rendering, feeds, OG metadata, and moderation never -branch on provider. - -## Approach (decided) - -**Provider registry (client) + provider enum (server).** One small module per provider in -`resources/js/lib/gif-providers/`; the picker receives the resolved provider object and -depends only on its interface. The server owns provider ids, URL allowlists, and enum -validation. Rejected alternatives: server-sent provider config with a generic fetcher -(hides API-shape knowledge in a config DSL, still needs per-provider normalization) and -per-provider picker components (duplicates state, errors, attribution, and the UX). - -**One active provider.** Connecting a provider replaces the previous connection. This -matches provider terms: KLIPY's integration requirements forbid blending its results with -another provider's in the same result set, and users may have provider-specific -preferences. There is no simultaneous multi-key or in-picker provider switcher. - -**No backward compatibility.** The feature is unreleased: the existing two migrations are -rewritten in place (`giphy_*` → `gif_*`, provider columns added), and the public API emits -the final shape (`gif.provider`) from day one. No backfills, no legacy aliases. - -## Provider contracts - -### Shared interface (`resources/js/lib/gif-providers/types.ts`) - -```ts -export type GifProviderId = 'giphy' | 'klipy'; - -export type GifSearchPage = { - results: GifResult[]; - nextCursor: string | null; // opaque; null ends pagination -}; - -export type GifKeyValidation = - | 'valid' - | 'invalid' - | 'rate-limited' - | 'unreachable'; - -export interface GifProvider { - readonly id: GifProviderId; - readonly label: string; // GIPHY | KLIPY - readonly searchPlaceholder: string; - readonly attribution: { readonly label: string; readonly href: string }; - search( - key: string, - query: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - trending( - key: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - validateKey(key: string): Promise; -} -``` - -`GifResult` normalizes a provider item to `{id, url, pageUrl, title, username|null, -width, height}`. The adapter picks renditions; consumers only see the normalized values. -`index.ts` exports `providers: Record` and -`providerFor(id: GifProviderId)`. - -### GIPHY adapter (moved from today's `lib/giphy.ts`) - -- Search `/v1/gifs/search`, trending `/v1/gifs/trending` on `api.giphy.com`, with - `api_key`, `limit=24`, `offset={cursor}`, `rating=g`. -- Stored rendition: `downsized_medium → downsized → original`; preview: - `fixed_height_small → downsized → downsized_medium → original`. -- Page URL: the item's `url`; username may be present; title may be empty → null. -- Key validation: trending with `limit=1`; 200 valid, 401/403 invalid, 429 - rate-limited, network/other unreachable. -- `searchPlaceholder: 'Search GIPHY'`; `attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }`. - -### KLIPY adapter - -- Tenor-compatible API on `https://api.klipy.com`: - - search: `GET /v2/search?key={key}&q={query}&limit=24&pos={cursor}&contentfilter=high` - - trending: `GET /v2/featured?key={key}&limit=24&pos={cursor}&contentfilter=high` -- Response: `{ results: [...], next: string }`; `next` empty ends pagination. Each result - has `id`, `title`, `content_description`, `itemurl`, and - `media_formats: { gif: { url, dims: [w, h] }, tinygif?: { url } }`. -- Stored rendition: `media_formats.gif` (URL kept verbatim); preview: - `media_formats.tinygif ?? gif`; page URL: `itemurl`; username: null; title falls back to - `content_description`. -- Key validation: featured with `limit=8`; same status mapping as above. -- `searchPlaceholder: 'Search KLIPY'` (**required** by KLIPY's attribution guidelines); - `attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }`. -- Media hosts for validation: `static.klipy.com`, `static1.klipy.com`, `static2.klipy.com`. - Page host: `klipy.com` (and `www.klipy.com`). - -### Tests prove the seam - -The adapter contract test suite is parameterized over the registry and runs with a fixture -per provider (URL building, cursor mapping, normalization, empty pages, error mapping). -The picker component takes the provider object as a prop, so component tests inject a fake -provider and prove the shared UI has no provider-specific branches. - -## Data model - -Migrations are rewritten in place (no compatibility shims): - -| Migration | Change | -|---|---| -| `add_gif_to_commits_table` | nullable `gif_provider` (string 16) + the seven attachment columns renamed `giphy_*` → `gif_*` (`gif_id`, `gif_url`, `gif_page_url`, `gif_title`, `gif_username`, `gif_width`, `gif_height`) | -| `add_gif_key_to_users_table` | nullable `gif_provider` (string 16) + `gif_key` (text, encrypted cast) | - -- `App\Enums\GifProvider: string` — cases `Giphy = 'giphy'`, `Klipy = 'klipy'`; methods - `label()`, `mediaHostPatterns(): list`, `pageHostPatterns(): list`. - Hosts: GIPHY `media*.giphy.com` and `giphy.com`/`www.giphy.com`; KLIPY - `static{1,2}.klipy.com` and `klipy.com`/`www.klipy.com`. The client-side search - placeholder is adapter-owned, not part of the enum. - `Commit::casts()` maps `gif_provider` to the enum. -- `Commit` keeps `#[Hidden]` over the raw `gif_*` columns and derives one `gif` object: - `{provider, id, url, page_url, title, username, width, height}` (null when no GIF). -- `User` gains `gif_provider` (enum cast, hidden) and `gif_key` (encrypted, hidden); - both null when no provider is connected. -- `CommitBuilder::COLUMNS_BUT_EMBEDDING` carries the renamed `gif_*` columns and - `gif_provider`; both factories expose `CommitFactory::withGif(GifProvider $provider = - GifProvider::Giphy)` and `UserFactory::withGifKey(GifProvider $provider = - GifProvider::Giphy, string $key = 'gif-test-key')`. - -## Server validation - -- `App\Rules\GifMediaUrl` takes the `GifProvider`; accepts only https URLs whose host - matches the provider's media hosts, path under `/` ending in `.gif`, query strings - verbatim, no fragment/port/userinfo, and no dot-segments. -- `App\Rules\GifPageUrl` takes the `GifProvider`; https and a provider page host. -- `CreateCommitRequest` and `CreateCommitReplyRequest` accept the `gif_*` set including - `gif_provider` (`Rule::enum(GifProvider::class)`, required with the rest of the set); - media and page rules are constructed with that provider, so a GIPHY URL with - `gif_provider=klipy` is rejected. Exclusivity rules are unchanged (GIF ⟂ images, - GIF ⟂ poll, GIF ⟂ scheduling). -- `App\Dto\Gif` replaces `App\Dto\Giphy` and carries the provider to the columns. -- BYOK: `PUT settings/gif` takes `{provider, key}` (enum + trimmed non-empty string, - max 255) and stores both atomically; `DELETE settings/gif` clears both. The server never - calls a provider; the browser validates before saving. -- `HandleInertiaRequests` shares `gif: { provider, key } | null` from the signed-in user - (guests and unconnected users get null). The owner-only prop is the only channel that - ships the key; `gif_key` is hidden from model serialization and from the account export. - -## Settings UI - -- The Profile settings section is titled **"GIFs"**: a segmented control - **[GIPHY | KLIPY]** above one key field. Selecting a provider shows that provider's key - field and help link (`developers.giphy.com` / KLIPY partner panel). -- Connect validates the key in the browser through the provider adapter, then - `PUT settings/gif` stores `provider + key` atomically, replacing any previous - connection. Invalid/unreachable keys never save; a rate-limited key is accepted as real. -- Connected state: "Connected to GIPHY" (or KLIPY) with **Update**, **Change provider** - (reveals the selector + field), and **Remove**. The key is write-only. -- `GifKeyController` replaces `GiphyKeyController`; routes `settings/gif` - (`gif-key.update`, `gif-key.destroy`). - -## Picker, composer, and rendering - -- `GifPickerButton` stays hidden unless `gif` prop is non-null; it passes the resolved - provider to `GifPickerDialog`. -- Dialog: provider placeholder from the adapter (KLIPY's required "Search KLIPY"), - debounced query, trending on empty query, cursor pagination, provider attribution mark - in the footer, provider-named rate-limit copy ("You've hit your KLIPY key's search rate - limit."), loading/empty/error states unchanged. -- Picking a GIF stores `{provider, ...}` in the draft/stash and submits hidden `gif_*` - fields including `gif_provider`; previews and exclusivity behavior are unchanged. -- `CommitGif` renders the stored provider's attribution: GIPHY → "GIF by @user on GIPHY" - (or "via GIPHY"), KLIPY → "via KLIPY"; both link the stored page URL. Attribution is - historical — switching providers never changes old commits. -- Read surfaces stay URL-based and provider-agnostic: `CommitResource.gif` includes - `provider`, `PageMetadata` uses the stored URL for OG images, Atom encloses it as - `image/gif`, and AI moderation appends the URL to the image list. - -## File map (renames) - -- `app/Dto/Giphy.php` → `app/Dto/Gif.php` -- `app/Rules/GiphyMediaUrl.php` → `app/Rules/GifMediaUrl.php`; new `GifPageUrl` rule -- `app/Http/Controllers/GiphyKeyController.php` → `GifKeyController.php` -- `app/Http/Requests/UpdateGiphyKeyRequest.php` → `UpdateGifKeyRequest.php` -- `resources/js/lib/giphy.ts` → `resources/js/lib/gif-providers/{types,giphy,klipy,index}.ts` -- `resources/js/components/giphy-picker-{button,dialog}.tsx` → `gif-picker-{button,dialog}.tsx` -- `resources/js/components/giphy-attachment-preview.tsx` → `gif-attachment-preview.tsx` -- `resources/js/components/giphy-key-setting.tsx` → `gif-key-setting.tsx` -- `resources/js/components/commit-giphy.tsx` → `commit-gif.tsx` -- Tests and configs renamed accordingly (`Giphy*.test.*` → `Gif*.test.*`). - -## Testing - -- **Client:** adapter contract suite for both providers (URLs, cursors, normalization, - empty/429/401/network mapping); picker component tests with an injected fake provider - (placeholder, attribution, rate-limit copy, pagination); settings tests (provider - selector, connect/replace semantics, invalid key never saves); render tests for per- - provider attribution; stash round-trip retains the provider. -- **Server:** enum host patterns; `GifMediaUrl`/`GifPageUrl` per provider including - cross-provider rejection; request validation for the `gif_*` set; `Commit::gif()` - serialization and API resource include `provider`; BYOK controller (connect, replace - across providers, remove, validation, auth); middleware prop; export excludes the key. -- Existing suites (composer exclusivity, feeds, OG, moderation) keep passing with the - renamed fields. - -## Out of scope - -- Tenor (closed to new API clients; KLIPY's Tenor-compatible surface keeps an adapter - cheap to add later). -- Multiple simultaneous providers or in-picker provider switching. -- Provider extras (KLIPY categories/stickers/clips/ads, Tenor stickers) and per-provider - default preferences beyond the single active connection. From ef5ed569ebd9ee4468aceff09d89229b149c82ba Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 19:42:30 -0400 Subject: [PATCH 64/83] refactor: give commits a dedicated gifs table --- app/Actions/CreateCommit.php | 5 +- app/Actions/CreateUserFeed.php | 4 +- app/Actions/UpdateCommitAiDecision.php | 4 +- app/Dto/Gif.php | 16 ++-- app/Http/Resources/CommitResource.php | 2 +- app/Models/Commit.php | 59 +++---------- app/Models/CommitGif.php | 84 +++++++++++++++++++ app/Queries/CommitBuilder.php | 36 +++++++- app/Services/PageMetadata.php | 10 +-- database/factories/CommitFactory.php | 35 ++++---- ..._10_17_024509_add_gif_to_commits_table.php | 24 ------ ..._10_17_024509_create_commit_gifs_table.php | 27 ++++++ .../Controllers/CommitControllerTest.php | 8 +- .../Controllers/CommitReplyControllerTest.php | 4 +- tests/Unit/Models/CommitTest.php | 23 +++-- tests/Unit/Queries/CommitBuilderTest.php | 2 +- .../Queries/NotificationGroupsQueryTest.php | 2 +- 17 files changed, 213 insertions(+), 132 deletions(-) create mode 100644 app/Models/CommitGif.php delete mode 100644 database/migrations/2026_10_17_024509_add_gif_to_commits_table.php create mode 100644 database/migrations/2026_10_17_024509_create_commit_gifs_table.php diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 250ba4c46..727538b46 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -67,9 +67,12 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'quoted_commit_id' => $quotedCommit?->id, 'hot_score' => $this->theAlgorithm->score($createdAt, 0, 0), 'created_at' => $createdAt, - ...$gif?->toColumns() ?? [], ]); + if ($gif instanceof Gif) { + $commit->gif()->create($gif->toColumns()); + } + $this->createCommitImages->handle($commit, $images, $imageDescriptions); if ($poll instanceof NewPoll) { diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index 2683c4a8c..cd7905c08 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -71,8 +71,8 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'enclosure', $image->url(), 'image/webp'); } - if ($commit->gif_url !== null) { - $this->link($xml, 'enclosure', $commit->gif_url, 'image/gif'); + if ($commit->gif !== null) { + $this->link($xml, 'enclosure', $commit->gif->url, 'image/gif'); } $xml->writeElement('published', $commit->created_at->toAtomString()); diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index d5b1f8555..3a8f6d04d 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -36,8 +36,8 @@ public function handle(Commit $commit): void fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), )->all()); - if ($commit->gif_url !== null) { - $images[] = $commit->gif_url; + if ($commit->gif !== null) { + $images[] = $commit->gif->url; } $scores = $this->openAI->moderate($commit->body, $images); diff --git a/app/Dto/Gif.php b/app/Dto/Gif.php index 6a5d37050..37d25dc23 100644 --- a/app/Dto/Gif.php +++ b/app/Dto/Gif.php @@ -28,14 +28,14 @@ public function __construct( public function toColumns(): array { return [ - 'gif_provider' => $this->provider->value, - 'gif_id' => $this->id, - 'gif_url' => $this->url, - 'gif_page_url' => $this->pageUrl, - 'gif_title' => $this->title, - 'gif_username' => $this->username, - 'gif_width' => $this->width, - 'gif_height' => $this->height, + 'provider' => $this->provider->value, + 'provider_gif_id' => $this->id, + 'url' => $this->url, + 'page_url' => $this->pageUrl, + 'title' => $this->title, + 'username' => $this->username, + 'width' => $this->width, + 'height' => $this->height, ]; } } diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 7aa8881d5..0d40d0d6f 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -60,7 +60,7 @@ public function toArray(Request $request): array 'replies_count' => $this->whenCounted('replies'), 'link_preview' => $this->whenLoaded('linkPreview'), 'images' => $this->whenLoaded('images'), - 'gif' => $this->gif(), + 'gif' => $this->gif?->toPayload(), 'author' => UserResource::make($this->user), ]; diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 0e1969dbc..32dd8ca91 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Enums\CommitStatus; -use App\Enums\GifProvider; use App\Queries\CommitBuilder; use App\Services\CommitBodies; use Carbon\CarbonInterface; @@ -37,14 +36,6 @@ * @property-read CommitStatus|null $ai_decision * @property-read list|null $ai_categories * @property-read list|null $embedding - * @property-read GifProvider|null $gif_provider - * @property-read string|null $gif_id - * @property-read string|null $gif_url - * @property-read string|null $gif_page_url - * @property-read string|null $gif_title - * @property-read string|null $gif_username - * @property-read int|null $gif_width - * @property-read int|null $gif_height * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read CarbonInterface|null $deleted_at @@ -61,6 +52,7 @@ * @property-read string|null $continuation_id * @property-read LinkPreview|null $linkPreview * @property-read Poll|null $poll + * @property-read CommitGif|null $gif * @property-read Commit|null $quotedCommit * @property-read Collection $quotes * @property-read Collection $images @@ -69,14 +61,6 @@ 'ai_decision', 'ai_categories', 'embedding', - 'gif_provider', - 'gif_id', - 'gif_url', - 'gif_page_url', - 'gif_title', - 'gif_username', - 'gif_width', - 'gif_height', ])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model @@ -133,14 +117,6 @@ public function casts(): array 'ai_decision' => CommitStatus::class, 'ai_categories' => 'array', 'embedding' => AsVector::class, - 'gif_provider' => GifProvider::class, - 'gif_id' => 'string', - 'gif_url' => 'string', - 'gif_page_url' => 'string', - 'gif_title' => 'string', - 'gif_username' => 'string', - 'gif_width' => 'integer', - 'gif_height' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -162,7 +138,7 @@ public function toArray(): array return array_intersect_key($this->attributesToArray(), array_flip($keys)); } - return [...parent::toArray(), 'gif' => $this->gif(), ...resolve(CommitBodies::class)->segments($this->body), 'is_checking' => $this->isBeingChecked()]; + return [...parent::toArray(), 'gif' => $this->gif?->toPayload(), ...resolve(CommitBodies::class)->segments($this->body), 'is_checking' => $this->isBeingChecked()]; } /** @@ -259,6 +235,14 @@ public function poll(): HasOne return $this->hasOne(Poll::class); } + /** + * @return HasOne + */ + public function gif(): HasOne + { + return $this->hasOne(CommitGif::class); + } + /** * @return BelongsTo */ @@ -299,29 +283,6 @@ public function imagesAreVisibleTo(?User $user): bool return $user instanceof User && ($this->user_id === $user->id || $user->isAdmin()); } - /** - * The attached GIF, hotlinked by its media URL exactly as the provider returned it. - * - * @return array{provider: string, id: string, url: string, page_url: string, title: string|null, username: string|null, width: int|null, height: int|null}|null - */ - public function gif(): ?array - { - if ($this->gif_id === null || $this->gif_url === null || $this->gif_page_url === null || $this->gif_provider === null) { - return null; - } - - return [ - 'provider' => $this->gif_provider->value, - 'id' => $this->gif_id, - 'url' => $this->gif_url, - 'page_url' => $this->gif_page_url, - 'title' => $this->gif_title, - 'username' => $this->gif_username, - 'width' => $this->gif_width, - 'height' => $this->gif_height, - ]; - } - /** * Loads what anyone may see around the commit: its author, its approved replies, and its parent only when approved. */ diff --git a/app/Models/CommitGif.php b/app/Models/CommitGif.php new file mode 100644 index 000000000..b40980059 --- /dev/null +++ b/app/Models/CommitGif.php @@ -0,0 +1,84 @@ +> */ + use HasFactory; + + use HasUuids; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'commit_id' => 'string', + 'provider' => GifProvider::class, + 'provider_gif_id' => 'string', + 'url' => 'string', + 'page_url' => 'string', + 'title' => 'string', + 'username' => 'string', + 'width' => 'integer', + 'height' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class); + } + + /** + * The GIF as every read surface exposes it. + * + * @return array{provider: string, id: string, url: string, page_url: string, title: string|null, username: string|null, width: int, height: int} + */ + public function toPayload(): array + { + return [ + 'provider' => $this->provider->value, + 'id' => $this->provider_gif_id, + 'url' => $this->url, + 'page_url' => $this->page_url, + 'title' => $this->title, + 'username' => $this->username, + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index d15ff61ee..0c3f47d97 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -28,9 +28,15 @@ final class CommitBuilder extends Builder private const array COLUMNS_BUT_EMBEDDING = [ 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', - 'gif_provider', 'gif_id', 'gif_url', 'gif_page_url', 'gif_title', 'gif_username', 'gif_width', 'gif_height', ]; + public function __construct(QueryBuilder $query) + { + parent::__construct($query); + + $this->with('gif'); + } + /** * Embeddings are only ever compared within the database, so commits are loaded without theirs rather than carry * kilobytes of numbers each that nothing reads. @@ -56,6 +62,27 @@ public function getModels($columns = ['*']): array } } + /** + * Only a commit loaded with every column can be serialized, which is the only time the gif is read. + * + * @param array $models + * @return array + */ + public function eagerLoadRelations(array $models): array + { + $eagerLoads = $this->getEagerLoads(); + + if ($models !== [] && ! $this->hasEveryColumn($models[0])) { + $this->without('gif'); + } + + try { + return parent::eagerLoadRelations($models); + } finally { + $this->setEagerLoads($eagerLoads); + } + } + /** * Approved commits are visible to everyone, guests included, while authors may also see their own pending or rejected commits. * A block hides each one's commits from the other. @@ -317,6 +344,13 @@ private function isEveryColumn(Expression|string $column): bool && ($column === $this->qualifyColumn('*') || ($column === '*' && $this->query->joins === null)); } + private function hasEveryColumn(Commit $commit): bool + { + $attributes = $commit->getAttributes(); + + return array_all(self::COLUMNS_BUT_EMBEDDING, fn (string $column): bool => array_key_exists($column, $attributes)); + } + /** * @param HasMany $relation * @return HasMany diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index a1f49310f..c59dbae40 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -289,13 +289,13 @@ private function commitImages(Commit $commit, User $user): array ]; } - if ($commit->gif_url !== null) { + if ($commit->gif !== null) { $images[] = [ - 'url' => $commit->gif_url, - 'width' => (int) $commit->gif_width, - 'height' => (int) $commit->gif_height, + 'url' => $commit->gif->url, + 'width' => $commit->gif->width, + 'height' => $commit->gif->height, 'type' => 'image/gif', - 'alt' => $commit->gif_title ?? "GIF attached to the commit of {$user->nameWithHandle()}", + 'alt' => $commit->gif->title ?? "GIF attached to the commit of {$user->nameWithHandle()}", ]; } diff --git a/database/factories/CommitFactory.php b/database/factories/CommitFactory.php index e6e4299a6..734562db2 100644 --- a/database/factories/CommitFactory.php +++ b/database/factories/CommitFactory.php @@ -63,28 +63,27 @@ public function withGif(GifProvider $provider = GifProvider::Giphy): self { $gif = match ($provider) { GifProvider::Giphy => [ - 'gif_id' => 'xT9IgzoKnwFNmISR8I', - 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', - 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - 'gif_title' => 'Deal With It GIF', - 'gif_username' => 'giphy', - 'gif_width' => 480, - 'gif_height' => 270, + 'provider_gif_id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, ], GifProvider::Klipy => [ - 'gif_id' => 'hello-hi-662', - 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', - 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', - 'gif_title' => 'Hello', - 'gif_username' => null, - 'gif_width' => 498, - 'gif_height' => 498, + 'provider_gif_id' => 'hello-hi-662', + 'url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', + 'page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'title' => 'Hello', + 'username' => null, + 'width' => 498, + 'height' => 498, ], }; - return $this->state(fn (array $attributes): array => [ - 'gif_provider' => $provider->value, - ...$gif, - ]); + return $this->afterCreating(function (Commit $commit) use ($provider, $gif): void { + $commit->gif()->create(['provider' => $provider->value, ...$gif]); + }); } } diff --git a/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php b/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php deleted file mode 100644 index 596e0b025..000000000 --- a/database/migrations/2026_10_17_024509_add_gif_to_commits_table.php +++ /dev/null @@ -1,24 +0,0 @@ -string('gif_provider', 16)->nullable(); - $table->string('gif_id', 64)->nullable(); - $table->text('gif_url')->nullable(); - $table->text('gif_page_url')->nullable(); - $table->string('gif_title', 300)->nullable(); - $table->string('gif_username', 100)->nullable(); - $table->unsignedSmallInteger('gif_width')->nullable(); - $table->unsignedSmallInteger('gif_height')->nullable(); - }); - } -}; diff --git a/database/migrations/2026_10_17_024509_create_commit_gifs_table.php b/database/migrations/2026_10_17_024509_create_commit_gifs_table.php new file mode 100644 index 000000000..1525a8074 --- /dev/null +++ b/database/migrations/2026_10_17_024509_create_commit_gifs_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('commit_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('provider', 16); + $table->string('provider_gif_id', 64); + $table->text('url'); + $table->text('page_url'); + $table->string('title', 300)->nullable(); + $table->string('username', 100)->nullable(); + $table->unsignedSmallInteger('width'); + $table->unsignedSmallInteger('height'); + $table->timestamps(); + }); + } +}; diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 69bab6059..2e3e3e32a 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1449,9 +1449,9 @@ $response->assertSessionHasNoErrors(); $commit = $user->commits()->sole(); - expect($commit->gif_id)->toBe('xT9IgzoKnwFNmISR8I') - ->and($commit->gif_url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') - ->and($commit->gif_width)->toBe(480); + expect($commit->gif?->provider_gif_id)->toBe('xT9IgzoKnwFNmISR8I') + ->and($commit->gif?->url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') + ->and($commit->gif?->width)->toBe(480); }); it('accepts a klipy gif', function (): void { @@ -1469,7 +1469,7 @@ $response->assertSessionHasNoErrors(); - expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); + expect($user->commits()->sole()->gif?->provider->value)->toBe('klipy'); }); it('rejects a gif url from another provider', function (): void { diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 41ce2f2f1..31b6a4441 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -966,7 +966,7 @@ $response->assertSessionHasNoErrors(); - expect($user->commits()->sole()->gif_id)->toBe('xT9IgzoKnwFNmISR8I'); + expect($user->commits()->sole()->gif?->provider_gif_id)->toBe('xT9IgzoKnwFNmISR8I'); }); it('accepts a klipy gif reply', function (): void { @@ -985,7 +985,7 @@ $response->assertSessionHasNoErrors(); - expect($user->commits()->sole()->gif_provider->value)->toBe('klipy'); + expect($user->commits()->sole()->gif?->provider->value)->toBe('klipy'); }); it('rejects invalid providers', function (array $payload): void { diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 76a30a064..740a36d2c 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -439,7 +439,7 @@ expect($tombstone->toArray())->not->toHaveKey('images'); }); -it('serializes an attached gif as one object and hides the raw columns', function (): void { +it('serializes an attached gif as one object without its raw attributes', function (): void { $commit = Commit::factory()->withGif()->create(); $payload = $commit->toArray(); @@ -453,7 +453,15 @@ 'username' => 'giphy', 'width' => 480, 'height' => 270, - ])->and(array_keys($payload))->each->not->toStartWith('gif_'); + ])->and($payload)->toHaveKey('gif') + ->and(array_keys($payload))->each->not->toStartWith('gif_'); +}); + +it('keeps its gif in a dedicated row', function (): void { + $commit = Commit::factory()->withGif()->create(); + + expect($commit->gif)->not->toBeNull() + ->and($commit->gif->commit->is($commit))->toBeTrue(); }); it('serializes the klipy provider too', function (): void { @@ -478,14 +486,3 @@ expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); }); - -it('serializes no gif when the provider is missing', function (): void { - $commit = Commit::factory()->create([ - 'gif_id' => 'xT9IgzoKnwFNmISR8I', - 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', - 'gif_page_url' => 'https://giphy.com/gifs/xT9IgzoKnwFNmISR8I', - 'gif_provider' => null, - ]); - - expect($commit->toArray()['gif'])->toBeNull(); -}); diff --git a/tests/Unit/Queries/CommitBuilderTest.php b/tests/Unit/Queries/CommitBuilderTest.php index 596f55e1f..0bf0b3559 100644 --- a/tests/Unit/Queries/CommitBuilderTest.php +++ b/tests/Unit/Queries/CommitBuilderTest.php @@ -66,7 +66,7 @@ $commits = Commit::query()->withStarsFor($viewer)->with('user')->get()->keyBy('id'); - expect(DB::getQueryLog())->toHaveCount(2) + expect(DB::getQueryLog())->toHaveCount(3) ->and($commits[$starred->id]->stars_count)->toBe(2) ->and($commits[$starred->id]->starred)->toBeTrue() ->and($commits[$plain->id]->stars_count)->toBe(0) diff --git a/tests/Unit/Queries/NotificationGroupsQueryTest.php b/tests/Unit/Queries/NotificationGroupsQueryTest.php index 5a9784dbf..3982a4f04 100644 --- a/tests/Unit/Queries/NotificationGroupsQueryTest.php +++ b/tests/Unit/Queries/NotificationGroupsQueryTest.php @@ -186,7 +186,7 @@ function followAt(User $follower, User $user, int $minutesAgo): void resolve(NotificationGroupsQuery::class)->paginate($user); - expect(DB::getQueryLog())->toHaveCount(4); + expect(DB::getQueryLog())->toHaveCount(5); })->with([1, 5]); it('skips loading stacked actors when nothing is grouped', function (): void { From f65661eaf4737a3263354e698d8e3f13e278fbe9 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 19:45:12 -0400 Subject: [PATCH 65/83] test: add a commit gif factory --- database/factories/CommitGifFactory.php | 34 +++++++++++++++++++++++++ tests/Unit/Models/CommitTest.php | 9 +++++++ 2 files changed, 43 insertions(+) create mode 100644 database/factories/CommitGifFactory.php diff --git a/database/factories/CommitGifFactory.php b/database/factories/CommitGifFactory.php new file mode 100644 index 000000000..4e9c147c2 --- /dev/null +++ b/database/factories/CommitGifFactory.php @@ -0,0 +1,34 @@ + + */ +final class CommitGifFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'commit_id' => Commit::factory(), + 'provider' => GifProvider::Giphy->value, + 'provider_gif_id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ]; + } +} diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 740a36d2c..21cdb6835 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -6,6 +6,7 @@ use App\Enums\CommitStatus; use App\Enums\GifProvider; use App\Models\Commit; +use App\Models\CommitGif; use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\User; @@ -486,3 +487,11 @@ expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); }); + +it('creates its gif through the factory', function (): void { + $gif = CommitGif::factory()->create(); + + expect($gif->provider)->toBe(GifProvider::Giphy) + ->and($gif->commit)->toBeInstanceOf(Commit::class) + ->and($gif->toPayload()['id'])->toBe('xT9IgzoKnwFNmISR8I'); +}); From 9d8764096cd4ebffaecd29d34b7d38f7c46e5f80 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 20:00:31 -0400 Subject: [PATCH 66/83] fix: handle keyed collections when eager loading gifs --- app/Queries/CommitBuilder.php | 12 +++--------- tests/Unit/Queries/CommitBuilderTest.php | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 0c3f47d97..acb04c66c 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -63,7 +63,7 @@ public function getModels($columns = ['*']): array } /** - * Only a commit loaded with every column can be serialized, which is the only time the gif is read. + * Only a commit with its id can be serialized, which is the only time the gif is read. * * @param array $models * @return array @@ -71,8 +71,9 @@ public function getModels($columns = ['*']): array public function eagerLoadRelations(array $models): array { $eagerLoads = $this->getEagerLoads(); + $first = array_first($models); - if ($models !== [] && ! $this->hasEveryColumn($models[0])) { + if ($first instanceof Commit && ! array_key_exists('id', $first->getAttributes())) { $this->without('gif'); } @@ -344,13 +345,6 @@ private function isEveryColumn(Expression|string $column): bool && ($column === $this->qualifyColumn('*') || ($column === '*' && $this->query->joins === null)); } - private function hasEveryColumn(Commit $commit): bool - { - $attributes = $commit->getAttributes(); - - return array_all(self::COLUMNS_BUT_EMBEDDING, fn (string $column): bool => array_key_exists($column, $attributes)); - } - /** * @param HasMany $relation * @return HasMany diff --git a/tests/Unit/Queries/CommitBuilderTest.php b/tests/Unit/Queries/CommitBuilderTest.php index 0bf0b3559..00f60de0c 100644 --- a/tests/Unit/Queries/CommitBuilderTest.php +++ b/tests/Unit/Queries/CommitBuilderTest.php @@ -207,3 +207,17 @@ expect($commit->continuation_id)->toBeNull() ->and($commit->replies_count)->toBe(0); }); + +it('eager loads gifs on a keyed commit collection', function (): void { + $commits = Commit::factory()->withGif()->count(3)->create(); + + $keyed = Commit::query() + ->whereIn('id', $commits->pluck('id')) + ->get() + ->keyBy('id'); + + $loaded = $keyed->load('user'); + + expect($loaded)->toHaveCount(3) + ->and($loaded->every(fn (Commit $commit): bool => $commit->relationLoaded('gif')))->toBeTrue(); +}); From ee0af0c52cca4520ef03670b39a64b476a50146f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 20:12:27 -0400 Subject: [PATCH 67/83] fix: keep partial commit queries free of gif eager loads --- app/Queries/CommitBuilder.php | 13 ++++++++++--- tests/Unit/Queries/CommitBuilderTest.php | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index acb04c66c..17ef469a9 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -65,7 +65,7 @@ public function getModels($columns = ['*']): array /** * Only a commit with its id can be serialized, which is the only time the gif is read. * - * @param array $models + * @param array $models * @return array */ public function eagerLoadRelations(array $models): array @@ -73,12 +73,12 @@ public function eagerLoadRelations(array $models): array $eagerLoads = $this->getEagerLoads(); $first = array_first($models); - if ($first instanceof Commit && ! array_key_exists('id', $first->getAttributes())) { + if ($first instanceof Commit && ! $this->hasEveryColumn($first)) { $this->without('gif'); } try { - return parent::eagerLoadRelations($models); + return parent::eagerLoadRelations(array_values($models)); } finally { $this->setEagerLoads($eagerLoads); } @@ -339,6 +339,13 @@ private function whetherViewerHasRow(QueryBuilder $rows, string $commitColumn, s /** * A bare * only stands for the commit's columns when no other table is joined in. */ + private function hasEveryColumn(Commit $commit): bool + { + $attributes = $commit->getAttributes(); + + return array_all(self::COLUMNS_BUT_EMBEDDING, fn (string $column): bool => array_key_exists($column, $attributes)); + } + private function isEveryColumn(Expression|string $column): bool { return $this->query->from === $this->getModel()->getTable() diff --git a/tests/Unit/Queries/CommitBuilderTest.php b/tests/Unit/Queries/CommitBuilderTest.php index 00f60de0c..94097a055 100644 --- a/tests/Unit/Queries/CommitBuilderTest.php +++ b/tests/Unit/Queries/CommitBuilderTest.php @@ -213,6 +213,7 @@ $keyed = Commit::query() ->whereIn('id', $commits->pluck('id')) + ->without('gif') ->get() ->keyBy('id'); From 99fa27c0b3283b591bdbbd044b828599eff5483f Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 19 Oct 2026 21:29:02 -0400 Subject: [PATCH 68/83] fix: close the gif picker's remaining review nits --- app/Http/Requests/CreateCommitReplyRequest.php | 11 +---------- app/Http/Requests/CreateCommitRequest.php | 12 ++++++++++++ resources/js/components/commit-composer.tsx | 8 +++----- resources/js/components/commit-thread.tsx | 4 ++-- resources/js/lib/gif-providers/index.ts | 15 +++++++++++++++ resources/js/types/commit.ts | 4 ++-- .../Controllers/CommitReplyControllerTest.php | 11 +++++++++++ 7 files changed, 46 insertions(+), 19 deletions(-) diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index 7eaaaac3c..0205d156c 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -9,8 +9,6 @@ use App\Enums\GifProvider; use App\Models\Commit; use App\Models\User; -use App\Rules\GifMediaUrl; -use App\Rules\GifPageUrl; use App\Services\Urls; use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\ValidationRule; @@ -65,14 +63,7 @@ public function rules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], - 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], - 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], - 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], - 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifPageUrl($provider)], - 'gif_title' => ['nullable', 'string', 'max:300'], - 'gif_username' => ['nullable', 'string', 'max:100'], - 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], - 'gif_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], + ...CreateCommitRequest::gifRules($provider), ]; } diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index cc4871f85..aa44d0da8 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -67,6 +67,18 @@ public static function attachmentRules(GifProvider $provider = GifProvider::Giph ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], + ...self::gifRules($provider), + ]; + } + + /** + * Shared by top-level commits and replies; replies have no poll field, but gif_id still prohibits one. + * + * @return array> + */ + public static function gifRules(GifProvider $provider = GifProvider::Giphy): array + { + return [ 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index be15a3df9..2cd689c8e 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -53,7 +53,7 @@ import { } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; -import { gifFormFields } from '@/lib/gif-providers'; +import { gifFormFields, toCommitGif } from '@/lib/gif-providers'; import type { GifAttachment } from '@/lib/gif-providers'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { @@ -294,9 +294,7 @@ function CommitComposerBody({ {preview.isPreviewing && stash.gif !== null && ( - + )} {issue.hint && ( @@ -666,7 +664,7 @@ export function CommitComposer({ if (attachments.images.length > 0 && stash.gif !== null) { stash.updateGif(null); } - }, [attachments.images.length, stash.gif, stash.updateGif]); + }, [attachments.images.length, stash]); const onKeyDown = (event: KeyboardEvent) => { if ( diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 6708cfa25..904ef6354 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -66,7 +66,7 @@ import { useUser } from '@/hooks/use-user'; import { signIn } from '@/lib/auth'; import { maxBodyLength, minBodyLength } from '@/lib/commit'; import { commitStateKey } from '@/lib/commit-state'; -import { gifFormFields } from '@/lib/gif-providers'; +import { gifFormFields, toCommitGif } from '@/lib/gif-providers'; import type { GifProviderId } from '@/lib/gif-providers'; import { hasModKey } from '@/lib/keyboard'; import { @@ -1330,7 +1330,7 @@ function ReplyComposer({ )} {preview.isPreviewing && gif !== null && ( - + )} { diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 7cb02b308..b48e77192 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -55,8 +55,8 @@ export type CommitGif = { page_url: string; title: string | null; username: string | null; - width: number | null; - height: number | null; + width: number; + height: number; }; export type BodySegment = diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 31b6a4441..e9b2c2521 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -1041,6 +1041,17 @@ $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); }); +it('rejects a partial gif submission on a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Half a gif', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['gif_url', 'gif_page_url', 'gif_width', 'gif_height']); +}); + it('rejects a reply gif alongside uploaded images', function (): void { Storage::fake(); From 52f4f37d22627b5a13a32493b42a90c4d62173e4 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Tue, 20 Oct 2026 17:51:36 -0400 Subject: [PATCH 69/83] feat: unify composer media actions and add /gif command --- resources/js/components/commit-composer.tsx | 38 +++-- .../js/components/commit-quote-button.tsx | 23 +-- resources/js/components/commit-thread.tsx | 23 +-- .../js/components/composer-media-button.tsx | 151 ++++++++++++++++++ resources/js/components/gif-picker-button.tsx | 55 ------- resources/js/components/issue-command.tsx | 37 +++-- resources/js/lib/issue-commands.ts | 48 +++++- tests/Unit/ComposerDraft.test.tsx | 77 +++++++-- tests/Unit/GifPickerDialog.test.tsx | 54 +++++-- tests/Unit/IssueCommands.test.ts | 26 +++ tests/Unit/QuoteDraft.test.tsx | 14 +- 11 files changed, 415 insertions(+), 131 deletions(-) create mode 100644 resources/js/components/composer-media-button.tsx delete mode 100644 resources/js/components/gif-picker-button.tsx diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 8a1b69c20..da35916c0 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -9,10 +9,10 @@ import { BodyCounter } from '@/components/body-counter'; import { CommitGif } from '@/components/commit-gif'; import { ImageDropOverlay, - ImagePickerButton, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/composer-media-button'; import { ComposerPollPreview, ComposerPreviewBody, @@ -20,7 +20,6 @@ import { useComposerPreview, } from '@/components/composer-preview'; import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; -import { GifPickerButton } from '@/components/gif-picker-button'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import type { UseIssueCommandReturn } from '@/components/issue-command'; @@ -41,6 +40,7 @@ import { import { SecretWarning } from '@/components/secret-warning'; import { StashLabel } from '@/components/stash-label'; import { Button } from '@/components/ui/button'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import type { UseLinkCardReturn } from '@/hooks/use-link-card'; @@ -59,6 +59,7 @@ import { maxRawBodyLength, minBodyLength, } from '@/lib/commit'; +import type { GifProviderKey } from '@/lib/gif-provider-key'; import { gifFormFields, toCommitGif } from '@/lib/gif-providers'; import type { GifAttachment } from '@/lib/gif-providers'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; @@ -112,6 +113,9 @@ function CommitComposerBody({ errors, onKeyDown, drop, + gifKey, + gifOpen, + onGifOpenChange, }: { id: string; isInDialog: boolean; @@ -129,6 +133,9 @@ function CommitComposerBody({ errors: Record; onKeyDown: (event: KeyboardEvent) => void; drop: () => void; + gifKey: GifProviderKey | null; + gifOpen: boolean; + onGifOpenChange: (open: boolean) => void; }) { const keys = useKeyLabels(); const { body, isRestored, updateBody } = stash; @@ -440,26 +447,22 @@ function CommitComposerBody({
{!isIssue && ( <> - { + onPickImages={(files) => { stash.updateGif(null); return attachments.pick(files); }} + onPickGif={pickGif} className="-ml-2" /> - {!isScheduling && ( - - )} (null); + const gifKey = useGifProviderKey(user.id); + const [gifOpen, setGifOpen] = useState(false); const secrets = useSecretWarning(body); const isPosting = useRef(false); @@ -654,6 +659,8 @@ export function CommitComposer({ poll === null && !isScheduling, hasSecrets: secrets.findings.length > 0, + canAttachGif: gifKey !== null && stash.gif === null, + onAttachGif: () => setGifOpen(true), onCreated: () => { drop(); focusBody(id); @@ -786,6 +793,9 @@ export function CommitComposer({ errors={errors} onKeyDown={onKeyDown} drop={drop} + gifKey={gifKey} + gifOpen={gifOpen} + onGifOpenChange={setGifOpen} /> )} diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index f9a787c3d..0bcb5b601 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -7,12 +7,11 @@ import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; import { ImageDropOverlay, - ImagePickerButton, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/composer-media-button'; import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; -import { GifPickerButton } from '@/components/gif-picker-button'; import InputError from '@/components/input-error'; import { MentionTextarea } from '@/components/mention-textarea'; import { QuotedCommit } from '@/components/quoted-commit'; @@ -27,6 +26,7 @@ import { } from '@/components/ui/dialog'; import { UserAvatar } from '@/components/user-avatar'; import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources'; +import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useSecretWarning } from '@/hooks/use-secret-warning'; import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; @@ -115,6 +115,8 @@ function QuoteComposer({ onClose: () => void; }) { const { user } = useUser(); + const gifKey = useGifProviderKey(user.id); + const [gifOpen, setGifOpen] = useState(false); const markCommitDeleted = useMarkCommitDeleted(); const id = `quote-${commit.id}`; const { @@ -300,24 +302,23 @@ function QuoteComposer({
- { + onPickImages={(files) => { updateGif(null); return attachments.pick(files); }} - className="size-8" - /> - { + onPickGif={(picked) => { attachments.clear(); updateGif(picked); }} - className="-ml-2 size-8" + className="size-8" /> {isRestored && ( void; }) { const { user } = useUser(); + const gifKey = useGifProviderKey(user.id); + const [gifOpen, setGifOpen] = useState(false); const commitNotFound = useCommitNotFound(); const { body, @@ -1439,24 +1441,23 @@ function ReplyComposer({ />
- { + onPickImages={(files) => { updateGif(null); return attachments.pick(files); }} - className="size-8" - /> - { + onPickGif={(picked) => { attachments.clear(); updateGif(picked); }} - className="-ml-2 size-8" + className="size-8" /> void; + hasGif: boolean; + gifDisabled?: boolean; + count: number; + disabled?: boolean; + reading?: boolean; + onPickImages: (files: File[]) => Promise; + onPickGif: (gif: GifAttachment) => void; + className?: string; +}) { + const inputRef = useRef(null); + const canAddImages = !disabled && !reading && count < maxImages; + const openImages = () => { + if (canAddImages) { + inputRef.current?.click(); + } + }; + + return ( + <> + { + const input = event.currentTarget; + const files = Array.from(input.files ?? []); + // Allow the same selection again even if this read is discarded. + input.value = ''; + if (files.length === 0) return; + + await onPickImages(files); + }} + data-test="commit-image-input" + /> + {gifKey === null ? ( + + + + ) : ( + + + + + + + + { + openImages(); + }} + > + + Upload image + + { + onGifOpenChange(true); + }} + > + + {hasGif ? 'One GIF per commit' : 'Choose a GIF'} + + + + )} + {gifKey !== null && ( + + )} + + ); +} diff --git a/resources/js/components/gif-picker-button.tsx b/resources/js/components/gif-picker-button.tsx deleted file mode 100644 index 3640eb9d9..000000000 --- a/resources/js/components/gif-picker-button.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { usePage } from '@inertiajs/react'; -import { ImagePlay } from 'lucide-react'; -import { useState } from 'react'; -import { GifPickerDialog } from '@/components/gif-picker-dialog'; -import { Button } from '@/components/ui/button'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; -import { gifProviderFor } from '@/lib/gif-providers'; -import type { GifAttachment } from '@/lib/gif-providers'; -import { cn } from '@/lib/utils'; - -/** Invisible until the signed-in user stores a GIF provider key in settings. */ -export function GifPickerButton({ - disabled, - hasGif, - onPick, - className, -}: { - disabled?: boolean; - hasGif: boolean; - onPick: (gif: GifAttachment) => void; - className?: string; -}) { - const { auth } = usePage().props; - const entry = useGifProviderKey(auth.user?.id ?? null); - const [open, setOpen] = useState(false); - - if (entry === null) { - return null; - } - - return ( - <> - - - - ); -} diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index bf14fbaba..3acbab6ef 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -1,12 +1,17 @@ import { useHttp, usePage } from '@inertiajs/react'; -import { Bug, Lightbulb, X } from 'lucide-react'; +import { Bug, ImagePlay, Lightbulb, X } from 'lucide-react'; import type { KeyboardEvent } from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; import { store } from '@/actions/App/Http/Controllers/IssueController'; import { collectEnvironment } from '@/lib/environment'; import type { IssueEnvironment } from '@/lib/environment'; -import type { IssueCommand, IssueKind } from '@/lib/issue-commands'; +import type { + IssueCommand, + IssueKind, + SlashCommand, + SlashCommandKind, +} from '@/lib/issue-commands'; import { hintedCommand, issueCommands, @@ -34,12 +39,16 @@ export function useIssueCommand({ updateBody, enabled, hasSecrets, + canAttachGif = false, + onAttachGif, onCreated, }: { body: string; updateBody: (body: string) => void; enabled: boolean; hasSecrets: boolean; + canAttachGif?: boolean; + onAttachGif?: () => void; onCreated: () => void; }) { const [kind, setKind] = useState(null); @@ -56,20 +65,30 @@ export function useIssueCommand({ // A draft restored from the stash can be a lone slash: the menu only opens while the user is typing in it. const suggestions = enabled && kind === null && !dismissed && focused - ? suggestedCommands(body) + ? suggestedCommands(body, { canAttachGif }) : []; const hint = enabled && kind === null && !hintDismissed ? hintedCommand(body) : null; - const start = (next: IssueKind, rest: string) => { - setKind(next); + const start = (next: SlashCommandKind, rest: string) => { setSelected(0); + + if (next === 'gif') { + onAttachGif?.(); + updateBody(''); + return; + } + + setKind(next); updateBody(rest); }; const change = (value: string) => { setDismissed(false); - const typed = enabled && kind === null ? typedCommand(value) : null; + const typed = + enabled && kind === null + ? typedCommand(value, { canAttachGif }) + : null; if (typed) { start(typed.kind, typed.body); @@ -222,7 +241,7 @@ export function useIssueCommand({ export type UseIssueCommandReturn = ReturnType; -const icons = { feature: Lightbulb, bug: Bug }; +const icons = { feature: Lightbulb, bug: Bug, gif: ImagePlay }; export function IssueCommandBadge({ command, @@ -261,9 +280,9 @@ export function IssueCommandSuggestions({ selected, onPick, }: { - suggestions: IssueCommand[]; + suggestions: SlashCommand[]; selected: number; - onPick: (kind: IssueKind) => void; + onPick: (kind: SlashCommandKind) => void; }) { return (
command.name.startsWith(body)) + ? availableCommands(options).filter((command) => + command.name.startsWith(body), + ) : []; } /** - * A complete command followed by a space or a new line turns the draft into an issue, without the command. + * A complete command followed by a space or a new line. Issue commands keep the rest of the draft as their body; + * `/gif` only matches on its own so it can never discard text. */ export function typedCommand( body: string, -): { kind: IssueKind; body: string } | null { + { canAttachGif = false }: CommandOptions = {}, +): { kind: SlashCommandKind; body: string } | null { + if (canAttachGif && /^\/gif\s+$/.test(body)) { + return { kind: 'gif', body: '' }; + } + const match = /^\/(feature|bug)\s/.exec(body); return match diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index 2f86db123..a2a3c0fca 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -5,6 +5,7 @@ import type { ReactElement, ReactNode } from 'react'; import { beforeEach, test, vi } from 'vite-plus/test'; import { cancelImageReads } from '../../resources/js/lib/draft-image-reads'; import type { GifAttachment } from '../../resources/js/lib/gif-providers/types'; +import type { GifProviderKey } from '../../resources/js/lib/gif-provider-key'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); const gif: GifAttachment = { @@ -97,6 +98,10 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ moveCaretToEnd: vi.fn(), discardSeriesStash: vi.fn(), })); +let gifKey: GifProviderKey | null = null; +vi.doMock('../../resources/js/hooks/use-gif-provider-key', () => ({ + useGifProviderKey: () => gifKey, +})); const { CommitComposer } = await import('../../resources/js/components/commit-composer'); @@ -106,8 +111,8 @@ const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); -const { GifPickerButton } = - await import('../../resources/js/components/gif-picker-button'); +const { ComposerMediaButton } = + await import('../../resources/js/components/composer-media-button'); const { ComposerPreviewBody, PreviewToggle } = await import('../../resources/js/components/composer-preview'); const { MentionTextarea } = @@ -155,6 +160,7 @@ beforeEach(() => { draft.images = []; draft.descriptions = []; draft.gif = null; + gifKey = null; }); test('preserves restored images and link cards and allows scheduling them', () => { @@ -175,7 +181,7 @@ test('preserves restored images and link cards and allows scheduling them', () = )!; assert.deepEqual(preview.props.images, [image]); const picker = fields.find( - (element) => element.type === ImagePickerButton, + (element) => element.type === ComposerMediaButton, )!; assert.equal(picker.props.count, 1); const schedule = fields.find( @@ -279,7 +285,7 @@ test('removing restored images enables polls and successful posting resets the p )!; assert.equal(submit().props.disabled, true); assert.equal( - view.fields.find((element) => element.type === ImagePickerButton)! + view.fields.find((element) => element.type === ComposerMediaButton)! .props.disabled, true, ); @@ -400,7 +406,7 @@ test('schedules in UTC with pasted images and the chosen link card', () => { scheduled_at: scheduledAt, }); assert.equal( - view.fields.some((element) => element.type === ImagePickerButton), + view.fields.some((element) => element.type === ComposerMediaButton), true, ); assert.equal( @@ -511,9 +517,9 @@ test('picking a gif clears attached images and any poll and submits the gif fiel true, ); const gifButton = view.fields.find( - (element) => element.type === GifPickerButton, + (element) => element.type === ComposerMediaButton, )!; - (gifButton.props.onPick as (picked: GifAttachment) => void)(gif); + (gifButton.props.onPickGif as (picked: GifAttachment) => void)(gif); assert.deepEqual(draft.images, []); assert.deepEqual(draft.descriptions, []); assert.equal(draft.gif, gif); @@ -556,8 +562,8 @@ test('attaching images while a gif is stashed clears the gif and submits the ima true, ); const picked = ( - view.fields.find((element) => element.type === ImagePickerButton)!.props - .onPick as (files: File[]) => Promise + view.fields.find((element) => element.type === ComposerMediaButton)! + .props.onPickImages as (files: File[]) => Promise )([image]); picked.catch(() => undefined); assert.equal(draft.gif, null); @@ -702,7 +708,7 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.ok(find('data-test', 'open-issue-button')); assert.equal(find('data-test', 'create-commit-button'), undefined); assert.equal( - view.fields.some((element) => element.type === ImagePickerButton), + view.fields.some((element) => element.type === ComposerMediaButton), false, ); assert.equal(find('data-test', 'add-poll-button'), undefined); @@ -722,6 +728,57 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.equal(view.form.props.onBefore(), true); }); +test('a lone /gif opens the GIF picker without becoming an issue draft', () => { + gifKey = { provider: 'giphy', key: 'test-key' }; + draft.body = '/'; + focus(render()); + const view = render(); + const suggestions = view.fields.find( + (element) => element.type === IssueCommandSuggestions, + )!; + assert.deepEqual( + (suggestions.props.suggestions as { name: string }[]).map( + (command) => command.name, + ), + ['/feature', '/bug', '/gif'], + ); + + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + (textarea.props.onValueChange as (value: string) => void)('/gif '); + + assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['']); + + const next = render(); + assert.equal( + next.fields.find((element) => element.type === ComposerMediaButton)! + .props.gifOpen, + true, + ); + assert.equal( + next.fields.some((element) => element.type === IssueCommandBadge), + false, + ); +}); + +test('a stashed gif removes /gif from the slash suggestions', () => { + gifKey = { provider: 'giphy', key: 'test-key' }; + draft.gif = gif; + draft.body = '/'; + focus(render()); + const view = render(); + const suggestions = view.fields.find( + (element) => element.type === IssueCommandSuggestions, + )!; + assert.deepEqual( + (suggestions.props.suggestions as { name: string }[]).map( + (command) => command.name, + ), + ['/feature', '/bug'], + ); +}); + test('picking a suggestion with Enter starts an empty issue, and the shortcut opens it instead of committing', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/140.0.0.0', diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index 704d00312..ce9f884f6 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -83,11 +83,8 @@ const provider: GifProvider = { const { GifPickerDialog } = await import( '../../resources/js/components/gif-picker-dialog' ); -const { GifPickerButton } = await import( - '../../resources/js/components/gif-picker-button' -); -const { writeGifProviderKey } = await import( - '../../resources/js/lib/gif-provider-key' +const { ComposerMediaButton } = await import( + '../../resources/js/components/composer-media-button' ); const { gifProviderFor } = await import( '../../resources/js/lib/gif-providers' @@ -265,15 +262,38 @@ test('loads more with the cursor from the previous page', async () => { assert.ok(options?.signal instanceof AbortSignal); }); -test('renders nothing when no gif provider key is stored', () => { - assert.equal(GifPickerButton({ hasGif: false, onPick: vi.fn() }), null); -}); +test('renders no GIF dialog without a provider key and keeps the image picker working', () => { + const fields = elements( + ComposerMediaButton({ + gifKey: null, + gifOpen: false, + onGifOpenChange: vi.fn(), + hasGif: false, + count: 0, + onPickImages: vi.fn(async () => {}), + onPickGif: vi.fn(), + }) as ReactElement, + ); -test('opens the picker with the stored provider and key', () => { - writeGifProviderKey('user-1', { provider: 'klipy', key: 'stored-key' }); + assert.equal( + fields.some((element) => element.props.apiKey !== undefined), + false, + ); + assert.ok(field(fields, 'attach-images-button')); +}); +test('opens the picker with the stored provider and key through one media trigger', () => { + const onGifOpenChange = vi.fn(); const fields = elements( - GifPickerButton({ hasGif: false, onPick: vi.fn() }) as ReactElement, + ComposerMediaButton({ + gifKey: { provider: 'klipy', key: 'stored-key' }, + gifOpen: false, + onGifOpenChange, + hasGif: false, + count: 0, + onPickImages: vi.fn(async () => {}), + onPickGif: vi.fn(), + }) as ReactElement, ); const dialog = fields.find( (element) => element.props.apiKey === 'stored-key', @@ -282,5 +302,17 @@ test('opens the picker with the stored provider and key', () => { assert.ok(dialog); assert.equal(dialog.props.provider, gifProviderFor('klipy')); assert.equal(dialog.props.open, false); + assert.ok(field(fields, 'attach-media-button')); + assert.ok(field(fields, 'attach-images-button')); assert.ok(field(fields, 'attach-gif-button')); + + const preventDefault = vi.fn(); + ( + field(fields, 'attach-gif-button').props.onSelect as (event: { + preventDefault: () => void; + }) => void + )({ preventDefault }); + + assert.deepEqual(onGifOpenChange.mock.calls, [[true]]); + assert.equal(preventDefault.mock.calls.length, 0); }); diff --git a/tests/Unit/IssueCommands.test.ts b/tests/Unit/IssueCommands.test.ts index 82f553b67..79fcf6916 100644 --- a/tests/Unit/IssueCommands.test.ts +++ b/tests/Unit/IssueCommands.test.ts @@ -46,3 +46,29 @@ test('hints the command a plain draft mentions', () => { assert.equal(name('/bug already a command'), null); assert.equal(name('Shipped the parser'), null); }); + +test('suggests /gif only when a GIF provider key is stored', () => { + const names = (body: string, canAttachGif = false) => + suggestedCommands(body, { canAttachGif }).map( + (command) => command.name, + ); + + assert.deepEqual(names('/'), ['/feature', '/bug']); + assert.deepEqual(names('/', true), ['/feature', '/bug', '/gif']); + assert.deepEqual(names('/g', true), ['/gif']); + assert.deepEqual(names('/g', false), []); +}); + +test('a lone /gif command attaches a GIF without discarding typed text', () => { + assert.deepEqual(typedCommand('/gif ', { canAttachGif: true }), { + kind: 'gif', + body: '', + }); + assert.deepEqual(typedCommand('/gif\n', { canAttachGif: true }), { + kind: 'gif', + body: '', + }); + assert.equal(typedCommand('/gif', { canAttachGif: true }), null); + assert.equal(typedCommand('/gif cats ', { canAttachGif: true }), null); + assert.equal(typedCommand('/gif ', { canAttachGif: false }), null); +}); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 8e58eb113..a396bc183 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -51,8 +51,12 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ const { Form } = await import('@inertiajs/react'); const { CommitQuoteButton } = await import('../../resources/js/components/commit-quote-button'); -const { ImagePickerButton, ImagePickerPreviews } = - await import('../../resources/js/components/commit-image-picker'); +const { ComposerMediaButton } = await import( + '../../resources/js/components/composer-media-button' +); +const { ImagePickerPreviews } = await import( + '../../resources/js/components/commit-image-picker' +); const { StashLabel } = await import('../../resources/js/components/stash-label'); @@ -127,14 +131,16 @@ test('quotes submit restored images and clear the complete draft only on success assert.deepEqual(previews.props.images, [image]); const picker = fields.find( - (element) => element.type === ImagePickerButton, + (element) => element.type === ComposerMediaButton, )!; assert.equal(picker.props.count, 1); const added = new File(['new'], 'new.png', { type: 'image/png', lastModified: 1, }); - await (picker.props.onPick as (files: File[]) => Promise)([added]); + await (picker.props.onPickImages as (files: File[]) => Promise)([ + added, + ]); const [pickedImages, descriptions] = updateImages.mock.lastCall as [ File[], string[], From 0d7fa7ceaac5831ae4c72a8ff02a8967ef96f5cb Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 16:40:36 -0400 Subject: [PATCH 70/83] docs: add gif provider safety design --- .../2026-10-22-gif-provider-safety-design.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md diff --git a/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md b/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md new file mode 100644 index 000000000..f9029ab02 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md @@ -0,0 +1,65 @@ +# GIF provider safety design + +## Goal + +Reduce explicit and NSFW GIFs reaching `/dev/null` through the GIF picker without adding +server-side moderation. Filtering is provider-side and client-side, so it is best-effort: a +crafted request can still attach any provider-hosted GIF URL. + +## Behavior + +- GIPHY keeps the existing `rating=g` request parameter. Each returned result must also carry a + per-result `rating` of exactly `g`. Missing or other ratings (`pg`, `pg-13`, `r`, `y`) are + dropped. +- KLIPY keeps the existing `contentfilter=high` request parameter. KLIPY exposes no per-result + rating, so its results pass the rating check and rely on the provider filter. +- Both providers drop results whose title, description, or username matches a curated NSFW + keyword list. Matching is case-insensitive and word-boundary based. +- Filtering runs during provider result normalization, before results reach the picker. +- Pagination still uses the raw provider page size, so filtering never changes the next cursor. +- If a page filters down to zero results but another page exists, the picker shows its empty + message with a `Load more` button instead of a dead end. + +## Keyword list + +A small curated list lives in one module: explicit sexual terms (`porn`, `pornography`, `nsfw`, +`xxx`, `hentai`, `nudity`, `nude`, `naked`, `erotic`, `sex`), extreme gore (`gore`, `gory`), and +hard drugs (`cocaine`, `heroin`, `methamphetamine`). The list is a safety net, not a content +classifier. + +## Architecture + +- New `resources/js/lib/gif-providers/safety.ts`: + - `isSafeGiphyRating(rating: string | undefined): boolean` accepts only `g`. + - `isBlockedGifText(...texts: Array): boolean` normalizes case and + matches whole words against the blocklist. +- `resources/js/lib/gif-providers/giphy.ts`: + - Add `rating?: string` to the response item type. + - `normalize()` returns `null` for unsafe or missing ratings and for blocked text. +- `resources/js/lib/gif-providers/klipy.ts`: + - `normalize()` returns `null` for blocked title or content description. +- `resources/js/components/gif-picker-dialog.tsx`: + - Keep `Load more` reachable when a page filters to zero results and `nextCursor` is not null. +- No server, database, or API changes. + +## Testing + +- Extend `tests/Unit/GifProviders.test.ts`: + - GIPHY keeps a `g`-rated result and drops `pg`, `r`, and missing ratings. + - Blocked terms drop GIPHY and KLIPY results case-insensitively. + - Cursors still derive from raw page sizes after filtering. + - Update existing fixtures to include `rating: 'g'`. +- Extend `tests/Unit/GifPickerDialog*`: + - A zero-result page with another cursor still offers `Load more`. +- Run `bun run test:unit`, `bun run test:types`, and `bun run test:lint`. + +## Documentation + +Add one sentence to the README GIF section: results are filtered by GIPHY's per-result rating +and a keyword list, KLIPY relies on `contentfilter=high`, and filtering remains best-effort. + +## Limitations + +- KLIPY has no per-result rating, so its protection depends entirely on `contentfilter=high`. +- A crafted API request can attach any provider-hosted `.gif` URL and bypass the picker. +- Provider ratings and keyword matching cannot guarantee that no NSFW content ever appears. From 9a6d1270e0ad83cb987eed8be340eb7f841a7d47 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:01:40 -0400 Subject: [PATCH 71/83] feat: add gif provider safety helpers --- resources/js/lib/gif-providers/safety.ts | 31 ++++++++++++++++++++++++ tests/Unit/GifSafety.test.ts | 25 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 resources/js/lib/gif-providers/safety.ts create mode 100644 tests/Unit/GifSafety.test.ts diff --git a/resources/js/lib/gif-providers/safety.ts b/resources/js/lib/gif-providers/safety.ts new file mode 100644 index 000000000..98d6074f4 --- /dev/null +++ b/resources/js/lib/gif-providers/safety.ts @@ -0,0 +1,31 @@ +const blockedTerms = [ + 'porn', + 'pornography', + 'nsfw', + 'xxx', + 'hentai', + 'nudity', + 'nude', + 'naked', + 'erotic', + 'sex', + 'gore', + 'gory', + 'cocaine', + 'heroin', + 'methamphetamine', +]; + +const blockedPattern = new RegExp(`\\b(?:${blockedTerms.join('|')})\\b`, 'i'); + +export function isSafeGiphyRating(rating: string | undefined): boolean { + return rating?.toLowerCase() === 'g'; +} + +export function isBlockedGifText( + ...texts: Array +): boolean { + return texts.some( + (text) => typeof text === 'string' && blockedPattern.test(text), + ); +} diff --git a/tests/Unit/GifSafety.test.ts b/tests/Unit/GifSafety.test.ts new file mode 100644 index 000000000..42f4ca5f8 --- /dev/null +++ b/tests/Unit/GifSafety.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + isBlockedGifText, + isSafeGiphyRating, +} from '../../resources/js/lib/gif-providers/safety.ts'; + +test('only g-rated giphy results are safe', () => { + assert.equal(isSafeGiphyRating('g'), true); + assert.equal(isSafeGiphyRating('G'), true); + assert.equal(isSafeGiphyRating('pg'), false); + assert.equal(isSafeGiphyRating('pg-13'), false); + assert.equal(isSafeGiphyRating('r'), false); + assert.equal(isSafeGiphyRating('y'), false); + assert.equal(isSafeGiphyRating(undefined), false); +}); + +test('blocked words match whole words in any case', () => { + assert.equal(isBlockedGifText('PORN star'), true); + assert.equal(isBlockedGifText('Essex'), false); + assert.equal(isBlockedGifText(null, 'Hello', 'NSFW'), true); + assert.equal(isBlockedGifText('gory scene'), true); + assert.equal(isBlockedGifText('cocaine'), true); + assert.equal(isBlockedGifText('hello greeting'), false); +}); From 8e75f1ba65b86c35ac82d894ad2d3dc298327e27 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:06:33 -0400 Subject: [PATCH 72/83] feat: drop unsafe giphy results --- resources/js/lib/gif-providers/giphy.ts | 9 ++++ tests/Unit/GifProviders.test.ts | 65 +++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/resources/js/lib/gif-providers/giphy.ts b/resources/js/lib/gif-providers/giphy.ts index 9abd26422..8f40a8134 100644 --- a/resources/js/lib/gif-providers/giphy.ts +++ b/resources/js/lib/gif-providers/giphy.ts @@ -1,3 +1,4 @@ +import { isBlockedGifText, isSafeGiphyRating } from './safety.ts'; import type { GifKeyValidation, GifProvider, @@ -12,6 +13,7 @@ type GiphyItem = { id: string; title: string; username: string; + rating?: string; url: string; images: Partial< Record< @@ -36,6 +38,13 @@ const params = (key: string, offset: string | null): URLSearchParams => }); const normalize = (item: GiphyItem): GifResult | null => { + if ( + !isSafeGiphyRating(item.rating) || + isBlockedGifText(item.title, item.username) + ) { + return null; + } + const stored = item.images.downsized_medium ?? item.images.downsized ?? diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index f92cc24bc..f85d80f01 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -31,6 +31,7 @@ const giphyPayload = { id: 'xT9IgzoKnwFNmISR8I', title: 'Deal With It GIF', username: 'giphy', + rating: 'g', url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', images: { fixed_height_small: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', width: '178', height: '100' }, @@ -88,6 +89,7 @@ test('giphy derives the next cursor from a full page and passes it back as offse id: `gif-${index}`, title: `GIF ${index}`, username: 'giphy', + rating: 'g', url: `https://giphy.com/gifs/gif-${index}`, images: { downsized_medium: { @@ -163,6 +165,7 @@ test('adapters drop items without a usable rendition', async () => { id: 'no-renditions', title: 'Nothing', username: '', + rating: 'g', url: 'https://giphy.com/gifs/nothing', images: {}, }], @@ -262,6 +265,7 @@ test('giphy falls back across stored renditions and previews', async () => { id: 'gif', title: 'GIF', username: 'giphy', + rating: 'g', url: 'https://giphy.com/gifs/gif', images, }], @@ -286,6 +290,7 @@ test('giphy normalizes empty titles and usernames to null', async () => { id: 'gif', title: '', username: '', + rating: 'g', url: 'https://giphy.com/gifs/gif', images: { original: rendition('original') }, }], @@ -301,6 +306,66 @@ test('giphy normalizes empty titles and usernames to null', async () => { } }); +test('giphy drops results without a safe rating', async () => { + const item = { + id: 'gif', + title: 'GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/gif', + images: { original: rendition('original') }, + }; + + for (const [rating, expected] of [ + ['g', 1], + ['pg', 0], + ['pg-13', 0], + ['r', 0], + ['y', 0], + [undefined, 0], + ] as const) { + stubFetch({ data: [{ ...item, rating }] }); + + try { + const page = await giphy.search('key-123', 'gif', { + cursor: null, + }); + + assert.equal(page.results.length, expected, String(rating)); + } finally { + restoreFetch(); + } + } +}); + +test('giphy drops blocked titles and usernames', async () => { + const item = (id: string, title: string, username: string) => ({ + id, + title, + username, + rating: 'g', + url: `https://giphy.com/gifs/${id}`, + images: { original: rendition(id) }, + }); + + stubFetch({ + data: [ + item('blocked-title', 'PORN star', 'giphy'), + item('blocked-username', 'Hello', 'xxx'), + item('safe', 'Hello', 'giphy'), + ], + }); + + try { + const page = await giphy.search('key-123', 'hello', { + cursor: null, + }); + + assert.deepEqual(page.results.map((result) => result.id), ['safe']); + } finally { + restoreFetch(); + } +}); + test('klipy falls back to the content description for its title', async () => { const cases: { name: string; From d9ee564472a23b8c526adabdf40a1dbfd57aad81 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:09:36 -0400 Subject: [PATCH 73/83] feat: drop blocked klipy results --- resources/js/lib/gif-providers/klipy.ts | 5 ++++ tests/Unit/GifProviders.test.ts | 38 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/resources/js/lib/gif-providers/klipy.ts b/resources/js/lib/gif-providers/klipy.ts index 7db0ab839..0527c5603 100644 --- a/resources/js/lib/gif-providers/klipy.ts +++ b/resources/js/lib/gif-providers/klipy.ts @@ -1,3 +1,4 @@ +import { isBlockedGifText } from './safety.ts'; import type { GifKeyValidation, GifProvider, @@ -36,6 +37,10 @@ const params = (key: string, cursor: string | null): URLSearchParams => { }; const normalize = (item: KlipyItem): GifResult | null => { + if (isBlockedGifText(item.title, item.content_description)) { + return null; + } + const gif = item.media_formats?.gif; if (gif === undefined) { diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index f85d80f01..0a4334320 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -366,6 +366,44 @@ test('giphy drops blocked titles and usernames', async () => { } }); +test('klipy drops blocked titles and descriptions', async () => { + const item = ( + id: string, + title: string, + description: string, + ) => ({ + id, + title, + content_description: description, + itemurl: `https://klipy.com/gifs/${id}`, + media_formats: { + gif: { + url: `https://static.klipy.com/ii/${id}.gif`, + dims: [498, 498] as [number, number], + }, + }, + }); + + stubFetch({ + results: [ + item('blocked-title', 'PORN', ''), + item('blocked-description', 'Hello', 'gory scene'), + item('safe', 'Hello', 'hello greeting'), + ], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { + cursor: null, + }); + + assert.deepEqual(page.results.map((result) => result.id), ['safe']); + } finally { + restoreFetch(); + } +}); + test('klipy falls back to the content description for its title', async () => { const cases: { name: string; From 7f786a116b6e62251e9eff1e935420d17baa3672 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:14:31 -0400 Subject: [PATCH 74/83] feat: keep gif load more reachable after filtering --- resources/js/components/gif-picker-dialog.tsx | 35 +++++++++++-------- tests/Unit/GifPickerDialog.test.tsx | 25 +++++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/resources/js/components/gif-picker-dialog.tsx b/resources/js/components/gif-picker-dialog.tsx index ef715abeb..bbd348bf9 100644 --- a/resources/js/components/gif-picker-dialog.tsx +++ b/resources/js/components/gif-picker-dialog.tsx @@ -150,6 +150,19 @@ export function GifPickerDialog({ setQuery(''); }; + const loadMoreButton = nextCursor !== null && ( + + ); + return ( @@ -198,9 +211,12 @@ export function GifPickerDialog({
)} {status === 'ready' && results.length === 0 && ( -

- No GIFs matched. /dev/null remains empty. -

+ <> +

+ No GIFs matched. /dev/null remains empty. +

+ {loadMoreButton} + )} {status === 'ready' && results.length > 0 && ( <> @@ -233,18 +249,7 @@ export function GifPickerDialog({ ), )} - {nextCursor !== null && ( - - )} + {loadMoreButton} )}
diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index ce9f884f6..9948b35fd 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -262,6 +262,31 @@ test('loads more with the cursor from the previous page', async () => { assert.ok(options?.signal instanceof AbortSignal); }); +test('offers load more when a page filters to no results', async () => { + trending.mockImplementation( + async (_key: string, options: { cursor: string | null }) => + options.cursor === null + ? { results: [], nextCursor: 'next-page' } + : { results: [result], nextCursor: null }, + ); + + render(); + await settle(); + + const fields = render(); + + assert.ok( + fields.some( + (element) => + element.props.children === + 'No GIFs matched. /dev/null remains empty.', + ), + ); + assert.ok( + fields.some((element) => element.props.children === 'Load more'), + ); +}); + test('renders no GIF dialog without a provider key and keeps the image picker working', () => { const fields = elements( ComposerMediaButton({ From e4e64518de6e9ac3876996cf48c05707061780b2 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:19:25 -0400 Subject: [PATCH 75/83] docs: explain gif result filtering --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c784d513f..42e781c91 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. +GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. Results are filtered by GIPHY's per-result rating and a keyword list, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. ### Verify installation From 742f6ac348f4e55779e8757f2335667175698351 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:28:40 -0400 Subject: [PATCH 76/83] test: cover gif cursor after filtering Clarify in the README that both GIF providers apply the keyword list. --- README.md | 2 +- tests/Unit/GifProviders.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42e781c91..48c198677 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. Results are filtered by GIPHY's per-result rating and a keyword list, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. +GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. Both providers apply a keyword list; GIPHY also requires a per-result rating of `g`, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. ### Verify installation diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index 0a4334320..a317bbc53 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -202,6 +202,34 @@ const rendition = (slug: string) => ({ height: '270', }); +test('giphy derives the next cursor from the raw page size after filtering', async () => { + const safe = (index: number) => ({ + id: `gif-${index}`, + title: `GIF ${index}`, + username: 'giphy', + rating: 'g', + url: `https://giphy.com/gifs/gif-${index}`, + images: { original: rendition(`gif-${index}`) }, + }); + + stubFetch({ + data: Array.from({ length: 24 }, (_, index) => + index === 0 + ? { ...safe(index), rating: 'r' } + : safe(index), + ), + }); + + try { + const page = await giphy.search('key-123', 'gif', { cursor: null }); + + assert.equal(page.results.length, 23); + assert.equal(page.nextCursor, '24'); + } finally { + restoreFetch(); + } +}); + test('giphy falls back across stored renditions and previews', async () => { const cases: { name: string; From 7b9f8b2ce6e84dcb2368369abc0187c109659117 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 17:47:32 -0400 Subject: [PATCH 77/83] chore: remove superpowers planning docs --- .../2026-10-22-gif-provider-safety-design.md | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md diff --git a/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md b/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md deleted file mode 100644 index f9029ab02..000000000 --- a/docs/superpowers/specs/2026-10-22-gif-provider-safety-design.md +++ /dev/null @@ -1,65 +0,0 @@ -# GIF provider safety design - -## Goal - -Reduce explicit and NSFW GIFs reaching `/dev/null` through the GIF picker without adding -server-side moderation. Filtering is provider-side and client-side, so it is best-effort: a -crafted request can still attach any provider-hosted GIF URL. - -## Behavior - -- GIPHY keeps the existing `rating=g` request parameter. Each returned result must also carry a - per-result `rating` of exactly `g`. Missing or other ratings (`pg`, `pg-13`, `r`, `y`) are - dropped. -- KLIPY keeps the existing `contentfilter=high` request parameter. KLIPY exposes no per-result - rating, so its results pass the rating check and rely on the provider filter. -- Both providers drop results whose title, description, or username matches a curated NSFW - keyword list. Matching is case-insensitive and word-boundary based. -- Filtering runs during provider result normalization, before results reach the picker. -- Pagination still uses the raw provider page size, so filtering never changes the next cursor. -- If a page filters down to zero results but another page exists, the picker shows its empty - message with a `Load more` button instead of a dead end. - -## Keyword list - -A small curated list lives in one module: explicit sexual terms (`porn`, `pornography`, `nsfw`, -`xxx`, `hentai`, `nudity`, `nude`, `naked`, `erotic`, `sex`), extreme gore (`gore`, `gory`), and -hard drugs (`cocaine`, `heroin`, `methamphetamine`). The list is a safety net, not a content -classifier. - -## Architecture - -- New `resources/js/lib/gif-providers/safety.ts`: - - `isSafeGiphyRating(rating: string | undefined): boolean` accepts only `g`. - - `isBlockedGifText(...texts: Array): boolean` normalizes case and - matches whole words against the blocklist. -- `resources/js/lib/gif-providers/giphy.ts`: - - Add `rating?: string` to the response item type. - - `normalize()` returns `null` for unsafe or missing ratings and for blocked text. -- `resources/js/lib/gif-providers/klipy.ts`: - - `normalize()` returns `null` for blocked title or content description. -- `resources/js/components/gif-picker-dialog.tsx`: - - Keep `Load more` reachable when a page filters to zero results and `nextCursor` is not null. -- No server, database, or API changes. - -## Testing - -- Extend `tests/Unit/GifProviders.test.ts`: - - GIPHY keeps a `g`-rated result and drops `pg`, `r`, and missing ratings. - - Blocked terms drop GIPHY and KLIPY results case-insensitively. - - Cursors still derive from raw page sizes after filtering. - - Update existing fixtures to include `rating: 'g'`. -- Extend `tests/Unit/GifPickerDialog*`: - - A zero-result page with another cursor still offers `Load more`. -- Run `bun run test:unit`, `bun run test:types`, and `bun run test:lint`. - -## Documentation - -Add one sentence to the README GIF section: results are filtered by GIPHY's per-result rating -and a keyword list, KLIPY relies on `contentfilter=high`, and filtering remains best-effort. - -## Limitations - -- KLIPY has no per-result rating, so its protection depends entirely on `contentfilter=high`. -- A crafted API request can attach any provider-hosted `.gif` URL and bypass the picker. -- Provider ratings and keyword matching cannot guarantee that no NSFW content ever appears. From 8c3bb64bfe3c4c227654876dc788c5eb98bfb792 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 22 Oct 2026 19:37:43 -0400 Subject: [PATCH 78/83] chore: sync bun lockfile with upstream deps --- bun.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 1c854a343..5ea6fcf74 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "laravel-vite-plugin": "^3.2.0", - "lucide-react": "^1.60.0", + "lucide-react": "^1.61.0", "react": "^19.3.1", "react-dom": "^19.3.1", "sonner": "^2.0.8", @@ -39,7 +39,7 @@ "@laravel/vite-plugin-wayfinder": "^0.1.11", "@rolldown/plugin-babel": "^0.2.4", "@types/node": "^26.6.5", - "fontaine": "^1.0.1", + "fontaine": "^1.0.2", "npm-check-updates": "^23.1.1", "playwright": "^1.64.1", "tw-animate-css": "^1.4.0", @@ -594,7 +594,7 @@ "flru": ["flru@1.0.2", "", {}, "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog=="], - "fontaine": ["fontaine@1.0.1", "", { "dependencies": { "@capsizecss/unpack": "^4.0.0", "css-tree": "^3.1.0", "magic-string": "^1.0.0", "unplugin": "^3.0.0" }, "peerDependencies": { "postcss": "^8.4.31" }, "optionalPeers": ["postcss"] }, "sha512-XSIphoSciha92BmOqgZrHF86OWmaQ/yZPqtonfuYB9MSQwF4xb3DO1zrIdXBbRQUUiCrXZK0nRCV/Wxkcst7Zg=="], + "fontaine": ["fontaine@1.0.2", "", { "dependencies": { "@capsizecss/unpack": "^4.0.0", "css-tree": "^3.1.0", "magic-string": "^1.0.0", "unplugin": "^3.0.0" }, "peerDependencies": { "postcss": "^8.4.31" }, "optionalPeers": ["postcss"] }, "sha512-YY/MYPLntxg87jSKZ0C+zrj2Qtl4J66amAnvTEKC/IBIJ0JDYDpOiQxupp6l0I+EFUTQ0Dpf9LejybVKxqo//g=="], "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], @@ -654,7 +654,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "lucide-react": ["lucide-react@1.60.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA=="], + "lucide-react": ["lucide-react@1.61.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], From 2ebe9ba64184d4bd1578fb7acd46f7f41f0b1f70 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 26 Oct 2026 19:32:07 -0400 Subject: [PATCH 79/83] fix: moderate gif first frames and document gif API fields --- README.md | 8 +++--- app/Actions/UpdateCommitAiDecision.php | 36 +++++++++++++++++++++++++- tests/Unit/Jobs/ModerateCommitTest.php | 15 ++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 48c198677..127bdda1c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Twitch requires the embedding site's host, which is taken from `APP_URL`. To add ### GIFs -GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is downloaded or rehosted. Connect one provider at a time; choosing another replaces it. Both providers apply a keyword list; GIPHY also requires a per-result rating of `g`, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. +GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://partner.klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is rehosted; the server briefly fetches the GIF to run moderation. Connect one provider at a time; choosing another replaces it. Both providers apply a keyword list; GIPHY also requires a per-result rating of `g`, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. ### Verify installation @@ -86,7 +86,7 @@ Read-only, no authentication, JSON, versioned under `/api/v1`. Only approved com | `GET /api/v1/users/{username}/commits` | The user's top-level commits, newest first, 20 per page (`?replies=1` for their replies instead) | | `GET /api/v1/users/{username}/followers` | The people following the user, newest follow first, 30 per page | | `GET /api/v1/users/{username}/following` | The people the user follows, newest follow first, 30 per page | -| `GET /api/v1/commits/{id}` | One commit with its author, public parent id, star and reply counts, link preview (with its `embed` player, if any), and images (`id`, `url`, `width`, `height`) | +| `GET /api/v1/commits/{id}` | One commit with its author, public parent id, star and reply counts, link preview (with its `embed` player, if any), images (`id`, `url`, `width`, `height`), and its `gif` (`provider`, `id`, `url`, `page_url`, `title`, `username`, `width`, `height`) when one is attached | Lists are cursor-paginated: follow `links.next` (or pass `meta.next_cursor` as `?cursor=`) until it is `null`. @@ -94,13 +94,13 @@ Lists are cursor-paginated: follow `links.next` (or pass `meta.next_cursor` as ` curl -s https://dev-null.sh/api/v1/users/nunomaduro/commits | jq '.data[] | {short_hash, body}' ``` -Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images as enclosures, cached for five minutes. +Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images and GIFs as enclosures, cached for five minutes. ## Signed requests The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting and pastéis only take signed requests. Signed requests are limited to 120 a minute per user. -Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. +Commits in a signed response carry what the web's cards show: the same `gif` object as the public response, `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. In a signed list of followers or following, each person says `is_followed`, whether the user follows them, and `follows_you`. diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 2f61a83be..b9f17af45 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -8,7 +8,11 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Services\OpenAI; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; +use RuntimeException; +use Throwable; final readonly class UpdateCommitAiDecision { @@ -17,6 +21,10 @@ */ private const float AUTO_REJECT_THRESHOLD = 0.95; + private const int GIF_MAX_BYTES = 20 * 1024 * 1024; + + private const int GIF_MAX_DIMENSION = 1600; + public function __construct( private OpenAI $openAI, private UpdateCommitStatuses $updateCommitStatuses, @@ -36,7 +44,7 @@ public function handle(Commit $commit): void )->all()); if ($commit->gif !== null) { - $images[] = $commit->gif->url; + $images[] = $this->gifForModeration($commit->gif->url); } $scores = $this->openAI->moderate($commit->body, $images); @@ -60,4 +68,30 @@ public function handle(Commit $commit): void $this->updateCommitStatuses->handle([$commit->id], CommitStatus::Approved, onlyPending: true); } } + + /** + * The moderation endpoint only accepts non-animated GIFs of at most 20 MB, while providers serve animated ones. + * Sending their first frame as WebP keeps the check on the image itself instead of relying on the browser filter. + */ + private function gifForModeration(string $url): string + { + $response = Http::timeout(10)->withoutRedirecting()->get($url)->throw(); + $body = $response->body(); + + if (mb_strlen($body) > self::GIF_MAX_BYTES) { + throw new RuntimeException('The GIF is too large to moderate.'); + } + + try { + $webp = Image::fromBytes($body) + ->usingGd() + ->scale(self::GIF_MAX_DIMENSION, self::GIF_MAX_DIMENSION) + ->optimize('webp', 60) + ->toBytes(); + } catch (Throwable $throwable) { + throw new RuntimeException('The GIF could not be prepared for moderation.', $throwable->getCode(), previous: $throwable); + } + + return 'data:image/webp;base64,'.base64_encode($webp); + } } diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index d557bf9d6..36c8a2fb9 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -25,6 +25,11 @@ config(['ai.providers.openai.key' => 'sk-test']); }); +function tinyGif(): string +{ + return base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); +} + /** * @param list $flagged * @param array $scores @@ -414,8 +419,9 @@ function reviewResponses(array $responses = []): Closure })->and($commit->refresh())->ai_decision->toBeNull()->status->toBe(CommitStatus::Pending); }); -it('moderates an attached gif by its giphy url', function (): void { +it('moderates an attached giphy gif by its first frame', function (): void { Http::fake([ + 'media2.giphy.com/*' => Http::response(tinyGif()), 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), 'api.openai.com/v1/responses' => reviewResponses(), ]); @@ -425,11 +431,12 @@ function reviewResponses(array $responses = []): Closure dispatch_sync(new ModerateCommit($commit)); Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') - && str_contains($request->body(), 'media2.giphy.com')); + && str_contains($request->body(), 'data:image')); }); -it('moderates an attached klipy gif by its url', function (): void { +it('moderates an attached klipy gif by its first frame', function (): void { Http::fake([ + 'static.klipy.com/*' => Http::response(tinyGif()), 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), 'api.openai.com/v1/responses' => reviewResponses(), ]); @@ -439,7 +446,7 @@ function reviewResponses(array $responses = []): Closure dispatch_sync(new ModerateCommit($commit)); Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') - && str_contains($request->body(), 'static.klipy.com')); + && str_contains($request->body(), 'data:image')); }); it('keeps unavailable moderation pending and recovers on a later queued attempt', function (bool $recovers): void { From ecd4ce71d23f7aab934fe019a46e7835044db064 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Mon, 26 Oct 2026 19:34:34 -0400 Subject: [PATCH 80/83] docs: list the API and signature rate limits --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 127bdda1c..13f53183b 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `403` | `own_commit` | The user tried to report their own commit. | | `404` | `commit_unavailable` | The commit to report doesn't exist, was deleted, isn't published, or is across a block. | | `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. | -| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 60 pastéis a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | +| `429` | `rate_limited` | Over 120 requests a minute for a signed-in user, 60 requests a minute per IP otherwise, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 60 pastéis a minute, one profile sync a minute, or 60 failed signature attempts a minute from one IP. `Retry-After` says when to try again. | | `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. | Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for a commit that doesn't exist, except when reporting it. Unsigned requests to the public endpoints get the same errors as before, without codes. From 6c14b1ee3aff6c01b05f710750ae08088ee7a0eb Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Tue, 27 Oct 2026 18:09:35 -0400 Subject: [PATCH 81/83] chore: consolidate gif modules and tests --- README.md | 260 +----------- app/Http/Requests/CreateCommitRequest.php | 7 +- app/Rules/GifPageUrl.php | 38 -- app/Rules/{GifMediaUrl.php => GifUrl.php} | 25 +- resources/js/components/commit-composer.tsx | 7 +- resources/js/components/commit-gif.tsx | 41 ++ .../js/components/commit-image-picker.tsx | 147 ++++++- .../js/components/commit-quote-button.tsx | 6 +- resources/js/components/commit-thread.tsx | 7 +- .../js/components/composer-media-button.tsx | 151 ------- .../js/components/gif-attachment-preview.tsx | 41 -- resources/js/components/gif-key-setting.tsx | 234 ----------- resources/js/hooks/use-gif-provider-key.ts | 26 -- resources/js/hooks/use-stash.ts | 4 +- resources/js/lib/gif-provider-key.ts | 54 ++- resources/js/lib/gif-providers.ts | 391 ++++++++++++++++++ resources/js/lib/gif-providers/giphy.ts | 144 ------- resources/js/lib/gif-providers/index.ts | 56 --- resources/js/lib/gif-providers/klipy.ts | 127 ------ resources/js/lib/gif-providers/safety.ts | 31 -- resources/js/lib/gif-providers/types.ts | 48 --- resources/js/lib/gif-storage.ts | 32 -- resources/js/pages/user-profile/edit.tsx | 232 ++++++++++- tests/Unit/CommitGifRender.test.ts | 18 - tests/Unit/ComposerDraft.test.tsx | 6 +- tests/Unit/Enums/GifProviderTest.php | 40 -- tests/Unit/GifKeySetting.test.tsx | 2 +- tests/Unit/GifKeySettingRender.test.ts | 18 - tests/Unit/GifPickerDialog.test.tsx | 4 +- tests/Unit/GifPickerDialogRender.test.ts | 18 - tests/Unit/GifProviderKey.test.ts | 42 ++ tests/Unit/GifProviders.test.ts | 29 +- tests/Unit/GifSafety.test.ts | 25 -- tests/Unit/GifStorage.test.ts | 55 --- tests/Unit/QuoteDraft.test.tsx | 2 +- tests/Unit/Rules/GifMediaUrlTest.php | 38 -- tests/Unit/Rules/GifPageUrlTest.php | 36 -- tests/Unit/Rules/GifUrlTest.php | 102 +++++ tests/commit-gif.config.ts | 13 - tests/gif-key.config.ts | 13 - tests/gif-picker.config.ts | 13 - tests/tsconfig.json | 5 +- 42 files changed, 1070 insertions(+), 1518 deletions(-) delete mode 100644 app/Rules/GifPageUrl.php rename app/Rules/{GifMediaUrl.php => GifUrl.php} (54%) delete mode 100644 resources/js/components/composer-media-button.tsx delete mode 100644 resources/js/components/gif-attachment-preview.tsx delete mode 100644 resources/js/components/gif-key-setting.tsx delete mode 100644 resources/js/hooks/use-gif-provider-key.ts create mode 100644 resources/js/lib/gif-providers.ts delete mode 100644 resources/js/lib/gif-providers/giphy.ts delete mode 100644 resources/js/lib/gif-providers/index.ts delete mode 100644 resources/js/lib/gif-providers/klipy.ts delete mode 100644 resources/js/lib/gif-providers/safety.ts delete mode 100644 resources/js/lib/gif-providers/types.ts delete mode 100644 resources/js/lib/gif-storage.ts delete mode 100644 tests/Unit/CommitGifRender.test.ts delete mode 100644 tests/Unit/Enums/GifProviderTest.php delete mode 100644 tests/Unit/GifKeySettingRender.test.ts delete mode 100644 tests/Unit/GifPickerDialogRender.test.ts delete mode 100644 tests/Unit/GifSafety.test.ts delete mode 100644 tests/Unit/GifStorage.test.ts delete mode 100644 tests/Unit/Rules/GifMediaUrlTest.php delete mode 100644 tests/Unit/Rules/GifPageUrlTest.php create mode 100644 tests/Unit/Rules/GifUrlTest.php delete mode 100644 tests/commit-gif.config.ts delete mode 100644 tests/gif-key.config.ts delete mode 100644 tests/gif-picker.config.ts diff --git a/README.md b/README.md index 7b9f7da45..c9dbe385d 100644 --- a/README.md +++ b/README.md @@ -29,266 +29,10 @@ composer setup composer dev ``` -The setup script installs dependencies, creates `.env`, generates an app key, runs migrations, and builds assets. - -### Local sign-in - -With `APP_ENV=local`, run `composer dev`, visit `http://localhost:8000` (or your configured `APP_URL`), and sign in: a modal asks for a username and signs you in as that user, creating it if it doesn't exist yet. GitHub isn't involved at all. The routes are only registered in the local environment, see `routes/web.php`. - -### GitHub login - -Outside the local environment, users sign in with GitHub. Create a [GitHub OAuth app](https://github.com/settings/developers) with the callback URL `{APP_URL}/auth/github/callback`, then set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` in `.env`. - -### Issue commands - -The `/feature` and `/bug` commands below the composer open an issue on `dev-null-sh/dev-null.sh` from the draft, as a GitHub App crediting the reporter. No user grants any extra permission and no user token is stored. Create a [GitHub App](https://github.com/settings/apps/new) with webhooks disabled and only the **Issues: Read and write** repository permission, install it on `dev-null-sh/dev-null.sh` alone, generate a private key, then set `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` (the PEM contents, with newlines written as `\n`) in `.env`. Each installation token is requested for issues on this repository only and is cached, encrypted, for 50 minutes. - -### Embeds - -A link card for a video, song or pen becomes a player that loads when clicked. The player URL is built on the server from the link, so only the sites below can be framed. None of them need an API key. - -| Key | Links | -|---|---| -| `youtube` | `youtube.com/watch`, `youtu.be`, `/shorts`, `/live`, `/playlist` (with `t=` start times) | -| `vimeo` | `vimeo.com/{id}`, unlisted `vimeo.com/{id}/{hash}`, channels and groups | -| `loom` | `loom.com/share/{id}` | -| `twitch` | channels, `/videos/{id}` and clips | -| `suno` | `suno.com/song/{id}` | -| `spotify` | tracks, albums, playlists, artists, episodes and shows | -| `soundcloud` | tracks and sets | -| `apple_music` | albums, playlists and songs | -| `codepen` | pens | -| `asciinema` | recordings | - -| Variable | Default | | -|---|---|---| -| `EMBEDS_ENABLED` | `true` | `false` turns every link back into a plain link card | -| `EMBEDS_PROVIDERS` | empty (all) | Comma-separated keys from the table above, such as `youtube,suno,spotify` | -| `EMBEDS_YOUTUBE_PRIVACY_ENHANCED` | `true` | Plays YouTube from `youtube-nocookie.com` | - -Twitch requires the embedding site's host, which is taken from `APP_URL`. To add a site, implement `App\Contracts\Services\EmbedProvider` and register it in `config/embeds.php`. - -### GIFs - -GIF search is bring-your-own-key and stays in your browser: connect one provider — [GIPHY](https://developers.giphy.com) or [KLIPY](https://partner.klipy.com) — under Settings → Profile → GIFs and paste its API key. The key is stored only in this browser (`localStorage`), never sent to the server or saved in the database; each device needs its own, and clearing site data removes it. Searches run on your key and quota, so no paid key is shared by the instance; without one the picker is hidden. The browser talks to the provider directly and commits store only the GIF's URLs and metadata — nothing is rehosted; the server briefly fetches the GIF to run moderation. Connect one provider at a time; choosing another replaces it. Both providers apply a keyword list; GIPHY also requires a per-result rating of `g`, while KLIPY relies on its `contentfilter=high` setting. Filtering is best-effort and doesn't cover GIF URLs attached outside the picker. - -### Verify installation - -Run `composer test` to check linting, static analysis, type coverage, and tests. The code coverage check requires Xdebug or PCOV. - -## Public API - -Read-only, no authentication, JSON, versioned under `/api/v1`. Only approved commits are returned; pending, rejected, and deleted commits are a 404 or left out. Rate limited to 60 requests a minute per IP, and open to cross-origin `GET` requests. - -| Endpoint | Returns | -|---|---| -| `GET /api/v1/users/{username}` | A public profile with commit, follower, and following counts | -| `GET /api/v1/users/{username}/commits` | The user's top-level commits, newest first, 20 per page (`?replies=1` for their replies instead) | -| `GET /api/v1/users/{username}/followers` | The people following the user, newest follow first, 30 per page | -| `GET /api/v1/users/{username}/following` | The people the user follows, newest follow first, 30 per page | -| `GET /api/v1/commits/{id}` | One commit with its author, public parent id, star and reply counts, link preview (with its `embed` player, if any), images (`id`, `url`, `width`, `height`), and its `gif` (`provider`, `id`, `url`, `page_url`, `title`, `username`, `width`, `height`) when one is attached | - -Lists are cursor-paginated: follow `links.next` (or pass `meta.next_cursor` as `?cursor=`) until it is `null`. - -```bash -curl -s https://dev-null.sh/api/v1/users/nunomaduro/commits | jq '.data[] | {short_hash, body}' -``` - -Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images and GIFs as enclosures, cached for five minutes. - -## Signed requests - -The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, saved commits, committing, deleting, following, starring, saving, muting, blocking, reporting and pastéis only take signed requests. Signed requests are limited to 120 a minute per user. - -Commits in a signed response carry what the web's cards show: the same `gif` object as the public response, `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. - -A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. In a signed list of followers or following, each person says `is_followed`, whether the user follows them, and `follows_you`. - -`POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page. - -`GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it says `is_ranked`. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, which is always 0 on Trending, since the web offers none there. An empty Following log comes with a few `suggestions`. - -`GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `none`, or a comma-separated list of `replies`, `quotes`, `stars`, `mentions`, `follows` and `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it. - -`POST /api/v1/commits` takes a `body`, and either a `parent_id` to reply to a commit or a `quoted_commit_id` to quote one, but not both. Returns a `201` on successful commit creation. - -`POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. - -`GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. - -`DELETE /api/v1/me` deletes the signed-in user's account the way Delete account does on the web, with their commits and every device they approved. It answers `204`, and from then on the key gets `device_not_approved`. - -Each request sends three headers: - -| Header | Value | -|---|---| -| `X-Devnull-Timestamp` | Unix time in seconds, within 5 minutes of the server's clock | -| `X-Devnull-Nonce` | 32 random hex characters, used once | -| `X-SSH-Signature` | The SSHSIG signature, base64 on one line | - -The signature is made with `ssh-keygen -Y sign -n devnull-api` over these lines, joined by `\n`: the timestamp, the nonce, the method, the path with its query string exactly as sent, and the raw body. A body goes as JSON, with `Content-Type: application/json`, since the signature covers its bytes but not how they are read. Any other body is refused. - -```text -1760534400 -3f9c2a7e5b1d4c8f9a0b2c3d4e5f6a7b -POST -/api/v1/commits -{"body":"Hello from my phone"} -``` - -A request with any of these headers has to be signed right. A bad or expired signature is a `401`, never the public view. A request without them always gets the public view, and across a block a signed request gets neither profile: it is a `403`, `user_blocked` for the one who blocked and `user_unavailable` for the other, and their commits are hidden as on the web. - -A key signs in once its user approved it as a device. It doesn't have to be on their GitHub account, and being on it isn't enough, so a key made for the GitHub Action can't use the API. Users see their devices, when each was last used, and revoke them under Settings → Devices (`/settings/devices`). A key that isn't approved, or was revoked, gets a `401` with `device_not_approved`, and the app signs in again. - -### Errors - -An error is JSON with a `message` to show the user. The errors the app has to tell apart also have a `code`, which stays the same when the message changes: - -```json -{"message": "This key isn't approved for the /dev/null app. Sign in again in the app.", "code": "device_not_approved"} -``` - -| Status | `code` | When | -|---|---|---| -| `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. | -| `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. | -| `401` | `device_not_approved` | Nobody approved the key as a device, or it was revoked. | -| `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. The error comes with the `user` they blocked, as the web shows them. | -| `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. | -| `403` | `own_commit` | The user tried to report their own commit. | -| `404` | `commit_unavailable` | The commit to report doesn't exist, was deleted, isn't published, or is across a block. | -| `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. | -| `429` | `rate_limited` | Over 120 requests a minute for a signed-in user, 60 requests a minute per IP otherwise, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 5 reports a minute, 60 pastéis a minute, one profile sync a minute, or 60 failed signature attempts a minute from one IP. `Retry-After` says when to try again. | -| `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. | - -Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for a commit that doesn't exist, except when reporting it. Unsigned requests to the public endpoints get the same errors as before, without codes. - -### Signing in - -The app signs in the way an OAuth app does, with the authorization code flow and PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)), and /dev/null as the provider. The code it gets is bound to its key, so only the app that holds the key can redeem it. - -1. The app makes an ed25519 key and a random `code_verifier`, and opens this page in a browser session (`ASWebAuthenticationSession` on iOS): - - ```text - https://dev-null.sh/mobile/authorize - ?fingerprint=SHA256:hPEdU4FVZ1lfjzbPyBhqTWG2qxUu3ZbCnD8mZw9t2Vk - &device_name=devnull%20on%20iPhone - &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM - &code_challenge_method=S256 - &state=af0ifjsldkj - &redirect_uri=devnull://auth/callback - ``` - - `code_challenge` is `base64url(sha256(code_verifier))` without padding. `redirect_uri` has to start with `devnull://`. A guest signs in with GitHub first and comes back to the page. A request with a missing or bad parameter gets an error page and is never redirected. -2. The page asks "Approve devnull on iPhone?", with the key's fingerprint and the account it signs in as. **Approve** redirects (`303`) to `devnull://auth/callback?code=…&state=af0ifjsldkj`. **Cancel** goes to `devnull://auth/callback?error=access_denied&state=af0ifjsldkj`. The app checks that `state` is the one it sent. A code is good for 5 minutes and only once, and each user can approve 10 times a minute. -3. The app redeems the code with `POST /api/v1/devices`, signed with the key like any other request, though the key isn't approved yet: - - ```json - {"code": "…", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"} - ``` - - The key becomes an approved device of the user who approved it, and the answer is `200` with the user, like `GET /api/v1/me`. From then on every request it signs is theirs, until they revoke the device. - -| Status | `code` | Means | -|---|---|---| -| `200` | | The device is approved. The body is the user, with `unread_notifications_count`. | -| `400` | `invalid_grant` | The code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. Sign in again. | -| `401` | `invalid_signature` | The signature is bad, expired or used already. | -| `422` | | `code` or `code_verifier` is missing, or the verifier isn't 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`. | -| `429` | `rate_limited` | Over 12 tries a minute for one key, or 60 from one IP, counted before signatures are checked. `Retry-After` says when to try again. | - -## Available Tooling - -### Development -- `composer dev` - Starts Laravel server, queue worker, log monitoring, and Vite+ dev server concurrently - -### Code Quality -- `composer lint` - Runs Rector (refactoring), Pint (PHP formatting), and Oxfmt (JS/TS formatting) -- `composer test:lint` - Dry-run mode for CI/CD pipelines - -### Testing -- `composer test:type-coverage` - Ensures 100% type coverage with Pest -- `composer test:types` - Runs PHPStan at the configured maximum level and TypeScript checks -- `composer test:unit` - Runs Pest tests with 100% code coverage requirement -- `composer test` - Runs the complete test suite (linting, static analysis, type coverage, unit tests) - -### Maintenance -- `composer update:requirements` - Updates the PHP and frontend dependency version constraints - -### Semantic commit search - -Commit search generates and caches query embeddings with the Laravel AI SDK, using the existing `OPENAI_API_KEY`. The provider, model, and dimensions are explicitly kept compatible with stored embeddings, so no re-embedding or database migration is needed. Query embeddings are cached for one hour; result visibility and relevance are evaluated on every search. - -PostgreSQL cosine ranking, visibility filters, and the existing OpenAI relevance classifier are unchanged. Batch ingestion continues through the existing OpenAI service to preserve response-index ordering and count validation. Moderation is unchanged. After installing dependencies, run `php artisan boost:update` to refresh agent guidance and the AI SDK skill (run `php artisan boost:install` first if Boost is not configured locally). - -### Knowledge base embeddings - -After running the database migrations and configuring `OPENAI_API_KEY`, import the Markdown knowledge base: - -```bash -php artisan knowledge-base:embed -``` - -The command defaults to `KNOWLEDGE_BASE.md`. Pass another repository-relative Markdown path to import a separate file. - -Each non-empty section becomes a row in `knowledge_base_sections`, storing its source path, position, heading hierarchy, Markdown content, and vector embedding. All heading levels create boundaries; parent headings accompany child sections in the embedding input. Empty headings create no rows, and headings inside code blocks or blockquotes do not split sections. - -Rerun the command after editing a file. It uses the existing OpenAI service to embed only new or changed sections, reusing vectors when both content and heading hierarchy are unchanged, even if a section moved. It atomically updates or inserts rows and removes sections no longer present. Other files are left alone; a failed import preserves the previous data. The reported count is the number of newly generated embeddings. Unchanged imports make no paid requests. Keep sections small enough for the embedding model's input limit; there is no additional size-based splitting. Tom uses this indexed knowledge through his search tool. - -#### Internal semantic search - -Inject `App\Queries\KnowledgeBaseSearchQuery` into an internal caller: - -```php -$sections = $knowledgeBaseSearchQuery->get('Who is Tom?', limit: 5); -``` - -The query uses the Laravel AI SDK to generate query embeddings, with the same OpenAI provider, model, and dimensions as ingestion and commit search. It searches all imported sources using PostgreSQL cosine distance and returns a collection of sections, closest first, with `id`, `source`, `position`, `headings`, Markdown `content`, and a numeric `distance` (lower is closer). Ties sort by source and position. Embedding vectors are not included in the results. - -The default limit is five. Blank queries and nonpositive limits return no results without calling OpenAI; short terms such as `AI` are supported. The SDK caches trimmed query embeddings for an hour and shares compatible cached queries with commit search, but section results are read fresh so imports immediately affect retrieval. Provider errors propagate to the caller rather than looking like an empty knowledge base. - -These are nearest matches, not a guarantee of relevance or a calibrated confidence score. There is no similarity cutoff or post-specific relevance classifier; Tom is instructed to assess whether the returned sections answer the question. The query has no public endpoint. - -#### Tom's public replies - -Set `OPENCODE_API_KEY` alongside `OPENAI_API_KEY`, keep the queue worker running, and index the knowledge base and commits. The existing `@tom` account is identified by its nonnumeric GitHub ID, not merely its username. No new AI conversation tables or migrations are needed. - -Tom's scope is simple friendly chat and answers about the platform or public posts. Greetings, thanks, jokes and small talk need no search. Factual platform and post answers use the search tools; unrelated tasks, general coding help and professional advice are outside his role. - -An approved commit or reply mentioning `@tom` queues a reply. Pending mentions wait for approval. The Laravel AI SDK's classification API evaluates per-tool criteria through OpenAI's Decisions API, then Tom executes the selected tools and answers using DeepSeek V4.1 Flash (`deepseek-v4.1-flash`) through OpenCode Zen's OpenAI-compatible API at `https://opencode.ai/zen/v1`, in non-thinking mode. His five read-only tools are `SearchKnowledgeBase`, `SearchCommits`, `RepositoryInsights`, `SearchRepositoryHistory`, and `CommunityStats`. Commit searches run as a public viewer, never with access to the mention author's pending or rejected posts. Replies go through the normal commit creation, moderation, embedding and notification pipeline. Failures retry without logging provider response bodies; the same mention never gets a second reply, even if the first was deleted. - -Continue by tagging Tom in a reply. He sees at most twelve public ancestor posts on that branch and at most 12,000 characters including the current question, not sibling branches, other threads or private messages. Each turn has at most seven model steps, at most two calls per tool, five commit excerpts per commit search, and three knowledge-base excerpts per knowledge search. He has no general web or write tools. Both OpenCode and OpenAI keys must be configured: if either is absent, new mentions do not queue bot work, and already-queued jobs exit without making provider requests or posting a reply. - -Tom's job timeout is 900 seconds. A conservative outbound request budget is 840 seconds: one classification at up to 40 seconds, seven model steps at up to 40 seconds each, four query embeddings at up to 70 seconds each, two relevance checks at up to 60 seconds each, two repository-insights calls at up to ten 5-second GitHub requests each, and two repository-history calls at up to two 5-second GitHub requests each. Classification, model and embedding budgets include the SDK's one retry and up to ten seconds of backoff. This bound does not rely on cache hits or parallel tool execution. The remaining time allows for database and queue work; normal replies do not wait for this ceiling. - -The database, Redis and Beanstalkd connections default to a 960-second `retry_after`, keeping jobs reserved beyond Tom's timeout. If you override `DB_QUEUE_RETRY_AFTER`, `REDIS_QUEUE_RETRY_AFTER` or `BEANSTALKD_QUEUE_RETRY_AFTER`, keep the value at least 960 seconds; for SQS, configure its visibility timeout to at least 960 seconds. Restart workers after deploying these settings. This reservation applies to every job on the connection, so a worker crash can delay redelivery of other jobs until it expires. - -#### Translation workers and failure recovery - -Background language detection runs on the `translations` queue using the existing `QUEUE_CONNECTION`. Run a separate worker so these jobs cannot occupy the workers processing moderation: - -```sh -php artisan queue:work --queue=translations --sleep=3 --tries=3 --timeout=120 -``` - -Keep the existing default-queue workers running separately. No new environment variable is needed, and do not change the connection's default queue (`DB_QUEUE` or `REDIS_QUEUE`) to `translations`. For local development, run the same additional worker alongside `composer dev`. Explicit Translate requests still generate translations in the HTTP request, not in this queue. - -On Laravel Cloud with database or Redis queues, add a worker cluster from the environment's infrastructure canvas, keep it awake, and add the command above under **Background processes → New background process → Custom worker**, initially with one process. Save and deploy the environment changes before or alongside the code that routes jobs to `translations`. Cloud restarts the workers on deployment. Each configured process runs on every cluster replica. See the [Cloud worker documentation](https://cloud.laravel.com/docs/workers). - -If the environment already uses `QUEUE_CONNECTION=cloud`, create a standard managed queue named `translations` instead, leaving the existing default queue unchanged. Use Pro compute because this job's 120-second timeout exceeds managed Flex's 90-second limit. Do not introduce managed queues just to add this worker: creating the first managed queue changes the environment's default connection and requires the AWS SDK dependency. See the [managed queue documentation](https://cloud.laravel.com/docs/queues). - -Detection and moderation make at most three attempts, with 60-second and 600-second backoffs. Translation allows 35 seconds to obtain its lock and up to 70 seconds for two 30-second provider attempts plus retry delay; its lock lasts 90 seconds after acquisition and its job timeout is 120 seconds. Moderation allows 180 seconds for up to three 15-second image/text checks and two 60-second reviews. Both job timeouts remain below the connection's 960-second reservation. Failed moderation stays pending for human review, never automatically approved. Search logs for `Moderation failed for commit`, `Translation failed for commit`, or `Language detection dispatch failed for commit`; errors retain exception types and codes, not provider messages or response bodies. - -For database/Redis queues, retry individual failed jobs through `/operations` or `php artisan queue:retry `. Existing queued and failed payloads retain their original queue and retry settings, so keep default workers running while those drain. For Cloud managed queues, use Cloud's **Monitoring → Queues** retry controls instead; Artisan failed-job commands do not support that driver. A missing job class still requires workers on the correct release before retrying: application-level catches cannot execute before the job has been deserialized. - -#### Automatic production updates - -The scheduler dispatches the `App\Jobs\EmbedKnowledgeBase` job every hour, so changes to `KNOWLEDGE_BASE.md` are embedded within an hour of being deployed. The job reads the file from the running release and only embeds new or changed sections, so runs with no changes make no OpenAI requests. Only one import per source runs at a time. A failed run is retried by the queue, leaves existing sections untouched, and is picked up again on the next hour. - -Scheduled imports currently cover `KNOWLEDGE_BASE.md` only. If the knowledge base is split into more files, schedule the job for each source. +Run `composer test` before opening a pull request. Commits are attributed to people only: never add Amp, Claude Code, or any other AI tool as a co-author, and keep their names out of commit attribution (author, committer, and trailers). ## License -Licensed under MIT, as declared in `composer.json`. Based on [Nuno Maduro's Laravel starter kit](https://github.com/nunomaduro/laravel-starter-kit-inertia-react). +/dev/null is open-sourced software licensed under the **[MIT license](composer.json)**. diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 4b9f53a49..c7af5e42a 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -12,8 +12,7 @@ use App\Models\Commit; use App\Models\User; use App\Rules\CommitBodyLength; -use App\Rules\GifMediaUrl; -use App\Rules\GifPageUrl; +use App\Rules\GifUrl; use App\Rules\VisibleCommit; use App\Services\CommitBodies; use App\Services\Urls; @@ -85,8 +84,8 @@ public static function gifRules(GifProvider $provider = GifProvider::Giphy): arr return [ 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], - 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifMediaUrl($provider)], - 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifPageUrl($provider)], + 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifUrl($provider)], + 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifUrl($provider, page: true)], 'gif_title' => ['nullable', 'string', 'max:300'], 'gif_username' => ['nullable', 'string', 'max:100'], 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], diff --git a/app/Rules/GifPageUrl.php b/app/Rules/GifPageUrl.php deleted file mode 100644 index eb47dffba..000000000 --- a/app/Rules/GifPageUrl.php +++ /dev/null @@ -1,38 +0,0 @@ -provider->pageHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ) !== [] - && ! isset($parts['port']) - && ! isset($parts['user']) - && ! isset($parts['pass']); - - if (! $isValid) { - $fail(__('The GIF could not be attached.')); - } - } -} diff --git a/app/Rules/GifMediaUrl.php b/app/Rules/GifUrl.php similarity index 54% rename from app/Rules/GifMediaUrl.php rename to app/Rules/GifUrl.php index 22b6a6a5a..7d49d3727 100644 --- a/app/Rules/GifMediaUrl.php +++ b/app/Rules/GifUrl.php @@ -9,13 +9,15 @@ use Illuminate\Contracts\Validation\ValidationRule; /** - * Only https URLs on the provider's media hosts pointing at a .gif rendition. Query strings stay verbatim and - * dot-segments are rejected, so the host boundary holds after browsers normalize the URL. + * Only https URLs on the provider's media hosts pointing at a .gif rendition, or its page hosts when $page is true. + * Query strings stay verbatim and dot-segments are rejected, so the host boundary holds after browsers normalize it. */ -final readonly class GifMediaUrl implements ValidationRule +final readonly class GifUrl implements ValidationRule { - public function __construct(private GifProvider $provider) - { + public function __construct( + private GifProvider $provider, + private bool $page = false, + ) { // } @@ -23,19 +25,24 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { $parts = is_string($value) ? parse_url($value) : false; $host = is_array($parts) ? ($parts['host'] ?? '') : ''; + $patterns = $this->page + ? $this->provider->pageHostPatterns() + : $this->provider->mediaHostPatterns(); $isValid = is_array($parts) && ($parts['scheme'] ?? '') === 'https' && array_filter( - $this->provider->mediaHostPatterns(), + $patterns, fn (string $pattern): bool => preg_match($pattern, $host) === 1, ) !== [] - && ! isset($parts['fragment']) && ! isset($parts['port']) && ! isset($parts['user']) && ! isset($parts['pass']) - && preg_match('#^/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 - && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1; + && ($this->page || ( + ! isset($parts['fragment']) + && preg_match('#^/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 + && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1 + )); if (! $isValid) { $fail(__('The GIF could not be attached.')); diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 666ebf8d3..2c5dd81ce 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -6,21 +6,20 @@ import CommitController from '@/actions/App/Http/Controllers/CommitController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; -import { CommitGif } from '@/components/commit-gif'; +import { CommitGif, GifAttachmentPreview } from '@/components/commit-gif'; import { ImageDropOverlay, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/commit-image-picker'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; -import { ComposerMediaButton } from '@/components/composer-media-button'; import { ComposerPollPreview, ComposerPreviewBody, PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; -import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import type { UseIssueCommandReturn } from '@/components/issue-command'; @@ -40,7 +39,6 @@ import { } from '@/components/schedule-picker'; import { StashLabel } from '@/components/stash-label'; import { Button } from '@/components/ui/button'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import type { UseLinkCardReturn } from '@/hooks/use-link-card'; @@ -58,6 +56,7 @@ import { maxRawBodyLength, minBodyLength, } from '@/lib/commit'; +import { useGifProviderKey } from '@/lib/gif-provider-key'; import type { GifProviderKey } from '@/lib/gif-provider-key'; import { gifFormFields, toCommitGif } from '@/lib/gif-providers'; import type { GifAttachment } from '@/lib/gif-providers'; diff --git a/resources/js/components/commit-gif.tsx b/resources/js/components/commit-gif.tsx index 955460501..453184742 100644 --- a/resources/js/components/commit-gif.tsx +++ b/resources/js/components/commit-gif.tsx @@ -1,4 +1,6 @@ +import { X } from 'lucide-react'; import { gifProviders } from '@/lib/gif-providers'; +import type { GifAttachment } from '@/lib/gif-providers'; import { cn } from '@/lib/utils'; import type { CommitGif } from '@/types/commit'; @@ -55,3 +57,42 @@ export function CommitGif({ ); } + +export function GifAttachmentPreview({ + gif, + onRemove, +}: { + gif: GifAttachment | null; + onRemove: () => void; +}) { + if (gif === null) { + return null; + } + + return ( +
+
+ {gif.title + + + GIF + +
+
+ ); +} diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 760990efa..e09e3ca73 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,9 +1,19 @@ -import { ImagePlus, X } from 'lucide-react'; +import { ImagePlay, ImagePlus, X } from 'lucide-react'; import { useId, useRef, useState } from 'react'; import { ActionTooltip } from '@/components/action-tooltip'; import type { ShownImage } from '@/components/commit-images'; +import { GifPickerDialog } from '@/components/gif-picker-dialog'; import { ImageViewer } from '@/components/image-viewer'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { GifProviderKey } from '@/lib/gif-provider-key'; +import { gifProviderFor } from '@/lib/gif-providers'; +import type { GifAttachment } from '@/lib/gif-providers'; import { acceptedImageTypes, invalidImageMessage, @@ -358,3 +368,138 @@ export function ImagePickerPreviews({
); } + +/** + * One image affordance for the commit composers. With a stored GIF provider key it offers images or a GIF; without + * one it stays the plain image picker, so the menu never has a single dead option. + */ +export function ComposerMediaButton({ + gifKey, + gifOpen, + onGifOpenChange, + hasGif, + gifDisabled = false, + count, + disabled = false, + reading = false, + onPickImages, + onPickGif, + className, +}: { + gifKey: GifProviderKey | null; + gifOpen: boolean; + onGifOpenChange: (open: boolean) => void; + hasGif: boolean; + gifDisabled?: boolean; + count: number; + disabled?: boolean; + reading?: boolean; + onPickImages: (files: File[]) => Promise; + onPickGif: (gif: GifAttachment) => void; + className?: string; +}) { + const inputRef = useRef(null); + const canAddImages = !disabled && !reading && count < maxImages; + const openImages = () => { + if (canAddImages) { + inputRef.current?.click(); + } + }; + + return ( + <> + { + const input = event.currentTarget; + const files = Array.from(input.files ?? []); + // Allow the same selection again even if this read is discarded. + input.value = ''; + if (files.length === 0) return; + + await onPickImages(files); + }} + data-test="commit-image-input" + /> + {gifKey === null ? ( + + + + ) : ( + + + + + + + + { + openImages(); + }} + > + + Upload image + + { + onGifOpenChange(true); + }} + > + + {hasGif ? 'One GIF per commit' : 'Choose a GIF'} + + + + )} + {gifKey !== null && ( + + )} + + ); +} diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index b66d53e3d..52af1d61e 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -5,19 +5,19 @@ import { useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; +import { GifAttachmentPreview } from '@/components/commit-gif'; import { ImageDropOverlay, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/commit-image-picker'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; -import { ComposerMediaButton } from '@/components/composer-media-button'; import { ComposerPreviewBody, PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; -import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; import { MentionTextarea } from '@/components/mention-textarea'; @@ -32,7 +32,6 @@ import { } from '@/components/ui/dialog'; import { UserAvatar } from '@/components/user-avatar'; import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; @@ -44,6 +43,7 @@ import { maxRawBodyLength, minBodyLength, } from '@/lib/commit'; +import { useGifProviderKey } from '@/lib/gif-provider-key'; import { gifFormFields } from '@/lib/gif-providers'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, cn, isRejectedSubmission } from '@/lib/utils'; diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index cb3543ac8..75cd304d9 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -22,12 +22,13 @@ import CommitReplyController from '@/actions/App/Http/Controllers/CommitReplyCon import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; import { FoldedCommitBody } from '@/components/commit-body'; -import { CommitGif } from '@/components/commit-gif'; +import { CommitGif, GifAttachmentPreview } from '@/components/commit-gif'; import { ImageDropOverlay, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/commit-image-picker'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; @@ -35,13 +36,11 @@ import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { useCommitTranslation } from '@/components/commit-translation'; -import { ComposerMediaButton } from '@/components/composer-media-button'; import { ComposerPreviewBody, PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; -import { GifAttachmentPreview } from '@/components/gif-attachment-preview'; import { graphDash, graphStrokeWidth, @@ -71,7 +70,6 @@ import { useCommitNotFound, useIsCommitDeleted, } from '@/hooks/use-deleted-resources'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { focusCommit } from '@/hooks/use-keyboard-shortcuts'; import { useLinkCard } from '@/hooks/use-link-card'; @@ -87,6 +85,7 @@ import { minBodyLength, } from '@/lib/commit'; import { commitStateKey } from '@/lib/commit-state'; +import { useGifProviderKey } from '@/lib/gif-provider-key'; import { gifFormFields, toCommitGif } from '@/lib/gif-providers'; import type { GifProviderId } from '@/lib/gif-providers'; import { hasModKey } from '@/lib/keyboard'; diff --git a/resources/js/components/composer-media-button.tsx b/resources/js/components/composer-media-button.tsx deleted file mode 100644 index 06ef42eed..000000000 --- a/resources/js/components/composer-media-button.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { ImagePlay, ImagePlus } from 'lucide-react'; -import { useRef } from 'react'; -import { ActionTooltip } from '@/components/action-tooltip'; -import { GifPickerDialog } from '@/components/gif-picker-dialog'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import type { GifProviderKey } from '@/lib/gif-provider-key'; -import { gifProviderFor } from '@/lib/gif-providers'; -import type { GifAttachment } from '@/lib/gif-providers'; -import { acceptedImageTypes, maxImages } from '@/lib/read-picked-images'; -import { cn } from '@/lib/utils'; - -/** - * One image affordance for the commit composers. With a stored GIF provider key it offers images or a GIF; without - * one it stays the plain image picker, so the menu never has a single dead option. - */ -export function ComposerMediaButton({ - gifKey, - gifOpen, - onGifOpenChange, - hasGif, - gifDisabled = false, - count, - disabled = false, - reading = false, - onPickImages, - onPickGif, - className, -}: { - gifKey: GifProviderKey | null; - gifOpen: boolean; - onGifOpenChange: (open: boolean) => void; - hasGif: boolean; - gifDisabled?: boolean; - count: number; - disabled?: boolean; - reading?: boolean; - onPickImages: (files: File[]) => Promise; - onPickGif: (gif: GifAttachment) => void; - className?: string; -}) { - const inputRef = useRef(null); - const canAddImages = !disabled && !reading && count < maxImages; - const openImages = () => { - if (canAddImages) { - inputRef.current?.click(); - } - }; - - return ( - <> - { - const input = event.currentTarget; - const files = Array.from(input.files ?? []); - // Allow the same selection again even if this read is discarded. - input.value = ''; - if (files.length === 0) return; - - await onPickImages(files); - }} - data-test="commit-image-input" - /> - {gifKey === null ? ( - - - - ) : ( - - - - - - - - { - openImages(); - }} - > - - Upload image - - { - onGifOpenChange(true); - }} - > - - {hasGif ? 'One GIF per commit' : 'Choose a GIF'} - - - - )} - {gifKey !== null && ( - - )} - - ); -} diff --git a/resources/js/components/gif-attachment-preview.tsx b/resources/js/components/gif-attachment-preview.tsx deleted file mode 100644 index a416f1fea..000000000 --- a/resources/js/components/gif-attachment-preview.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { X } from 'lucide-react'; -import type { GifAttachment } from '@/lib/gif-providers'; - -export function GifAttachmentPreview({ - gif, - onRemove, -}: { - gif: GifAttachment | null; - onRemove: () => void; -}) { - if (gif === null) { - return null; - } - - return ( -
-
- {gif.title - - - GIF - -
-
- ); -} diff --git a/resources/js/components/gif-key-setting.tsx b/resources/js/components/gif-key-setting.tsx deleted file mode 100644 index c7b88f711..000000000 --- a/resources/js/components/gif-key-setting.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Spinner } from '@/components/ui/spinner'; -import { useGifProviderKey } from '@/hooks/use-gif-provider-key'; -import { useUser } from '@/hooks/use-user'; -import { - clearGifProviderKey, - writeGifProviderKey, -} from '@/lib/gif-provider-key'; -import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; -import type { GifProviderId } from '@/lib/gif-providers'; - -const helpHref: Record = { - giphy: 'https://developers.giphy.com', - klipy: 'https://partner.klipy.com', -}; - -export function GifKeySetting() { - const { user } = useUser(); - const entry = useGifProviderKey(user.id); - const [providerId, setProviderId] = useState( - entry?.provider ?? 'klipy', - ); - const [key, setKey] = useState(''); - const [editing, setEditing] = useState(false); - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(null); - const displayProviderId: GifProviderId = - editing || entry === null ? providerId : entry.provider; - const provider = gifProviderFor(displayProviderId); - const lastStored = useRef<{ provider?: GifProviderId; key?: string }>({ - provider: entry?.provider, - key: entry?.key, - }); - - useEffect(() => { - if ( - lastStored.current.provider !== entry?.provider || - lastStored.current.key !== entry?.key - ) { - lastStored.current = { provider: entry?.provider, key: entry?.key }; - setError(null); - } - }, [entry?.provider, entry?.key]); - - const connect = async () => { - if (processing) { - return; - } - - const candidate = key.trim(); - - if (candidate === '') { - return; - } - - if (candidate.length > 255) { - setError('That key is too long.'); - - return; - } - - setProcessing(true); - setError(null); - - const selected = gifProviderFor(providerId); - const verdict = await selected.validateKey(candidate); - - if (verdict === 'invalid') { - setError(`That key was rejected by ${selected.label}.`); - setProcessing(false); - - return; - } - - if (verdict === 'unreachable') { - setError(`Couldn't reach ${selected.label} to verify the key.`); - setProcessing(false); - - return; - } - - if ( - !writeGifProviderKey(user.id, { - provider: providerId, - key: candidate, - }) - ) { - setError("Couldn't save the key in this browser."); - setProcessing(false); - - return; - } - - setKey(''); - setError(null); - setEditing(false); - setProcessing(false); - }; - - const remove = () => { - clearGifProviderKey(user.id); - setError(null); - setEditing(false); - }; - - return ( -
-

- GIFs -

-

- GIF search runs on your own {provider.label} API key, under your - own quota. Get a key at{' '} - - {provider.label} - {' '} - and save it here. Without a key, the picker stays hidden. -

-

- Stored in this browser only — never sent to the server. Each - device needs its own key. -

- {entry !== null && !editing ? ( -
-

- Connected to {gifProviderFor(entry.provider).label}. -

- - -
- ) : ( -
-
- {Object.values(gifProviders).map((candidate) => ( - - ))} -
- -
- setKey(event.target.value)} - placeholder={`Paste your ${provider.label} API key`} - disabled={processing} - autoComplete="off" - spellCheck={false} - className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" - data-test="gif-key-input" - /> - -
-
- )} - {error !== null && ( -

- {error} -

- )} -
- ); -} diff --git a/resources/js/hooks/use-gif-provider-key.ts b/resources/js/hooks/use-gif-provider-key.ts deleted file mode 100644 index a5ba77d5e..000000000 --- a/resources/js/hooks/use-gif-provider-key.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMemo, useSyncExternalStore } from 'react'; -import { - readGifProviderKey, - subscribeGifProviderKey, -} from '@/lib/gif-provider-key'; -import type { GifProviderKey } from '@/lib/gif-provider-key'; - -const getServerSnapshot = (): null => null; - -export function useGifProviderKey( - userId: string | null, -): GifProviderKey | null { - const subscribe = useMemo( - () => (listener: () => void) => - userId === null - ? () => undefined - : subscribeGifProviderKey(userId, listener), - [userId], - ); - const getSnapshot = useMemo( - () => () => (userId === null ? null : readGifProviderKey(userId)), - [userId], - ); - - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} diff --git a/resources/js/hooks/use-stash.ts b/resources/js/hooks/use-stash.ts index 86edfa833..b086f77df 100644 --- a/resources/js/hooks/use-stash.ts +++ b/resources/js/hooks/use-stash.ts @@ -6,8 +6,8 @@ import { revokeObjectUrls, } from '@/components/commit-image-picker'; import { cancelImageRead, cancelImageReads } from '@/lib/draft-image-reads'; -import type { GifAttachment } from '@/lib/gif-providers/types'; -import { readStoredGif, writeStoredGif } from '@/lib/gif-storage'; +import { readStoredGif, writeStoredGif } from '@/lib/gif-provider-key'; +import type { GifAttachment } from '@/lib/gif-providers'; import type { StoredImages } from '@/lib/image-storage'; import { deleteStoredImages, diff --git a/resources/js/lib/gif-provider-key.ts b/resources/js/lib/gif-provider-key.ts index 5b9604574..75bf71255 100644 --- a/resources/js/lib/gif-provider-key.ts +++ b/resources/js/lib/gif-provider-key.ts @@ -1,7 +1,37 @@ -import { gifProviders } from './gif-providers/index.ts'; -import type { GifProviderId } from './gif-providers/types.ts'; +import { useMemo, useSyncExternalStore } from 'react'; +import { gifProviders } from './gif-providers.ts'; +import type { GifAttachment, GifProviderId } from './gif-providers.ts'; import { readStorage, writeStorage } from './storage.ts'; +const gifKey = (key: string): string => `${key}:gif`; + +/** A stashed GIF is tiny JSON in localStorage; images are Files in IndexedDB. */ +export function readStoredGif(key: string): GifAttachment | null { + const raw = readStorage(gifKey(key)); + + if (raw === '') { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + + return typeof parsed.id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.pageUrl === 'string' && + typeof parsed.provider === 'string' && + parsed.provider in gifProviders + ? (parsed as GifAttachment) + : null; + } catch { + return null; + } +} + +export function writeStoredGif(key: string, gif: GifAttachment | null): void { + writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); +} + export type GifProviderKey = { provider: GifProviderId; key: string; @@ -147,3 +177,23 @@ export function subscribeGifProviderKey( } }; } + +const getServerSnapshot = (): null => null; + +export function useGifProviderKey( + userId: string | null, +): GifProviderKey | null { + const subscribe = useMemo( + () => (listener: () => void) => + userId === null + ? () => undefined + : subscribeGifProviderKey(userId, listener), + [userId], + ); + const getSnapshot = useMemo( + () => () => (userId === null ? null : readGifProviderKey(userId)), + [userId], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/resources/js/lib/gif-providers.ts b/resources/js/lib/gif-providers.ts new file mode 100644 index 000000000..e93ed01ef --- /dev/null +++ b/resources/js/lib/gif-providers.ts @@ -0,0 +1,391 @@ +import type { CommitGif } from '@/types/commit'; + +export type GifProviderId = 'giphy' | 'klipy'; + +export type GifResult = { + id: string; + url: string; + pageUrl: string; + title: string | null; + username: string | null; + width: number; + height: number; + previewUrl: string | null; +}; + +export type GifAttachment = GifResult & { provider: GifProviderId }; + +export type GifSearchPage = { + results: GifResult[]; + nextCursor: string | null; +}; + +export type GifKeyValidation = + | 'valid' + | 'invalid' + | 'rate-limited' + | 'unreachable'; + +export class GifRateLimited extends Error { + constructor() { + super('The GIF provider is rate limiting searches.'); + } +} + +export interface GifProvider { + readonly id: GifProviderId; + readonly label: string; + readonly searchPlaceholder: string; + readonly attribution: { readonly label: string; readonly href: string }; + search( + key: string, + query: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + trending( + key: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + validateKey(key: string): Promise; +} + +const blockedTerms = [ + 'porn', + 'pornography', + 'nsfw', + 'xxx', + 'hentai', + 'nudity', + 'nude', + 'naked', + 'erotic', + 'sex', + 'gore', + 'gory', + 'cocaine', + 'heroin', + 'methamphetamine', +]; + +const blockedPattern = new RegExp(`\\b(?:${blockedTerms.join('|')})\\b`, 'i'); + +export function isSafeGiphyRating(rating: string | undefined): boolean { + return rating?.toLowerCase() === 'g'; +} + +export function isBlockedGifText( + ...texts: Array +): boolean { + return texts.some( + (text) => typeof text === 'string' && blockedPattern.test(text), + ); +} + +type GiphyRendition = { url: string; width: string; height: string }; + +type GiphyItem = { + id: string; + title: string; + username: string; + rating?: string; + url: string; + images: Partial< + Record< + | 'downsized_medium' + | 'downsized' + | 'original' + | 'fixed_height_small', + GiphyRendition + > + >; +}; + +const giphyPageSize = 24; +const giphyBaseUrl = 'https://api.giphy.com/v1/gifs'; + +const giphyParams = (key: string, offset: string | null): URLSearchParams => + new URLSearchParams({ + api_key: key, + limit: String(giphyPageSize), + offset: offset ?? '0', + rating: 'g', + }); + +const normalizeGiphy = (item: GiphyItem): GifResult | null => { + if ( + !isSafeGiphyRating(item.rating) || + isBlockedGifText(item.title, item.username) + ) { + return null; + } + + const stored = + item.images.downsized_medium ?? + item.images.downsized ?? + item.images.original; + + if (stored === undefined) { + return null; + } + + const preview = + item.images.fixed_height_small ?? + item.images.downsized ?? + item.images.downsized_medium ?? + item.images.original; + + return { + id: item.id, + url: stored.url, + pageUrl: item.url, + title: item.title === '' ? null : item.title, + username: item.username === '' ? null : item.username, + width: Number.parseInt(stored.width, 10), + height: Number.parseInt(stored.height, 10), + previewUrl: preview?.url ?? null, + }; +}; + +async function giphyPage( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`GIPHY request failed: ${response.status}`); + } + + const data = (await response.json()) as { data: GiphyItem[] }; + const results = data.data + .map(normalizeGiphy) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: + data.data.length < giphyPageSize + ? null + : String( + Number.parseInt(options.cursor ?? '0', 10) + + giphyPageSize, + ), + }; +} + +async function validateGiphyKey(key: string): Promise { + const search = new URLSearchParams({ + api_key: key, + limit: '1', + offset: '0', + rating: 'g', + }); + + let response: Response; + + try { + response = await fetch(`${giphyBaseUrl}/trending?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const giphy: GifProvider = { + id: 'giphy', + label: 'GIPHY', + searchPlaceholder: 'Search GIPHY', + attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }, + search: (key, query, options) => { + const search = giphyParams(key, options.cursor); + search.set('q', query); + + return giphyPage(`${giphyBaseUrl}/search?${search}`, options); + }, + trending: (key, options) => + giphyPage( + `${giphyBaseUrl}/trending?${giphyParams(key, options.cursor)}`, + options, + ), + validateKey: validateGiphyKey, +}; + +type KlipyMedia = { url: string; dims: [number, number] }; + +type KlipyItem = { + id: string; + title?: string; + content_description?: string; + itemurl: string; + media_formats?: { gif?: KlipyMedia; tinygif?: KlipyMedia }; +}; + +type KlipyResponse = { results?: KlipyItem[]; next?: string }; + +const klipyPageSize = 24; +const klipyBaseUrl = 'https://api.klipy.com/v2'; + +const klipyParams = (key: string, cursor: string | null): URLSearchParams => { + const search = new URLSearchParams({ + key, + limit: String(klipyPageSize), + contentfilter: 'high', + }); + + if (cursor !== null) { + search.set('pos', cursor); + } + + return search; +}; + +const normalizeKlipy = (item: KlipyItem): GifResult | null => { + if (isBlockedGifText(item.title, item.content_description)) { + return null; + } + + const gif = item.media_formats?.gif; + + if (gif === undefined) { + return null; + } + + return { + id: item.id, + url: gif.url, + pageUrl: item.itemurl, + title: item.title || item.content_description || null, + username: null, + width: gif.dims[0], + height: gif.dims[1], + previewUrl: item.media_formats?.tinygif?.url ?? gif.url, + }; +}; + +async function klipyPage( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`KLIPY request failed: ${response.status}`); + } + + const data = (await response.json()) as KlipyResponse; + const results = (data.results ?? []) + .map(normalizeKlipy) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: data.next === '' ? null : (data.next ?? null), + }; +} + +async function validateKlipyKey(key: string): Promise { + const search = new URLSearchParams({ + key, + limit: '8', + contentfilter: 'high', + }); + + let response: Response; + + try { + response = await fetch(`${klipyBaseUrl}/featured?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const klipy: GifProvider = { + id: 'klipy', + label: 'KLIPY', + searchPlaceholder: 'Search KLIPY', + attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, + search: (key, query, options) => { + const search = klipyParams(key, options.cursor); + search.set('q', query); + + return klipyPage(`${klipyBaseUrl}/search?${search}`, options); + }, + trending: (key, options) => + klipyPage( + `${klipyBaseUrl}/featured?${klipyParams(key, options.cursor)}`, + options, + ), + validateKey: validateKlipyKey, +}; + +export const gifProviders: Record = { + klipy, + giphy, +}; + +export function gifProviderFor(id: GifProviderId): GifProvider { + return gifProviders[id]; +} + +export function attachmentFromResult( + id: GifProviderId, + result: GifResult, +): GifAttachment { + return { ...result, provider: id }; +} + +/** A gif attachment shaped for CommitGif to render, dropping the draft's extra previewUrl field. */ +export function toCommitGif(gif: GifAttachment): CommitGif { + return { + provider: gif.provider, + id: gif.id, + url: gif.url, + page_url: gif.pageUrl, + title: gif.title, + username: gif.username, + width: gif.width, + height: gif.height, + }; +} + +export function gifFormFields( + gif: GifAttachment, +): Record { + return { + gif_provider: gif.provider, + gif_id: gif.id, + gif_url: gif.url, + gif_page_url: gif.pageUrl, + gif_title: gif.title ?? '', + gif_username: gif.username ?? '', + gif_width: gif.width, + gif_height: gif.height, + }; +} diff --git a/resources/js/lib/gif-providers/giphy.ts b/resources/js/lib/gif-providers/giphy.ts deleted file mode 100644 index 8f40a8134..000000000 --- a/resources/js/lib/gif-providers/giphy.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { isBlockedGifText, isSafeGiphyRating } from './safety.ts'; -import type { - GifKeyValidation, - GifProvider, - GifResult, - GifSearchPage, -} from './types.ts'; -import { GifRateLimited } from './types.ts'; - -type GiphyRendition = { url: string; width: string; height: string }; - -type GiphyItem = { - id: string; - title: string; - username: string; - rating?: string; - url: string; - images: Partial< - Record< - | 'downsized_medium' - | 'downsized' - | 'original' - | 'fixed_height_small', - GiphyRendition - > - >; -}; - -const pageSize = 24; -const baseUrl = 'https://api.giphy.com/v1/gifs'; - -const params = (key: string, offset: string | null): URLSearchParams => - new URLSearchParams({ - api_key: key, - limit: String(pageSize), - offset: offset ?? '0', - rating: 'g', - }); - -const normalize = (item: GiphyItem): GifResult | null => { - if ( - !isSafeGiphyRating(item.rating) || - isBlockedGifText(item.title, item.username) - ) { - return null; - } - - const stored = - item.images.downsized_medium ?? - item.images.downsized ?? - item.images.original; - - if (stored === undefined) { - return null; - } - - const preview = - item.images.fixed_height_small ?? - item.images.downsized ?? - item.images.downsized_medium ?? - item.images.original; - - return { - id: item.id, - url: stored.url, - pageUrl: item.url, - title: item.title === '' ? null : item.title, - username: item.username === '' ? null : item.username, - width: Number.parseInt(stored.width, 10), - height: Number.parseInt(stored.height, 10), - previewUrl: preview?.url ?? null, - }; -}; - -async function page( - url: string, - options: { cursor: string | null; signal?: AbortSignal }, -): Promise { - const response = await fetch(url, { signal: options.signal }); - - if (response.status === 429) { - throw new GifRateLimited(); - } - - if (!response.ok) { - throw new Error(`GIPHY request failed: ${response.status}`); - } - - const data = (await response.json()) as { data: GiphyItem[] }; - const results = data.data - .map(normalize) - .filter((result): result is GifResult => result !== null); - - return { - results, - nextCursor: - data.data.length < pageSize - ? null - : String(Number.parseInt(options.cursor ?? '0', 10) + pageSize), - }; -} - -async function validateKey(key: string): Promise { - const search = new URLSearchParams({ - api_key: key, - limit: '1', - offset: '0', - rating: 'g', - }); - - let response: Response; - - try { - response = await fetch(`${baseUrl}/trending?${search}`); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} - -export const giphy: GifProvider = { - id: 'giphy', - label: 'GIPHY', - searchPlaceholder: 'Search GIPHY', - attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }, - search: (key, query, options) => { - const search = params(key, options.cursor); - search.set('q', query); - - return page(`${baseUrl}/search?${search}`, options); - }, - trending: (key, options) => - page(`${baseUrl}/trending?${params(key, options.cursor)}`, options), - validateKey, -}; diff --git a/resources/js/lib/gif-providers/index.ts b/resources/js/lib/gif-providers/index.ts deleted file mode 100644 index f7b764361..000000000 --- a/resources/js/lib/gif-providers/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CommitGif } from '@/types/commit'; -import { giphy } from './giphy.ts'; -import { klipy } from './klipy.ts'; -import type { - GifAttachment, - GifProvider, - GifProviderId, - GifResult, -} from './types.ts'; - -export * from './types.ts'; - -export const gifProviders: Record = { - klipy, - giphy, -}; - -export function gifProviderFor(id: GifProviderId): GifProvider { - return gifProviders[id]; -} - -export function attachmentFromResult( - id: GifProviderId, - result: GifResult, -): GifAttachment { - return { ...result, provider: id }; -} - -/** A gif attachment shaped for CommitGif to render, dropping the draft's extra previewUrl field. */ -export function toCommitGif(gif: GifAttachment): CommitGif { - return { - provider: gif.provider, - id: gif.id, - url: gif.url, - page_url: gif.pageUrl, - title: gif.title, - username: gif.username, - width: gif.width, - height: gif.height, - }; -} - -export function gifFormFields( - gif: GifAttachment, -): Record { - return { - gif_provider: gif.provider, - gif_id: gif.id, - gif_url: gif.url, - gif_page_url: gif.pageUrl, - gif_title: gif.title ?? '', - gif_username: gif.username ?? '', - gif_width: gif.width, - gif_height: gif.height, - }; -} diff --git a/resources/js/lib/gif-providers/klipy.ts b/resources/js/lib/gif-providers/klipy.ts deleted file mode 100644 index 0527c5603..000000000 --- a/resources/js/lib/gif-providers/klipy.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { isBlockedGifText } from './safety.ts'; -import type { - GifKeyValidation, - GifProvider, - GifResult, - GifSearchPage, -} from './types.ts'; -import { GifRateLimited } from './types.ts'; - -type KlipyMedia = { url: string; dims: [number, number] }; - -type KlipyItem = { - id: string; - title?: string; - content_description?: string; - itemurl: string; - media_formats?: { gif?: KlipyMedia; tinygif?: KlipyMedia }; -}; - -type KlipyResponse = { results?: KlipyItem[]; next?: string }; - -const pageSize = 24; -const baseUrl = 'https://api.klipy.com/v2'; - -const params = (key: string, cursor: string | null): URLSearchParams => { - const search = new URLSearchParams({ - key, - limit: String(pageSize), - contentfilter: 'high', - }); - - if (cursor !== null) { - search.set('pos', cursor); - } - - return search; -}; - -const normalize = (item: KlipyItem): GifResult | null => { - if (isBlockedGifText(item.title, item.content_description)) { - return null; - } - - const gif = item.media_formats?.gif; - - if (gif === undefined) { - return null; - } - - return { - id: item.id, - url: gif.url, - pageUrl: item.itemurl, - title: item.title || item.content_description || null, - username: null, - width: gif.dims[0], - height: gif.dims[1], - previewUrl: item.media_formats?.tinygif?.url ?? gif.url, - }; -}; - -async function page( - url: string, - options: { cursor: string | null; signal?: AbortSignal }, -): Promise { - const response = await fetch(url, { signal: options.signal }); - - if (response.status === 429) { - throw new GifRateLimited(); - } - - if (!response.ok) { - throw new Error(`KLIPY request failed: ${response.status}`); - } - - const data = (await response.json()) as KlipyResponse; - const results = (data.results ?? []) - .map(normalize) - .filter((result): result is GifResult => result !== null); - - return { - results, - nextCursor: data.next === '' ? null : (data.next ?? null), - }; -} - -async function validateKey(key: string): Promise { - const search = new URLSearchParams({ - key, - limit: '8', - contentfilter: 'high', - }); - - let response: Response; - - try { - response = await fetch(`${baseUrl}/featured?${search}`); - } catch { - return 'unreachable'; - } - - if (response.ok) { - return 'valid'; - } - - if (response.status === 401 || response.status === 403) { - return 'invalid'; - } - - return response.status === 429 ? 'rate-limited' : 'unreachable'; -} - -export const klipy: GifProvider = { - id: 'klipy', - label: 'KLIPY', - searchPlaceholder: 'Search KLIPY', - attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, - search: (key, query, options) => { - const search = params(key, options.cursor); - search.set('q', query); - - return page(`${baseUrl}/search?${search}`, options); - }, - trending: (key, options) => - page(`${baseUrl}/featured?${params(key, options.cursor)}`, options), - validateKey, -}; diff --git a/resources/js/lib/gif-providers/safety.ts b/resources/js/lib/gif-providers/safety.ts deleted file mode 100644 index 98d6074f4..000000000 --- a/resources/js/lib/gif-providers/safety.ts +++ /dev/null @@ -1,31 +0,0 @@ -const blockedTerms = [ - 'porn', - 'pornography', - 'nsfw', - 'xxx', - 'hentai', - 'nudity', - 'nude', - 'naked', - 'erotic', - 'sex', - 'gore', - 'gory', - 'cocaine', - 'heroin', - 'methamphetamine', -]; - -const blockedPattern = new RegExp(`\\b(?:${blockedTerms.join('|')})\\b`, 'i'); - -export function isSafeGiphyRating(rating: string | undefined): boolean { - return rating?.toLowerCase() === 'g'; -} - -export function isBlockedGifText( - ...texts: Array -): boolean { - return texts.some( - (text) => typeof text === 'string' && blockedPattern.test(text), - ); -} diff --git a/resources/js/lib/gif-providers/types.ts b/resources/js/lib/gif-providers/types.ts deleted file mode 100644 index 1772ea16e..000000000 --- a/resources/js/lib/gif-providers/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type GifProviderId = 'giphy' | 'klipy'; - -export type GifResult = { - id: string; - url: string; - pageUrl: string; - title: string | null; - username: string | null; - width: number; - height: number; - previewUrl: string | null; -}; - -export type GifAttachment = GifResult & { provider: GifProviderId }; - -export type GifSearchPage = { - results: GifResult[]; - nextCursor: string | null; -}; - -export type GifKeyValidation = - | 'valid' - | 'invalid' - | 'rate-limited' - | 'unreachable'; - -export class GifRateLimited extends Error { - constructor() { - super('The GIF provider is rate limiting searches.'); - } -} - -export interface GifProvider { - readonly id: GifProviderId; - readonly label: string; - readonly searchPlaceholder: string; - readonly attribution: { readonly label: string; readonly href: string }; - search( - key: string, - query: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - trending( - key: string, - options: { cursor: string | null; signal?: AbortSignal }, - ): Promise; - validateKey(key: string): Promise; -} diff --git a/resources/js/lib/gif-storage.ts b/resources/js/lib/gif-storage.ts deleted file mode 100644 index 6ce4b7cab..000000000 --- a/resources/js/lib/gif-storage.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { gifProviders } from './gif-providers/index.ts'; -import type { GifAttachment } from './gif-providers/types.ts'; -import { readStorage, writeStorage } from './storage.ts'; - -const gifKey = (key: string): string => `${key}:gif`; - -/** A stashed GIF is tiny JSON in localStorage; images are Files in IndexedDB. */ -export function readStoredGif(key: string): GifAttachment | null { - const raw = readStorage(gifKey(key)); - - if (raw === '') { - return null; - } - - try { - const parsed = JSON.parse(raw) as Partial; - - return typeof parsed.id === 'string' && - typeof parsed.url === 'string' && - typeof parsed.pageUrl === 'string' && - typeof parsed.provider === 'string' && - parsed.provider in gifProviders - ? (parsed as GifAttachment) - : null; - } catch { - return null; - } -} - -export function writeStoredGif(key: string, gif: GifAttachment | null): void { - writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); -} diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index 704f5001e..5be159849 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -1,10 +1,10 @@ import { Form, Head, Link, router } from '@inertiajs/react'; import { ArrowUpRight, Download, RefreshCw } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import ExportController from '@/actions/App/Http/Controllers/ExportController'; import UserProfileController from '@/actions/App/Http/Controllers/UserProfileController'; import ActivityGraphEmbed from '@/components/activity-graph-embed'; import DeleteUser from '@/components/delete-user'; -import { GifKeySetting } from '@/components/gif-key-setting'; import { GitHubProfile } from '@/components/github-profile'; import Heading from '@/components/heading'; import LanePicker from '@/components/lane-picker'; @@ -13,11 +13,19 @@ import { RemotesSetting } from '@/components/remotes-setting'; import TimezonePicker from '@/components/timezone-picker'; import TranslationLanguageSetting from '@/components/translation-language-setting'; import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { + clearGifProviderKey, + useGifProviderKey, + writeGifProviderKey, +} from '@/lib/gif-provider-key'; +import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; import type { TranslationLanguage } from '@/lib/translation-languages'; import { formatFullDateTime, formatSyncedAgo } from '@/lib/utils'; import { show } from '@/routes/user'; @@ -155,3 +163,225 @@ export default function Edit({ } Edit.layout = [AppLayout, SettingsLayout]; + +const helpHref: Record = { + giphy: 'https://developers.giphy.com', + klipy: 'https://partner.klipy.com', +}; + +export function GifKeySetting() { + const { user } = useUser(); + const entry = useGifProviderKey(user.id); + const [providerId, setProviderId] = useState( + entry?.provider ?? 'klipy', + ); + const [key, setKey] = useState(''); + const [editing, setEditing] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const displayProviderId: GifProviderId = + editing || entry === null ? providerId : entry.provider; + const provider = gifProviderFor(displayProviderId); + const lastStored = useRef<{ provider?: GifProviderId; key?: string }>({ + provider: entry?.provider, + key: entry?.key, + }); + + useEffect(() => { + if ( + lastStored.current.provider !== entry?.provider || + lastStored.current.key !== entry?.key + ) { + lastStored.current = { provider: entry?.provider, key: entry?.key }; + setError(null); + } + }, [entry?.provider, entry?.key]); + + const connect = async () => { + if (processing) { + return; + } + + const candidate = key.trim(); + + if (candidate === '') { + return; + } + + if (candidate.length > 255) { + setError('That key is too long.'); + + return; + } + + setProcessing(true); + setError(null); + + const selected = gifProviderFor(providerId); + const verdict = await selected.validateKey(candidate); + + if (verdict === 'invalid') { + setError(`That key was rejected by ${selected.label}.`); + setProcessing(false); + + return; + } + + if (verdict === 'unreachable') { + setError(`Couldn't reach ${selected.label} to verify the key.`); + setProcessing(false); + + return; + } + + if ( + !writeGifProviderKey(user.id, { + provider: providerId, + key: candidate, + }) + ) { + setError("Couldn't save the key in this browser."); + setProcessing(false); + + return; + } + + setKey(''); + setError(null); + setEditing(false); + setProcessing(false); + }; + + const remove = () => { + clearGifProviderKey(user.id); + setError(null); + setEditing(false); + }; + + return ( +
+

+ GIFs +

+

+ GIF search runs on your own {provider.label} API key, under your + own quota. Get a key at{' '} + + {provider.label} + {' '} + and save it here. Without a key, the picker stays hidden. +

+

+ Stored in this browser only — never sent to the server. Each + device needs its own key. +

+ {entry !== null && !editing ? ( +
+

+ Connected to {gifProviderFor(entry.provider).label}. +

+ + +
+ ) : ( +
+
+ {Object.values(gifProviders).map((candidate) => ( + + ))} +
+ +
+ setKey(event.target.value)} + placeholder={`Paste your ${provider.label} API key`} + disabled={processing} + autoComplete="off" + spellCheck={false} + className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" + data-test="gif-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/tests/Unit/CommitGifRender.test.ts b/tests/Unit/CommitGifRender.test.ts deleted file mode 100644 index fd9a2218e..000000000 --- a/tests/Unit/CommitGifRender.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -await test('renders attached gifs with provider attribution on commit cards', () => { - const cli = fileURLToPath( - new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), - ); - - execFileSync( - process.execPath, - [cli, 'test', 'run', '--config', 'tests/commit-gif.config.ts'], - { - cwd: fileURLToPath(new URL('../..', import.meta.url)), - stdio: 'inherit', - }, - ); -}); diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index c18fc4281..109f6b86e 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { beforeEach, test, vi } from 'vite-plus/test'; import { cancelImageReads } from '../../resources/js/lib/draft-image-reads'; -import type { GifAttachment } from '../../resources/js/lib/gif-providers/types'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers'; import type { GifProviderKey } from '../../resources/js/lib/gif-provider-key'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); @@ -99,7 +99,7 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ discardSeriesStash: vi.fn(), })); let gifKey: GifProviderKey | null = null; -vi.doMock('../../resources/js/hooks/use-gif-provider-key', () => ({ +vi.doMock('../../resources/js/lib/gif-provider-key', () => ({ useGifProviderKey: () => gifKey, })); @@ -112,7 +112,7 @@ const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); const { ComposerMediaButton } = - await import('../../resources/js/components/composer-media-button'); + await import('../../resources/js/components/commit-image-picker'); const { ComposerPreviewBody, PreviewToggle } = await import('../../resources/js/components/composer-preview'); const { MentionTextarea } = diff --git a/tests/Unit/Enums/GifProviderTest.php b/tests/Unit/Enums/GifProviderTest.php deleted file mode 100644 index a604d291d..000000000 --- a/tests/Unit/Enums/GifProviderTest.php +++ /dev/null @@ -1,40 +0,0 @@ -label())->toBe('GIPHY') - ->and(GifProvider::Klipy->label())->toBe('KLIPY'); -}); - -it('accepts only its own media hosts', function (GifProvider $provider, string $host, bool $allowed): void { - $matches = array_filter( - $provider->mediaHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ); - - expect($matches !== [])->toBe($allowed); -})->with([ - 'giphy media' => [GifProvider::Giphy, 'media2.giphy.com', true], - 'giphy api host' => [GifProvider::Giphy, 'api.giphy.com', false], - 'klipy static' => [GifProvider::Klipy, 'static.klipy.com', true], - 'klipy numbered static' => [GifProvider::Klipy, 'static2.klipy.com', true], - 'klipy api host' => [GifProvider::Klipy, 'api.klipy.com', false], -]); - -it('accepts only its own page hosts', function (GifProvider $provider, string $host, bool $allowed): void { - $matches = array_filter( - $provider->pageHostPatterns(), - fn (string $pattern): bool => preg_match($pattern, $host) === 1, - ); - - expect($matches !== [])->toBe($allowed); -})->with([ - 'giphy page' => [GifProvider::Giphy, 'giphy.com', true], - 'giphy www page' => [GifProvider::Giphy, 'www.giphy.com', true], - 'klipy page' => [GifProvider::Klipy, 'klipy.com', true], - 'klipy www page' => [GifProvider::Klipy, 'www.klipy.com', true], - 'giphy page against klipy' => [GifProvider::Klipy, 'giphy.com', false], -]); diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx index 2302702fd..06b110a70 100644 --- a/tests/Unit/GifKeySetting.test.tsx +++ b/tests/Unit/GifKeySetting.test.tsx @@ -49,7 +49,7 @@ vi.doMock('../../resources/js/hooks/use-user', () => ({ })); const { GifKeySetting } = await import( - '../../resources/js/components/gif-key-setting' + '../../resources/js/pages/user-profile/edit' ); const { readGifProviderKey, writeGifProviderKey } = await import( '../../resources/js/lib/gif-provider-key' diff --git a/tests/Unit/GifKeySettingRender.test.ts b/tests/Unit/GifKeySettingRender.test.ts deleted file mode 100644 index 1c1b74f2c..000000000 --- a/tests/Unit/GifKeySettingRender.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -await test('connects, changes, and removes the gif provider from profile settings', () => { - const cli = fileURLToPath( - new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), - ); - - execFileSync( - process.execPath, - [cli, 'test', 'run', '--config', 'tests/gif-key.config.ts'], - { - cwd: fileURLToPath(new URL('../..', import.meta.url)), - stdio: 'inherit', - }, - ); -}); diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx index 9948b35fd..bbd2227ce 100644 --- a/tests/Unit/GifPickerDialog.test.tsx +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; -import type { GifProvider } from '../../resources/js/lib/gif-providers/types'; +import type { GifProvider } from '../../resources/js/lib/gif-providers'; const originalWindow = globalThis.window; @@ -84,7 +84,7 @@ const { GifPickerDialog } = await import( '../../resources/js/components/gif-picker-dialog' ); const { ComposerMediaButton } = await import( - '../../resources/js/components/composer-media-button' + '../../resources/js/components/commit-image-picker' ); const { gifProviderFor } = await import( '../../resources/js/lib/gif-providers' diff --git a/tests/Unit/GifPickerDialogRender.test.ts b/tests/Unit/GifPickerDialogRender.test.ts deleted file mode 100644 index 6faf1b3c5..000000000 --- a/tests/Unit/GifPickerDialogRender.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; - -await test('renders the gif picker for an injected provider', () => { - const cli = fileURLToPath( - new URL('./bin/vp', import.meta.resolve('vite-plus/package.json')), - ); - - execFileSync( - process.execPath, - [cli, 'test', 'run', '--config', 'tests/gif-picker.config.ts'], - { - cwd: fileURLToPath(new URL('../..', import.meta.url)), - stdio: 'inherit', - }, - ); -}); diff --git a/tests/Unit/GifProviderKey.test.ts b/tests/Unit/GifProviderKey.test.ts index ea674543e..07abc2cd8 100644 --- a/tests/Unit/GifProviderKey.test.ts +++ b/tests/Unit/GifProviderKey.test.ts @@ -1,12 +1,28 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, test } from 'node:test'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers.ts'; import { clearGifProviderKey, readGifProviderKey, + readStoredGif, subscribeGifProviderKey, writeGifProviderKey, + writeStoredGif, } from '../../resources/js/lib/gif-provider-key.ts'; +const gif: GifAttachment = { + provider: 'giphy', + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, + previewUrl: + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc123', +}; + let store: Map; let storageHandlers: Array<(event: StorageEvent) => void>; const originalWindow = globalThis.window; @@ -170,3 +186,29 @@ test('reacts to a full storage clear from another tab', () => { unsubscribe(); }); + +test('round-trips a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + assert.deepEqual(readStoredGif('stash:u1:commit'), gif); +}); + +await test('clears a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + writeStoredGif('stash:u1:commit', null); + assert.equal(readStoredGif('stash:u1:commit'), null); +}); + +await test('ignores unreadable stashed values', () => { + store.set('stash:u1:reply-1:gif', 'not json'); + assert.equal(readStoredGif('stash:u1:reply-1'), null); + store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); + assert.equal(readStoredGif('stash:u1:reply-2'), null); +}); + +await test('rejects a stashed gif with an unknown provider', () => { + store.set( + 'stash:u1:reply-3:gif', + JSON.stringify({ ...gif, provider: 'nope' }), + ); + assert.equal(readStoredGif('stash:u1:reply-3'), null); +}); diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts index a317bbc53..fe10bb8aa 100644 --- a/tests/Unit/GifProviders.test.ts +++ b/tests/Unit/GifProviders.test.ts @@ -3,10 +3,12 @@ import { test } from 'node:test'; import { attachmentFromResult, gifFormFields, -} from '../../resources/js/lib/gif-providers/index.ts'; -import { giphy } from '../../resources/js/lib/gif-providers/giphy.ts'; -import { klipy } from '../../resources/js/lib/gif-providers/klipy.ts'; -import { GifRateLimited } from '../../resources/js/lib/gif-providers/types.ts'; + giphy, + GifRateLimited, + isBlockedGifText, + isSafeGiphyRating, + klipy, +} from '../../resources/js/lib/gif-providers.ts'; const originalFetch = globalThis.fetch; @@ -594,3 +596,22 @@ test('attachments carry the provider and shape the hidden form fields', () => { gif_height: 498, }); }); + +test('only g-rated giphy results are safe', () => { + assert.equal(isSafeGiphyRating('g'), true); + assert.equal(isSafeGiphyRating('G'), true); + assert.equal(isSafeGiphyRating('pg'), false); + assert.equal(isSafeGiphyRating('pg-13'), false); + assert.equal(isSafeGiphyRating('r'), false); + assert.equal(isSafeGiphyRating('y'), false); + assert.equal(isSafeGiphyRating(undefined), false); +}); + +test('blocked words match whole words in any case', () => { + assert.equal(isBlockedGifText('PORN star'), true); + assert.equal(isBlockedGifText('Essex'), false); + assert.equal(isBlockedGifText(null, 'Hello', 'NSFW'), true); + assert.equal(isBlockedGifText('gory scene'), true); + assert.equal(isBlockedGifText('cocaine'), true); + assert.equal(isBlockedGifText('hello greeting'), false); +}); diff --git a/tests/Unit/GifSafety.test.ts b/tests/Unit/GifSafety.test.ts deleted file mode 100644 index 42f4ca5f8..000000000 --- a/tests/Unit/GifSafety.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - isBlockedGifText, - isSafeGiphyRating, -} from '../../resources/js/lib/gif-providers/safety.ts'; - -test('only g-rated giphy results are safe', () => { - assert.equal(isSafeGiphyRating('g'), true); - assert.equal(isSafeGiphyRating('G'), true); - assert.equal(isSafeGiphyRating('pg'), false); - assert.equal(isSafeGiphyRating('pg-13'), false); - assert.equal(isSafeGiphyRating('r'), false); - assert.equal(isSafeGiphyRating('y'), false); - assert.equal(isSafeGiphyRating(undefined), false); -}); - -test('blocked words match whole words in any case', () => { - assert.equal(isBlockedGifText('PORN star'), true); - assert.equal(isBlockedGifText('Essex'), false); - assert.equal(isBlockedGifText(null, 'Hello', 'NSFW'), true); - assert.equal(isBlockedGifText('gory scene'), true); - assert.equal(isBlockedGifText('cocaine'), true); - assert.equal(isBlockedGifText('hello greeting'), false); -}); diff --git a/tests/Unit/GifStorage.test.ts b/tests/Unit/GifStorage.test.ts deleted file mode 100644 index e5dd6a3ac..000000000 --- a/tests/Unit/GifStorage.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import type { GifAttachment } from '../../resources/js/lib/gif-providers/types.ts'; - -const store = new Map(); -(globalThis as Record).window = { - localStorage: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => void store.set(key, value), - removeItem: (key: string) => void store.delete(key), - }, -}; - -const { readStoredGif, writeStoredGif } = await import( - '../../resources/js/lib/gif-storage.ts' -); - -const gif: GifAttachment = { - provider: 'giphy', - id: 'xT9IgzoKnwFNmISR8I', - url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', - pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', - title: 'Deal With It GIF', - username: 'giphy', - width: 480, - height: 270, - previewUrl: - 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc123', -}; - -await test('round-trips a stashed gif', () => { - writeStoredGif('stash:u1:commit', gif); - assert.deepEqual(readStoredGif('stash:u1:commit'), gif); -}); - -await test('clears a stashed gif', () => { - writeStoredGif('stash:u1:commit', gif); - writeStoredGif('stash:u1:commit', null); - assert.equal(readStoredGif('stash:u1:commit'), null); -}); - -await test('ignores unreadable stashed values', () => { - store.set('stash:u1:reply-1:gif', 'not json'); - assert.equal(readStoredGif('stash:u1:reply-1'), null); - store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); - assert.equal(readStoredGif('stash:u1:reply-2'), null); -}); - -await test('rejects a stashed gif with an unknown provider', () => { - store.set( - 'stash:u1:reply-3:gif', - JSON.stringify({ ...gif, provider: 'nope' }), - ); - assert.equal(readStoredGif('stash:u1:reply-3'), null); -}); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 44cd3527c..74cc6b125 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -61,7 +61,7 @@ const { Form } = await import('@inertiajs/react'); const { CommitQuoteButton } = await import('../../resources/js/components/commit-quote-button'); const { ComposerMediaButton } = await import( - '../../resources/js/components/composer-media-button' + '../../resources/js/components/commit-image-picker' ); const { ImagePickerPreviews } = await import( '../../resources/js/components/commit-image-picker' diff --git a/tests/Unit/Rules/GifMediaUrlTest.php b/tests/Unit/Rules/GifMediaUrlTest.php deleted file mode 100644 index 32640fddd..000000000 --- a/tests/Unit/Rules/GifMediaUrlTest.php +++ /dev/null @@ -1,38 +0,0 @@ - $url], ['url' => [new GifMediaUrl($provider)]]); - - expect($validator->passes())->toBeTrue(); -})->with([ - 'giphy' => [GifProvider::Giphy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'], - 'klipy' => [GifProvider::Klipy, 'https://static2.klipy.com/ii/935d7ab9/14/af/8GCrVAB7.gif'], -]); - -it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl($provider)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'giphy url for klipy' => [GifProvider::Klipy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], - 'klipy url for giphy' => [GifProvider::Giphy, 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif'], -]); - -it('rejects unsafe or malformed media urls', function (mixed $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifMediaUrl(GifProvider::Klipy)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'not https' => ['http://static.klipy.com/ii/x.gif'], - 'not a gif' => ['https://static.klipy.com/ii/x.webp'], - 'with port' => ['https://static.klipy.com:8443/ii/x.gif'], - 'with userinfo' => ['https://user:pass@static.klipy.com/ii/x.gif'], - 'dot segment' => ['https://static.klipy.com/ii/../x.gif'], - 'array value' => [['nope']], -]); diff --git a/tests/Unit/Rules/GifPageUrlTest.php b/tests/Unit/Rules/GifPageUrlTest.php deleted file mode 100644 index 8f4bfc73e..000000000 --- a/tests/Unit/Rules/GifPageUrlTest.php +++ /dev/null @@ -1,36 +0,0 @@ - $url], ['url' => [new GifPageUrl($provider)]]); - - expect($validator->passes())->toBeTrue(); -})->with([ - 'giphy' => [GifProvider::Giphy, 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I'], - 'klipy' => [GifProvider::Klipy, 'https://klipy.com/gifs/hello-hi-662'], -]); - -it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifPageUrl($provider)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], - 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], -]); - -it('rejects unsafe or malformed page urls', function (mixed $url): void { - $validator = Validator::make(['url' => $url], ['url' => [new GifPageUrl(GifProvider::Klipy)]]); - - expect($validator->fails())->toBeTrue(); -})->with([ - 'not https' => ['http://klipy.com/gifs/x'], - 'with port' => ['https://klipy.com:8443/gifs/x'], - 'with userinfo' => ['https://user:pass@klipy.com/gifs/x'], - 'array value' => [['nope']], -]); diff --git a/tests/Unit/Rules/GifUrlTest.php b/tests/Unit/Rules/GifUrlTest.php new file mode 100644 index 000000000..8ec25fda3 --- /dev/null +++ b/tests/Unit/Rules/GifUrlTest.php @@ -0,0 +1,102 @@ + $url], ['url' => [new GifUrl($provider)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'], + 'klipy' => [GifProvider::Klipy, 'https://static2.klipy.com/ii/935d7ab9/14/af/8GCrVAB7.gif'], +]); + +it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy url for klipy' => [GifProvider::Klipy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'klipy url for giphy' => [GifProvider::Giphy, 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif'], +]); + +it('rejects unsafe or malformed media urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl(GifProvider::Klipy)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://static.klipy.com/ii/x.gif'], + 'not a gif' => ['https://static.klipy.com/ii/x.webp'], + 'with port' => ['https://static.klipy.com:8443/ii/x.gif'], + 'with userinfo' => ['https://user:pass@static.klipy.com/ii/x.gif'], + 'dot segment' => ['https://static.klipy.com/ii/../x.gif'], + 'array value' => [['nope']], +]); + +it('accepts matching provider page urls', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider, page: true)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I'], + 'klipy' => [GifProvider::Klipy, 'https://klipy.com/gifs/hello-hi-662'], +]); + +it('rejects page urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider, page: true)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], + 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], +]); + +it('rejects unsafe or malformed page urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl(GifProvider::Klipy, page: true)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://klipy.com/gifs/x'], + 'with port' => ['https://klipy.com:8443/gifs/x'], + 'with userinfo' => ['https://user:pass@klipy.com/gifs/x'], + 'array value' => [['nope']], +]); + +it('labels each provider as the ui does', function (): void { + expect(GifProvider::Giphy->label())->toBe('GIPHY') + ->and(GifProvider::Klipy->label())->toBe('KLIPY'); +}); + +it('accepts only its own media hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->mediaHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy media' => [GifProvider::Giphy, 'media2.giphy.com', true], + 'giphy api host' => [GifProvider::Giphy, 'api.giphy.com', false], + 'klipy static' => [GifProvider::Klipy, 'static.klipy.com', true], + 'klipy numbered static' => [GifProvider::Klipy, 'static2.klipy.com', true], + 'klipy api host' => [GifProvider::Klipy, 'api.klipy.com', false], +]); + +it('accepts only its own page hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->pageHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy page' => [GifProvider::Giphy, 'giphy.com', true], + 'giphy www page' => [GifProvider::Giphy, 'www.giphy.com', true], + 'klipy page' => [GifProvider::Klipy, 'klipy.com', true], + 'klipy www page' => [GifProvider::Klipy, 'www.klipy.com', true], + 'giphy page against klipy' => [GifProvider::Klipy, 'giphy.com', false], +]); diff --git a/tests/commit-gif.config.ts b/tests/commit-gif.config.ts deleted file mode 100644 index 6f74d45a8..000000000 --- a/tests/commit-gif.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('../resources/js', import.meta.url)), - }, - }, - test: { - include: ['tests/Unit/CommitGif.test.tsx'], - }, -}); diff --git a/tests/gif-key.config.ts b/tests/gif-key.config.ts deleted file mode 100644 index f1d9ec5c3..000000000 --- a/tests/gif-key.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('../resources/js', import.meta.url)), - }, - }, - test: { - include: ['tests/Unit/GifKeySetting.test.tsx'], - }, -}); diff --git a/tests/gif-picker.config.ts b/tests/gif-picker.config.ts deleted file mode 100644 index 040573863..000000000 --- a/tests/gif-picker.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite-plus'; - -export default defineConfig({ - resolve: { - alias: { - '@': fileURLToPath(new URL('../resources/js', import.meta.url)), - }, - }, - test: { - include: ['tests/Unit/GifPickerDialog.test.tsx'], - }, -}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index ba44989bd..2c64c4ad6 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -8,9 +8,6 @@ "Unit/**/*.test.ts", "Unit/**/*.test.tsx", "../resources/js/types/*.d.ts", - "draft-images.config.ts", - "commit-gif.config.ts", - "gif-picker.config.ts", - "gif-key.config.ts" + "draft-images.config.ts" ] } From 10020c2d05a2dc6abf27dad4324dedcaa67ef4ef Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 12:02:02 -0400 Subject: [PATCH 82/83] ci: pin the repository for tests on forks --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d2a1d9c1..9eb384dc3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,6 +111,7 @@ jobs: DB_PORT: 5432 DB_USERNAME: root DB_PASSWORD: password + GITHUB_REPOSITORY: dev-null-sh/dev-null.sh steps: - name: Checkout From c34f73a58a9ca584ba421cae187e9b078fef20d4 Mon Sep 17 00:00:00 2001 From: Oscar Arzola Date: Thu, 29 Oct 2026 12:14:52 -0400 Subject: [PATCH 83/83] test: pin the repository in every test --- .github/workflows/tests.yml | 1 - tests/Pest.php | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9eb384dc3..4d2a1d9c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,7 +111,6 @@ jobs: DB_PORT: 5432 DB_USERNAME: root DB_PASSWORD: password - GITHUB_REPOSITORY: dev-null-sh/dev-null.sh steps: - name: Checkout diff --git a/tests/Pest.php b/tests/Pest.php index ac43ed690..3083021e6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -33,6 +33,8 @@ Process::preventStrayProcesses(); Sleep::fake(); + config(['services.github.repository' => 'dev-null-sh/dev-null.sh']); + $this->freezeTime(); }) ->in('Feature', 'Unit');