From 268b8671defdd9beea46b5784b064a7535e4d146 Mon Sep 17 00:00:00 2001 From: devjerry0 <99199491+devjerry0@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:50:49 -0400 Subject: [PATCH] feat: play external GIF embeds inline Expose Giphy and verified GIF embeds atomically with frontend playback, reduced motion and manual retries. Codex-Thread: 01a15b08-d4ad-7e72-8725-b3ea1fde71ce --- KNOWLEDGE_BASE.md | 2 + README.md | 2 + app/Dto/Embed.php | 7 +- app/Enums/EmbedKind.php | 1 + app/Models/LinkPreview.php | 2 +- app/Services/EmbedProviders/Giphy.php | 75 +++++ app/Services/Embeds.php | 12 +- config/embeds.php | 3 + resources/js/components/gif-embed.tsx | 233 +++++++++++++++ resources/js/components/link-embed.tsx | 13 + resources/js/components/link-preview.tsx | 8 + resources/js/hooks/use-gif-playback.ts | 102 +++++++ resources/js/lib/gifs.ts | 158 ++++++++++ resources/js/types/commit.ts | 2 +- .../Controllers/Api/CommitControllerTest.php | 20 ++ tests/Unit/GifEmbed.test.tsx | 102 +++++++ tests/Unit/GifEmbedPlayback.test.tsx | 270 ++++++++++++++++++ tests/Unit/GifPlayback.test.ts | 226 +++++++++++++++ tests/Unit/GifPlaybackHook.test.tsx | 181 ++++++++++++ tests/Unit/Models/LinkPreviewTest.php | 14 + .../Services/EmbedProviders/GiphyTest.php | 44 +++ tests/Unit/Services/EmbedsTest.php | 30 ++ tests/draft-images.config.ts | 3 + tests/tsconfig.json | 7 +- 24 files changed, 1510 insertions(+), 7 deletions(-) create mode 100644 app/Services/EmbedProviders/Giphy.php create mode 100644 resources/js/components/gif-embed.tsx create mode 100644 resources/js/hooks/use-gif-playback.ts create mode 100644 resources/js/lib/gifs.ts create mode 100644 tests/Unit/GifEmbed.test.tsx create mode 100644 tests/Unit/GifEmbedPlayback.test.tsx create mode 100644 tests/Unit/GifPlayback.test.ts create mode 100644 tests/Unit/GifPlaybackHook.test.tsx create mode 100644 tests/Unit/Services/EmbedProviders/GiphyTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 240aa2d73..8ed85b896 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -242,6 +242,8 @@ URLs and bare domains in commits and replies become clickable links. Long URLs a Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null. +Giphy share links and verified public HTTPS `.gif` links play inline from their external host. Verified GIFs autoplay while visible and unload offscreen; reduced-motion users press **Play**. **Stop** unloads the animation, and failed GIFs offer two manual retries. No uploads or provider keys are required. Enable with `EMBEDS_PROVIDERS=giphy,gif`. Previously cached direct GIF links require refetching. + Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx). ### Replies and threads diff --git a/README.md b/README.md index 10c3fb906..ee62e1ceb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ composer setup composer dev ``` +Giphy links and verified public HTTPS `.gif` links play inline from their external host, with Play/Stop controls, visible-only autoplay, and manual playback for reduced motion. + Run `composer test` before opening a pull request. ## License diff --git a/app/Dto/Embed.php b/app/Dto/Embed.php index a5c6ef6a6..b221a40f6 100644 --- a/app/Dto/Embed.php +++ b/app/Dto/Embed.php @@ -24,8 +24,6 @@ private function __construct( } /** - * The poster stands in for the player until it is played, for when the link's own preview has no image. - * * @param array $query */ public static function video(string $provider, string $src, array $query = [], string $aspectRatio = '16 / 9', ?string $poster = null): self @@ -41,6 +39,11 @@ public static function fixed(string $provider, EmbedKind $kind, string $src, int return new self($provider, $kind, self::withQuery($src, $query), null, $height); } + public static function gif(string $provider, string $src, ?string $poster = null): self + { + return new self($provider, EmbedKind::Gif, $src, null, null, $poster); + } + /** * @return array{provider: string, kind: string, src: string, aspect_ratio: string|null, height: int|null, poster: string|null} */ diff --git a/app/Enums/EmbedKind.php b/app/Enums/EmbedKind.php index 09d39fa51..ca66dd258 100644 --- a/app/Enums/EmbedKind.php +++ b/app/Enums/EmbedKind.php @@ -9,4 +9,5 @@ enum EmbedKind: string case Video = 'video'; case Audio = 'audio'; case Code = 'code'; + case Gif = 'gif'; } diff --git a/app/Models/LinkPreview.php b/app/Models/LinkPreview.php index ccf22ed44..3a5b564d0 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -64,6 +64,6 @@ public function casts(): array */ public function toArray(): array { - return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url)?->toArray()]; + return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url, $this->gif_url)?->toArray()]; } } diff --git a/app/Services/EmbedProviders/Giphy.php b/app/Services/EmbedProviders/Giphy.php new file mode 100644 index 000000000..2856adffa --- /dev/null +++ b/app/Services/EmbedProviders/Giphy.php @@ -0,0 +1,75 @@ +supportsUrl($url)) { + return null; + } + + $identifier = $this->getGifId($url->getPath()); + + if ($identifier === null) { + return null; + } + + $mediaUrl = "https://media.giphy.com/media/{$identifier}"; + + return Embed::gif('GIPHY', "{$mediaUrl}/giphy.gif", "{$mediaUrl}/giphy_s.gif"); + } + + private function supportsUrl(Url $url): bool + { + if (! in_array($url->getScheme(), self::SUPPORTED_SCHEMES, true)) { + return false; + } + + if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true)) { + return false; + } + + if (($url->getUsername() ?? '') !== '') { + return false; + } + + if (($url->getPassword() ?? '') !== '') { + return false; + } + + return $url->getPort() === null; + } + + private function getGifId(string $path): ?string + { + $segments = explode('/', mb_trim($path, '/')); + + if (count($segments) !== self::PATH_SEGMENT_COUNT) { + return null; + } + + [$section, $slug] = $segments; + $identifier = match (Str::lower($section)) { + 'gifs' => Str::afterLast($slug, '-'), + 'embed' => $slug, + default => '', + }; + + return ctype_alnum($identifier) ? $identifier : null; + } +} diff --git a/app/Services/Embeds.php b/app/Services/Embeds.php index 9b2847713..bb805b5d5 100644 --- a/app/Services/Embeds.php +++ b/app/Services/Embeds.php @@ -11,12 +11,14 @@ final readonly class Embeds { + private const string GIF_PROVIDER = 'GIF'; + public function __construct(private Container $container) { // } - public function for(string $url): ?Embed + public function for(string $url, ?string $gifUrl = null): ?Embed { $parsed = Url::parse($url); @@ -32,7 +34,13 @@ public function for(string $url): ?Embed } } - return null; + $only = config()->array('embeds.only'); + + if ($gifUrl === null || ($only !== [] && ! in_array('gif', $only, true))) { + return null; + } + + return Embed::gif(self::GIF_PROVIDER, $gifUrl); } /** diff --git a/config/embeds.php b/config/embeds.php index c9349bc0c..bbc3b3d9d 100644 --- a/config/embeds.php +++ b/config/embeds.php @@ -5,6 +5,7 @@ use App\Services\EmbedProviders\AppleMusic; use App\Services\EmbedProviders\Asciinema; use App\Services\EmbedProviders\CodePen; +use App\Services\EmbedProviders\Giphy; use App\Services\EmbedProviders\Loom; use App\Services\EmbedProviders\SoundCloud; use App\Services\EmbedProviders\Spotify; @@ -17,9 +18,11 @@ 'enabled' => (bool) env('EMBEDS_ENABLED', true), + // "gif" selects verified external GIFs; the other keys select providers below. 'only' => array_values(array_filter(array_map(trim(...), explode(',', (string) env('EMBEDS_PROVIDERS', ''))))), 'providers' => [ + 'giphy' => Giphy::class, 'youtube' => YouTube::class, 'vimeo' => Vimeo::class, 'loom' => Loom::class, diff --git a/resources/js/components/gif-embed.tsx b/resources/js/components/gif-embed.tsx new file mode 100644 index 000000000..552f16022 --- /dev/null +++ b/resources/js/components/gif-embed.tsx @@ -0,0 +1,233 @@ +import { Play, RotateCcw, Square } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useGifPlayback } from '@/hooks/use-gif-playback'; +import { canLoadGifPoster } from '@/lib/gifs'; +import { hostname } from '@/lib/urls'; +import { cn } from '@/lib/utils'; +import type { Embed, LinkPreview } from '@/types'; + +const GIF_LABEL = 'GIF'; + +type GifEmbedProps = { + preview: LinkPreview; + embed?: Embed | null; + isStatic?: boolean; +}; + +type GifPlaybackState = ReturnType; + +type GifMediaProps = { + preview: LinkPreview; + embed: Embed | null; + playback: GifPlaybackState; +}; + +export function GifEmbed({ + preview, + embed = null, + isStatic = false, +}: GifEmbedProps) { + const container = useRef(null); + const playback = useGifPlayback({ embed, isStatic, container }); + + return ( +
event.stopPropagation()} + className="mt-3 overflow-hidden rounded-lg border" + data-test="gif-embed" + data-provider={embed?.provider ?? GIF_LABEL} + > +
+ + +
+ +
+ ); +} + +function GifMedia({ preview, embed, playback }: GifMediaProps) { + return ( + <> + {/* Retry posters only for new media or a manual retry. */} + + {playback.isPlaying && embed && ( + + )} + + ); +} + +function GifAnimation({ + preview, + embed, + playback, +}: GifMediaProps & { embed: Embed }) { + const [isLoaded, setIsLoaded] = useState(false); + + return ( + {preview.title setIsLoaded(true)} + onError={playback.onError} + className={cn( + 'absolute inset-0 size-full bg-muted object-contain', + !isLoaded && 'invisible', + )} + data-test="gif-animation" + /> + ); +} + +function GifFallback({ preview, embed, playback }: GifMediaProps) { + const [posterFailed, setPosterFailed] = useState(false); + + if ( + embed?.poster && + canLoadGifPoster(embed.poster) && + !posterFailed && + !playback.hasFailed + ) { + return ( + setPosterFailed(true)} + className="absolute inset-0 size-full object-contain" + data-test="gif-poster" + /> + ); + } + + return ; +} + +function GifStatus({ preview, embed, playback }: GifMediaProps) { + return ( + + {gifStatusMessage(preview, embed, playback.hasFailed)} + + ); +} + +function gifStatusMessage( + preview: LinkPreview, + embed: Embed | null, + hasFailed: boolean, +) { + if (hasFailed) { + return 'GIF could not load'; + } + + if (embed) { + return GIF_LABEL; + } + + if (preview.is_pending) { + return 'Loading GIF…'; + } + + return 'GIF preview unavailable'; +} + +function GifPlaybackButton({ + embed, + playback, + isStatic, +}: { + embed: Embed | null; + playback: GifPlaybackState; + isStatic: boolean; +}) { + if (isStatic || !embed || (playback.hasFailed && !playback.canRetry)) { + return null; + } + + const { Icon, label, onClick } = gifPlaybackControl(playback); + + return ( + + ); +} + +function gifPlaybackControl(playback: GifPlaybackState) { + if (playback.hasFailed) { + return { Icon: RotateCcw, label: 'Retry', onClick: playback.onRetry }; + } + + return { + Icon: playback.isPlaying ? Square : Play, + label: playback.isPlaying ? 'Stop' : 'Play', + onClick: playback.onToggle, + }; +} + +function GifSource({ preview, embed, isStatic }: GifEmbedProps) { + const details = ; + + if (isStatic) { + return
{details}
; + } + + return ( + + {details} + + ); +} + +function GifDetails({ preview, embed }: GifEmbedProps) { + return ( + <> + + {embed?.provider ?? hostname(preview.url)} + + + {preview.title ?? GIF_LABEL} + + + ); +} diff --git a/resources/js/components/link-embed.tsx b/resources/js/components/link-embed.tsx index cd19198ba..a8af6c897 100644 --- a/resources/js/components/link-embed.tsx +++ b/resources/js/components/link-embed.tsx @@ -1,6 +1,7 @@ import { AudioLines, Code2, Play } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; @@ -179,6 +180,18 @@ export function LinkEmbed({ isStatic?: boolean; }) { const [isPlaying, setIsPlaying] = useState(false); + + if (embed.kind === 'gif') { + return ( + + ); + } + const Icon = icons[embed.kind]; const label = `Play ${preview.title ?? preview.url} from ${embed.provider}`; const isVideo = embed.aspect_ratio !== null; diff --git a/resources/js/components/link-preview.tsx b/resources/js/components/link-preview.tsx index 513987a24..700848f6d 100644 --- a/resources/js/components/link-preview.tsx +++ b/resources/js/components/link-preview.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { LinkEmbed } from '@/components/link-embed'; import { Skeleton } from '@/components/ui/skeleton'; +import { isGifUrl } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; import type { LinkPreview as LinkPreviewData } from '@/types'; @@ -129,6 +131,12 @@ export function LinkPreview({ ); } + if (isGifUrl(preview.url)) { + return ( + + ); + } + if (isWaiting) { return ; } diff --git a/resources/js/hooks/use-gif-playback.ts b/resources/js/hooks/use-gif-playback.ts new file mode 100644 index 000000000..d5d66c2b4 --- /dev/null +++ b/resources/js/hooks/use-gif-playback.ts @@ -0,0 +1,102 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; +import { useMediaQuery } from '@/hooks/use-media-query'; +import { + canRetryGifLoad, + failGifLoad, + GIF_PLAYBACK_MODE, + hasGifLoadFailed, + isGifPlaying, + retryGifLoad, + syncGifLoadRetryState, + toggleGifPlayback, + type GifLoadRetryState, +} from '@/lib/gifs'; +import type { Embed } from '@/types'; + +const MIN_VISIBLE_RATIO = 0.1; + +function useGifVisibility( + container: RefObject, +): boolean { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const element = container.current; + + if (!element) { + return; + } + + if (typeof IntersectionObserver === 'undefined') { + const frame = requestAnimationFrame(() => setIsVisible(true)); + + return () => cancelAnimationFrame(frame); + } + + const observer = new IntersectionObserver( + ([entry]) => + setIsVisible( + entry.isIntersecting && + entry.intersectionRatio >= MIN_VISIBLE_RATIO, + ), + { threshold: MIN_VISIBLE_RATIO }, + ); + observer.observe(element); + + return () => observer.disconnect(); + }, [container]); + + return isVisible; +} + +export function useGifPlayback({ + embed, + isStatic, + container, +}: { + embed: Embed | null; + isStatic: boolean; + container: RefObject; +}) { + const isVisible = useGifVisibility(container); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const source = embed?.src ?? null; + const [retryState, setRetryState] = useState({ + source, + failedSource: null, + retryCount: 0, + playback: GIF_PLAYBACK_MODE.AUTOMATIC, + }); + const currentRetryState = syncGifLoadRetryState(retryState, source); + + if (currentRetryState !== retryState) { + setRetryState(currentRetryState); + } + + const hasFailed = hasGifLoadFailed(currentRetryState, source); + const canRetry = canRetryGifLoad(currentRetryState, source); + const isPlaying = + embed !== null && + isGifPlaying({ + source: embed.src, + playback: currentRetryState.playback, + isVisible, + reducedMotion, + isStatic, + hasFailed, + }); + + return { + isPlaying, + hasFailed, + canRetry, + retryCount: currentRetryState.retryCount, + onError: () => setRetryState((state) => failGifLoad(state, source)), + onRetry: () => setRetryState((state) => retryGifLoad(state, source)), + onToggle: () => + setRetryState((state) => + toggleGifPlayback(state, source, isPlaying), + ), + }; +} diff --git a/resources/js/lib/gifs.ts b/resources/js/lib/gifs.ts new file mode 100644 index 000000000..00138046c --- /dev/null +++ b/resources/js/lib/gifs.ts @@ -0,0 +1,158 @@ +const GIF_POSTER_HOSTS = new Set(['media.giphy.com']); + +function parseHttpsUrl(value: string): URL | null { + try { + const url = new URL(value); + + if ( + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || + url.port !== '' + ) { + return null; + } + + return url; + } catch { + return null; + } +} + +export function isGifUrl(value: string): boolean { + return ( + parseHttpsUrl(value)?.pathname.toLowerCase().endsWith('.gif') ?? false + ); +} + +export function canLoadGifPoster(value: string): boolean { + const url = parseHttpsUrl(value); + + return url !== null && GIF_POSTER_HOSTS.has(url.hostname); +} + +export const GIF_PLAYBACK_MODE = { + AUTOMATIC: 'auto', + PLAYING: 'play', + STOPPED: 'stop', +} as const; + +export type GifPlayback = + (typeof GIF_PLAYBACK_MODE)[keyof typeof GIF_PLAYBACK_MODE]; + +export const MAX_GIF_RETRIES = 2; + +export type GifLoadRetryState = { + source: string | null; + failedSource: string | null; + retryCount: number; + playback: GifPlayback; +}; + +export function syncGifLoadRetryState( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (state.source === source) { + return state; + } + + return { + source, + failedSource: null, + retryCount: 0, + playback: state.playback, + }; +} + +export function hasGifLoadFailed( + state: GifLoadRetryState, + source: string | null, +): boolean { + return source !== null && state.failedSource === source; +} + +export function canRetryGifLoad( + state: GifLoadRetryState, + source: string | null, +): boolean { + return ( + hasGifLoadFailed(state, source) && state.retryCount < MAX_GIF_RETRIES + ); +} + +export function failGifLoad( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (state.source !== source || source === null) { + return state; + } + + return { ...state, failedSource: source }; +} + +export function retryGifLoad( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (!canRetryGifLoad(state, source)) { + return state; + } + + return { + ...state, + failedSource: null, + retryCount: state.retryCount + 1, + playback: GIF_PLAYBACK_MODE.PLAYING, + }; +} + +export function toggleGifPlayback( + state: GifLoadRetryState, + source: string | null, + isPlaying: boolean, +): GifLoadRetryState { + if (state.source !== source) { + return state; + } + + return { + ...state, + playback: isPlaying + ? GIF_PLAYBACK_MODE.STOPPED + : GIF_PLAYBACK_MODE.PLAYING, + }; +} + +export function isGifPlaying({ + source, + playback, + isVisible, + reducedMotion, + isStatic, + hasFailed, +}: { + source: string; + playback: GifPlayback; + isVisible: boolean; + reducedMotion: boolean; + isStatic: boolean; + hasFailed: boolean; +}): boolean { + if ( + isStatic || + hasFailed || + !isVisible || + playback === GIF_PLAYBACK_MODE.STOPPED || + parseHttpsUrl(source) === null + ) { + return false; + } + + if (playback === GIF_PLAYBACK_MODE.PLAYING) { + return true; + } + + return !reducedMotion; +} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 8af6a4e68..532390c3a 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -2,7 +2,7 @@ import type { User } from '@/types/auth'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; -export type EmbedKind = 'video' | 'audio' | 'code'; +export type EmbedKind = 'video' | 'audio' | 'code' | 'gif'; export type Embed = { provider: string; diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1eb4c87d5..846a2ecdc 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -129,6 +129,26 @@ ]); }); +it('includes verified external GIF metadata in the public API', function (): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + ]); + $commit = Commit::factory()->create(['link_preview_id' => $preview->id]); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.link_preview.gif_url') + ->assertJsonPath('data.link_preview.embed', [ + 'provider' => 'GIF', + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + 'aspect_ratio' => null, + 'height' => null, + 'poster' => null, + ]); +}); + it('includes the images of the commit in order', function (): void { $commit = Commit::factory()->create(); $second = CommitImage::factory()->for($commit)->create(['position' => 1, 'width' => 640, 'height' => 480]); diff --git a/tests/Unit/GifEmbed.test.tsx b/tests/Unit/GifEmbed.test.tsx new file mode 100644 index 000000000..19d98aeab --- /dev/null +++ b/tests/Unit/GifEmbed.test.tsx @@ -0,0 +1,102 @@ +/// +import assert from 'node:assert/strict'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { GifEmbed } from '../../resources/js/components/gif-embed'; +import { LinkEmbed } from '../../resources/js/components/link-embed'; +import type { Embed, LinkPreview } from '../../resources/js/types/commit'; + +const preview: LinkPreview = { + url: 'https://example.com/cat.gif', + title: null, + description: null, + image_url: 'https://example.com/untrusted-thumbnail.gif', + site_name: null, + is_pending: false, +}; + +const embed: Embed = { + provider: 'GIF', + kind: 'gif', + src: 'https://example.com/cat.gif', + aspect_ratio: null, + height: null, + poster: null, +}; + +test('waits until hydration and visibility before requesting animated content', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('data-test="gif-embed"')); + assert.ok(html.includes('aspect-square')); + assert.ok(html.includes('aria-label="Play GIF"')); + assert.ok(html.includes('href="https://example.com/cat.gif"')); + assert.ok(!html.includes(' { + const pending = renderToStaticMarkup( + , + ); + const verified = renderToStaticMarkup( + , + ); + const box = /class="[^"]*aspect-square[^"]*"/; + + assert.equal(pending.match(box)?.[0], verified.match(box)?.[0]); + assert.ok(pending.includes('Loading GIF')); + assert.ok(!pending.includes(' { + const html = renderToStaticMarkup(); + + assert.ok(html.includes('GIF preview unavailable')); + assert.ok(html.includes('href="https://example.com/cat.gif"')); + assert.ok(!html.includes(' { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('aspect-square')); + assert.ok(!html.includes(' { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.ok(html.includes('referrerPolicy="no-referrer"')); + assert.ok(!html.includes('data-test="gif-animation"')); + assert.ok(!html.includes('untrusted-thumbnail')); +}); + +test('external GIFs do not load unverified posters before visibility', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('aria-label="Play GIF"')); + assert.ok(!html.includes('press Play to load')); + assert.ok(!html.includes(' +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ImgHTMLAttributes, ReactElement, ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { Embed, LinkPreview } from '../../resources/js/types/commit'; + +const playback = { + isPlaying: true, + hasFailed: false, + canRetry: false, + retryCount: 0, + onError: () => {}, + onToggle: () => {}, + onRetry: vi.fn(), +}; + +let states: unknown[] = []; +let stateIndex = 0; + +vi.doMock('react', () => ({ + ...React, + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [ + states[index], + (value: unknown) => { + states[index] = + typeof value === 'function' ? value(states[index]) : value; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-gif-playback', () => ({ + useGifPlayback: () => playback, +})); + +const { GifEmbed } = await import('../../resources/js/components/gif-embed'); + +afterAll(() => vi.doUnmock('../../resources/js/hooks/use-gif-playback')); +afterEach(() => { + playback.isPlaying = true; + playback.hasFailed = false; + playback.canRetry = false; + playback.retryCount = 0; + playback.onRetry.mockClear(); + states = []; + stateIndex = 0; +}); + +const preview: LinkPreview = { + url: 'https://giphy.com/gifs/cat', + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: false, +}; + +const embed: Embed = { + provider: 'GIPHY', + kind: 'gif', + src: 'https://media.giphy.com/media/cat/giphy.gif', + poster: 'https://media.giphy.com/media/cat/giphy_s.gif', + aspect_ratio: null, + height: null, +}; + +function render(current: Embed = embed): string { + startRender(); + + return renderToStaticMarkup(); +} + +function startRender() { + stateIndex = 0; +} + +function startPlaybackSession() { + states = []; + startRender(); +} + +function animation( + current: Embed = embed, +): ReactElement> { + const content = mediaContent(current); + const image = React.Children.toArray( + content.props.children, + )[1] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + + return ( + image.type as ( + props: typeof image.props, + ) => ReactElement> + )(image.props); +} + +function frame(current: Embed = embed): ReactElement<{ children: ReactNode }> { + startRender(); + const card = GifEmbed({ preview, embed: current }) as ReactElement<{ + children: ReactNode; + }>; + return React.Children.toArray(card.props.children)[0] as ReactElement<{ + children: ReactNode; + }>; +} + +function mediaContent( + current: Embed = embed, +): ReactElement<{ children: ReactNode }> { + const media = React.Children.toArray( + frame(current).props.children, + )[0] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + + return ( + media.type as (props: typeof media.props) => ReactElement<{ + children: ReactNode; + }> + )(media.props); +} + +test('keeps the static poster underneath an animation that has not loaded', () => { + const html = render(); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.match( + html, + /]*class="[^"]*invisible[^>]*data-test="gif-animation"/, + ); +}); + +test('keeps the direct GIF placeholder while its animation loads', () => { + const html = render({ ...embed, poster: null }); + + assert.match(html, /]*data-test="gif-animation"/, + ); +}); + +test('shows a loaded animation and starts a fresh playback session hidden', () => { + const loading = animation(); + + assert.match(String(loading.props.className), /invisible/); + loading.props.onLoad?.({} as React.SyntheticEvent); + + const loaded = animation(); + + assert.doesNotMatch(String(loaded.props.className), /invisible/); + + startPlaybackSession(); + + const restarted = animation({ ...embed, src: `${embed.src}?restart=1` }); + + assert.match(String(restarted.props.className), /invisible/); +}); + +test('Stop and offscreen states unload the animation and retain the poster', () => { + playback.isPlaying = false; + const html = render(); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.ok(!html.includes('data-test="gif-animation"')); + assert.ok(html.includes('aria-label="Play GIF"')); +}); + +test('a failed animation retains the card and reports its failure', () => { + playback.isPlaying = false; + playback.hasFailed = true; + const html = render(); + + assert.ok(html.includes('aspect-square')); + assert.ok(html.includes('GIF could not load')); + assert.ok(!html.includes(' { + playback.isPlaying = false; + playback.hasFailed = true; + playback.canRetry = true; + const html = render(); + + assert.ok(html.includes('GIF could not load')); + assert.ok(html.includes('aria-label="Retry GIF"')); + assert.ok(!html.includes('opacity-0')); + assert.ok(!html.includes('data-test="gif-animation"')); +}); + +test('the retry control invokes retry rather than toggling playback', () => { + playback.isPlaying = false; + playback.hasFailed = true; + playback.canRetry = true; + const control = React.Children.toArray( + frame().props.children, + )[1] as ReactElement<{ + embed: Embed; + playback: typeof playback; + isStatic: boolean; + }>; + const button = ( + control.type as (props: typeof control.props) => ReactElement<{ + onClick: () => void; + }> + )(control.props); + button.props.onClick(); + + assert.equal(playback.onRetry.mock.calls.length, 1); +}); + +test('retry keeps the verified animation and poster URLs unchanged', () => { + playback.retryCount = 1; + const html = render(); + + assert.ok(html.includes(`src="${embed.src}"`)); + assert.ok(html.includes(`src="${embed.poster}"`)); +}); + +test('poster failures survive Stop and visibility changes but reset on manual retry or new media', () => { + playback.isPlaying = false; + const fallback = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + const poster = ( + fallback.type as ( + props: typeof fallback.props, + ) => ReactElement> + )(fallback.props); + poster.props.onError?.({} as React.SyntheticEvent); + + assert.ok(!render().includes('data-test="gif-poster"')); + playback.isPlaying = true; + assert.ok(!render().includes('data-test="gif-poster"')); + const unchanged = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement; + assert.equal(unchanged.key, fallback.key); + + playback.retryCount = 1; + const retried = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement; + assert.notEqual(retried.key, fallback.key); + const changed = React.Children.toArray( + mediaContent({ + ...embed, + poster: 'https://media.giphy.com/media/dog/giphy_s.gif', + }).props.children, + )[0] as ReactElement; + assert.notEqual(changed.key, retried.key); +}); diff --git a/tests/Unit/GifPlayback.test.ts b/tests/Unit/GifPlayback.test.ts new file mode 100644 index 000000000..dba5398cf --- /dev/null +++ b/tests/Unit/GifPlayback.test.ts @@ -0,0 +1,226 @@ +/// +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + canLoadGifPoster, + canRetryGifLoad, + failGifLoad, + GIF_PLAYBACK_MODE, + hasGifLoadFailed, + isGifPlaying, + isGifUrl, + MAX_GIF_RETRIES, + retryGifLoad, + syncGifLoadRetryState, + type GifLoadRetryState, +} from '../../resources/js/lib/gifs.ts'; + +const validGifUrls = [ + 'https://example.com/animation.gif', + 'https://example.com/animation.GIF?token=abc#frame', + 'https://media2.giphy.com/media/cat/giphy.gif', + 'https://example.com:443/cat.gif', +]; + +for (const url of validGifUrls) { + void test(`recognizes a direct HTTPS GIF link: ${url}`, () => { + assert.equal(isGifUrl(url), true); + assert.equal(isGifPlaying({ ...playback, source: url }), true); + }); +} + +const invalidGifUrls = [ + 'not a URL', + 'http://example.com/cat.gif', + 'data:image/gif;base64,R0lGODlh', + 'javascript:alert(1)', + 'https://example.com/page?image=cat.gif', + 'https://example.com/cat.gif/page', + 'https://example.com/cat.gif.html', + 'https://user:secret@example.com/cat.gif', + 'https://user@example.com/cat.gif', + 'https://:secret@example.com/cat.gif', + 'https://example.com:8443/cat.gif', +]; + +for (const url of invalidGifUrls) { + void test(`does not treat an unsupported link as a GIF: ${url}`, () => { + assert.equal(isGifUrl(url), false); + }); +} + +const playback = { + source: 'https://media.giphy.com/media/cat/giphy.gif', + playback: 'auto' as const, + isVisible: true, + reducedMotion: false, + isStatic: false, + hasFailed: false, +}; + +void test('autoplays visible GIFs and suspends them outside the viewport', () => { + assert.equal(isGifPlaying(playback), true); + assert.equal(isGifPlaying({ ...playback, isVisible: false }), false); +}); + +void test('autoplays a server-verified GIF after an extensionless redirect', () => { + const source = 'https://www.example.com/media?id=cat'; + + assert.equal(isGifUrl(source), false); + assert.equal(isGifPlaying({ ...playback, source }), true); +}); + +for (const source of [ + 'not a URL', + 'http://example.com/media?id=cat', + 'https://user:secret@example.com/media?id=cat', + 'https://example.com:8443/media?id=cat', +]) { + void test(`rejects an unsafe verified GIF source in every playback mode: ${source}`, () => { + for (const mode of Object.values(GIF_PLAYBACK_MODE)) { + assert.equal( + isGifPlaying({ ...playback, source, playback: mode }), + false, + ); + } + }); +} + +void test('reduced motion requires an explicit Play action', () => { + assert.equal(isGifPlaying({ ...playback, reducedMotion: true }), false); + assert.equal( + isGifPlaying({ ...playback, reducedMotion: true, playback: 'play' }), + true, + ); +}); + +void test('Stop survives leaving and reentering the viewport', () => { + assert.equal(isGifPlaying({ ...playback, playback: 'stop' }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'stop', isVisible: false }), + false, + ); + assert.equal(isGifPlaying({ ...playback, playback: 'stop' }), false); + assert.equal(isGifPlaying({ ...playback, playback: 'play' }), true); +}); + +void test('static cards and failed GIFs never animate, even after Play', () => { + assert.equal(isGifPlaying({ ...playback, isStatic: true }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'play', isStatic: true }), + false, + ); + assert.equal(isGifPlaying({ ...playback, hasFailed: true }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'play', hasFailed: true }), + false, + ); +}); + +void test('only canonical Giphy media can supply a static poster', () => { + assert.equal( + canLoadGifPoster('https://media.giphy.com/media/cat/giphy.gif'), + true, + ); + for (const source of [ + 'https://example.com/cat.gif', + 'https://media.giphy.com.attacker.example/cat.gif', + 'https://attacker.giphy.com/cat.gif', + 'http://media.giphy.com/media/cat/giphy.gif', + 'https://user:secret@media.giphy.com/media/cat/giphy.gif', + 'https://user@media.giphy.com/media/cat/giphy.gif', + 'https://:secret@media.giphy.com/media/cat/giphy.gif', + 'https://media.giphy.com:8443/media/cat/giphy.gif', + 'not a URL', + ]) { + assert.equal(canLoadGifPoster(source), false); + } +}); + +void test('external GIFs autoplay while visible and respect playback preferences', () => { + const external = { ...playback, source: 'https://example.com/cat.gif' }; + assert.equal(isGifPlaying(external), true); + assert.equal(isGifPlaying({ ...external, isVisible: false }), false); + assert.equal(isGifPlaying({ ...external, reducedMotion: true }), false); + assert.equal( + isGifPlaying({ ...external, reducedMotion: true, playback: 'play' }), + true, + ); + assert.equal(isGifPlaying({ ...external, playback: 'play' }), true); + assert.equal(isGifPlaying({ ...external, playback: 'stop' }), false); + assert.equal( + isGifPlaying({ ...external, playback: 'play', isVisible: false }), + false, + ); +}); + +void test('a failed GIF stays stopped until the user retries it', () => { + const source = 'https://example.com/cat.gif'; + const failed = failGifLoad( + { source, failedSource: null, retryCount: 0, playback: 'auto' }, + source, + ); + + assert.equal(hasGifLoadFailed(failed, source), true); + assert.equal(isGifPlaying({ ...playback, source, hasFailed: true }), false); + assert.equal(canRetryGifLoad(failed, source), true); + + const retried = retryGifLoad(failed, source); + assert.equal(hasGifLoadFailed(retried, source), false); + assert.equal(retried.retryCount, 1); + assert.equal(retried.playback, 'play'); +}); + +void test('a new GIF source gets a fresh retry budget without changing playback preference', () => { + const source = 'https://example.com/cat.gif'; + const replacement = 'https://example.com/dog.gif'; + const failed = failGifLoad( + { + source, + failedSource: null, + retryCount: MAX_GIF_RETRIES, + playback: 'stop', + }, + source, + ); + + const replacementState = syncGifLoadRetryState(failed, replacement); + assert.equal(hasGifLoadFailed(replacementState, replacement), false); + assert.equal(replacementState.retryCount, 0); + assert.equal(replacementState.playback, 'stop'); + assert.equal(canRetryGifLoad(replacementState, replacement), false); + + const failedReplacement = failGifLoad(replacementState, replacement); + assert.equal(canRetryGifLoad(failedReplacement, replacement), true); + + const originalState = syncGifLoadRetryState(failedReplacement, source); + assert.equal(hasGifLoadFailed(originalState, source), false); + assert.equal(originalState.retryCount, 0); + assert.equal(canRetryGifLoad(originalState, source), false); +}); + +void test('GIF retries are bounded and stale failures cannot affect a replacement source', () => { + const source = 'https://example.com/cat.gif'; + const replacement = 'https://example.com/dog.gif'; + let state: GifLoadRetryState = { + source, + failedSource: null, + retryCount: 0, + playback: 'auto' as const, + }; + + for (let retry = 0; retry < MAX_GIF_RETRIES; retry++) { + state = failGifLoad(state, source); + assert.equal(canRetryGifLoad(state, source), true); + state = retryGifLoad(state, source); + } + + state = failGifLoad(state, source); + assert.equal(hasGifLoadFailed(state, source), true); + assert.equal(canRetryGifLoad(state, source), false); + assert.equal(retryGifLoad(state, source), state); + + const active = syncGifLoadRetryState(state, replacement); + assert.equal(failGifLoad(active, source), active); + assert.equal(hasGifLoadFailed(active, replacement), false); +}); diff --git a/tests/Unit/GifPlaybackHook.test.tsx b/tests/Unit/GifPlaybackHook.test.tsx new file mode 100644 index 000000000..1b197cf19 --- /dev/null +++ b/tests/Unit/GifPlaybackHook.test.tsx @@ -0,0 +1,181 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { Embed } from '../../resources/js/types/commit'; + +let states: unknown[] = []; +let stateIndex = 0; +let didUpdate = false; +let effects: (() => void | (() => void))[] = []; +let reducedMotion = false; +let setVisible: (visible: boolean) => void; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void | (() => void)) => effects.push(effect), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [ + states[index], + (update: unknown) => { + const next = + typeof update === 'function' + ? update(states[index]) + : update; + didUpdate ||= next !== states[index]; + states[index] = next; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-media-query', () => ({ + useMediaQuery: () => reducedMotion, +})); + +vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + setVisible = (visible) => + callback( + [ + { + isIntersecting: visible, + intersectionRatio: visible ? 1 : 0, + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ); + } + + observe() {} + disconnect() {} + }, +); + +const { useGifPlayback } = + await import('../../resources/js/hooks/use-gif-playback'); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + states = []; + stateIndex = 0; + effects = []; + reducedMotion = false; +}); + +const embed: Embed = { + provider: 'GIF', + kind: 'gif', + src: 'https://example.com/cat.gif', + poster: null, + aspect_ratio: null, + height: null, +}; +const container = { current: {} as HTMLDivElement }; + +function render(current: Embed = embed) { + let result: ReturnType; + let renders = 0; + + do { + assert.ok( + renders++ < 10, + 'source state must settle without a render loop', + ); + didUpdate = false; + stateIndex = 0; + result = useGifPlayback({ embed: current, isStatic: false, container }); + } while (didUpdate); + + return result; +} + +function mount() { + render(); + effects.shift()?.(); + setVisible(true); + + return render(); +} + +test('only manual retry clears a failure and the hook enforces two extra attempts', () => { + mount().onError(); + let playback = render(); + + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + assert.equal(playback.canRetry, true); + + setVisible(false); + render(); + setVisible(true); + playback = render(); + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + + for (let attempt = 1; attempt <= 2; attempt++) { + playback.onRetry(); + playback = render(); + assert.equal(playback.hasFailed, false); + assert.equal(playback.isPlaying, true); + assert.equal(playback.retryCount, attempt); + playback.onError(); + playback = render(); + } + + assert.equal(playback.canRetry, false); + playback.onRetry(); + playback = render(); + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + assert.equal(playback.retryCount, 2); +}); + +test('changing sources clears failure and the retry budget without stale error leakage', () => { + const first = mount(); + first.onError(); + render().onRetry(); + render().onError(); + const failedFirst = render(); + const next = { ...embed, src: 'https://example.com/dog.gif' }; + let playback = render(next); + + assert.equal(playback.hasFailed, false); + assert.equal(playback.isPlaying, true); + assert.equal(playback.retryCount, 0); + + first.onError(); + playback = render(next); + assert.equal(playback.hasFailed, false); + playback.onToggle(); + failedFirst.onRetry(); + playback = render(next); + assert.equal(playback.isPlaying, false); + assert.equal(playback.retryCount, 0); + playback.onError(); + assert.equal(render(next).hasFailed, true); + playback = render(); + assert.equal(playback.hasFailed, false); + assert.equal(playback.retryCount, 0); +}); + +test('retry is explicit playback for reduced motion and source changes retain Stop', () => { + reducedMotion = true; + let playback = mount(); + assert.equal(playback.isPlaying, false); + playback.onToggle(); + playback = render(); + assert.equal(playback.isPlaying, true); + playback.onError(); + render().onRetry(); + playback = render(); + assert.equal(playback.isPlaying, true); + playback.onToggle(); + playback = render({ ...embed, src: 'https://example.com/dog.gif' }); + assert.equal(playback.isPlaying, false); +}); diff --git a/tests/Unit/Models/LinkPreviewTest.php b/tests/Unit/Models/LinkPreviewTest.php index 83447671b..fc5b061e2 100644 --- a/tests/Unit/Models/LinkPreviewTest.php +++ b/tests/Unit/Models/LinkPreviewTest.php @@ -41,6 +41,20 @@ expect($linkPreview->url_hash)->toBe(hash('sha256', 'https://example.com')); }); +it('serializes a verified external GIF without exposing its storage field', function (): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + ])->refresh(); + + expect($preview->gif_url)->toBe('https://cdn.example.com/cat.gif') + ->and($preview->toArray())->not->toHaveKey('gif_url') + ->and($preview->toArray()['embed'])->toMatchArray([ + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + ]); +}); + it('casts the fetched at timestamp', function (): void { $linkPreview = LinkPreview::factory()->fetched()->create()->refresh(); diff --git a/tests/Unit/Services/EmbedProviders/GiphyTest.php b/tests/Unit/Services/EmbedProviders/GiphyTest.php new file mode 100644 index 000000000..264b5c3b1 --- /dev/null +++ b/tests/Unit/Services/EmbedProviders/GiphyTest.php @@ -0,0 +1,44 @@ +embed(Url::parse($url))?->toArray())->toBe([ + 'provider' => 'GIPHY', + 'kind' => 'gif', + 'src' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + 'aspect_ratio' => null, + 'height' => null, + 'poster' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy_s.gif', + ]); +})->with([ + 'share' => 'https://giphy.com/gifs/cat-typing-JIX9t2j0ZTN9S', + 'id' => 'https://www.giphy.com/gifs/JIX9t2j0ZTN9S/', + 'embed' => 'https://giphy.com/embed/JIX9t2j0ZTN9S', + 'uppercase section' => 'https://giphy.com/GIFS/cat-JIX9t2j0ZTN9S', + 'query and fragment' => 'https://giphy.com/gifs/cat-JIX9t2j0ZTN9S?tracking=ignored#ignored', + 'http link' => 'http://giphy.com/gifs/JIX9t2j0ZTN9S', + 'explicit default HTTPS port' => 'https://giphy.com:443/gifs/JIX9t2j0ZTN9S', +]); + +it('does not embed malformed links or untrusted hosts', function (string $url): void { + expect(resolve(Giphy::class)->embed(Url::parse($url)))->toBeNull(); +})->with([ + 'home' => 'https://giphy.com/', + 'search' => 'https://giphy.com/search/cats', + 'missing id' => 'https://giphy.com/gifs/', + 'empty slug identifier' => 'https://giphy.com/gifs/cat-', + 'embed with slug' => 'https://giphy.com/embed/cat-JIX9t2j0ZTN9S', + 'escaped id' => 'https://giphy.com/gifs/cat-%3Cscript%3E', + 'extra segment' => 'https://giphy.com/embed/JIX9t2j0ZTN9S/other', + 'lookalike' => 'https://giphy.com.example.com/gifs/JIX9t2j0ZTN9S', + 'other host' => 'https://example.com/gifs/JIX9t2j0ZTN9S', + 'credentials' => 'https://user:secret@giphy.com/gifs/JIX9t2j0ZTN9S', + 'username' => 'https://user@giphy.com/gifs/JIX9t2j0ZTN9S', + 'password' => 'https://:secret@giphy.com/gifs/JIX9t2j0ZTN9S', + 'port' => 'https://giphy.com:8443/gifs/JIX9t2j0ZTN9S', + 'scheme' => 'ftp://giphy.com/gifs/JIX9t2j0ZTN9S', +]); diff --git a/tests/Unit/Services/EmbedsTest.php b/tests/Unit/Services/EmbedsTest.php index 02386da8c..ebaf34f88 100644 --- a/tests/Unit/Services/EmbedsTest.php +++ b/tests/Unit/Services/EmbedsTest.php @@ -9,6 +9,36 @@ use App\Services\Embeds; use Uri\WhatWg\Url; +it('only embeds direct GIF URLs after the unfurler has verified them', function (): void { + expect(resolve(Embeds::class)->for('https://example.com/cat.gif'))->toBeNull() + ->and(resolve(Embeds::class)->for('https://example.com/cat.gif', 'https://cdn.example.com/cat.gif')?->toArray())->toBe([ + 'provider' => 'GIF', + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + 'aspect_ratio' => null, + 'height' => null, + 'poster' => null, + ]); +}); + +it('honors the GIF selectors independently', function (): void { + $embeds = resolve(Embeds::class); + config(['embeds.only' => ['gif']]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S'))->toBeNull() + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif')?->provider)->toBe('GIF'); + + config(['embeds.only' => ['giphy']]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S')?->provider)->toBe('GIPHY') + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif'))->toBeNull(); + + config(['embeds.enabled' => false]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S'))->toBeNull() + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif'))->toBeNull(); +}); + it('embeds links to a provider', function (): void { expect(resolve(Embeds::class)->for('https://www.youtube.com/watch?v=dQw4w9WgXcQ')?->toArray())->toBe([ 'provider' => 'YouTube', diff --git a/tests/draft-images.config.ts b/tests/draft-images.config.ts index 45bb2673d..31d9e490d 100644 --- a/tests/draft-images.config.ts +++ b/tests/draft-images.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ VITE_APP_NAME: '/dev/null', }, include: [ + 'tests/Unit/GifEmbed.test.tsx', + 'tests/Unit/GifEmbedPlayback.test.tsx', + 'tests/Unit/GifPlaybackHook.test.tsx', 'tests/Unit/CommitPhoto.test.tsx', 'tests/Unit/CommitSize.test.tsx', 'tests/Unit/ThreadHistory.test.tsx', diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 2a1a766d9..19288397a 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,11 +1,16 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"], + "types": [ + "node" + ], "allowImportingTsExtensions": true }, "include": [ "Unit/**/*.test.ts", + "Unit/GifEmbed.test.tsx", + "Unit/GifEmbedPlayback.test.tsx", + "Unit/GifPlaybackHook.test.tsx", "Unit/CommitPhoto.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx",