diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 8ed85b896..981bd8c25 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -216,7 +216,7 @@ Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](reso ### Draft previews -Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote before submitting it. Preview shows the rendered code, emoji, mentions, links, and images, and identifies the link selected for a card. Esc returns from preview to writing. +Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote. The selected link card also loads while writing; direct GIFs appear after verification. Selected URLs and metadata share the posted-link cache, even if the draft is never published. Uncached links load in the background. Esc returns to writing. Draft text is not scanned for secrets before publishing or saving locally. diff --git a/README.md b/README.md index ee62e1ceb..49e0270bb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ 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. +Link cards preview while writing. 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. diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a8898760d..2f1ee1aa6 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -19,6 +19,7 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { DraftLinkPreview } from '@/components/draft-link-preview'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import type { UseIssueCommandReturn } from '@/components/issue-command'; @@ -286,7 +287,6 @@ function CommitComposerBody({ {preview.isPreviewing && ( + + {issue.hint && ( + + + {!preview.isPreviewing && ( export type ComposerPreview = ReturnType; -/** - * Switches a composer between writing and previewing its draft as the commit it becomes. The draft is rendered right - * away, and its code blocks are highlighted and its mentions linked once the server, the only place code is - * highlighted and handles are looked up, sends them back. - */ export function useComposerPreview({ body, textareaRef, @@ -106,9 +101,7 @@ export function useComposerPreview({ previewId, toggle: (moveFocus = false) => show(!isPreviewing, moveFocus), close: () => setIsPreviewing(false), - /** - * Handles ⌘/Ctrl + Shift + P in the textarea, and tells whether it did. - */ + handleShortcut: (event: KeyboardEvent) => { if (!isPreviewShortcut(event)) { return false; @@ -172,13 +165,11 @@ export function ComposerPreviewBody({ preview, images, descriptions = [], - url, className, }: { preview: ComposerPreview; images: File[]; descriptions?: string[]; - url: string | null; className?: string; }) { return ( @@ -211,34 +202,10 @@ export function ComposerPreviewBody({ description: descriptions[index]?.trim() || null, }))} /> - - {url && ( -
- -
-

- {url.replace(/^https?:\/\//i, '')} -

-

- Unfurls into a link card after you commit. -

-
-
- )} ); } -/** - * Shows a poll draft as it reads once committed: open right away for admins, whose commits skip moderation, and - * waiting for approval for everyone else. - */ export function ComposerPollPreview({ poll, className, diff --git a/resources/js/components/draft-link-preview.tsx b/resources/js/components/draft-link-preview.tsx new file mode 100644 index 000000000..b4b5b6b30 --- /dev/null +++ b/resources/js/components/draft-link-preview.tsx @@ -0,0 +1,98 @@ +import { Link2 } from 'lucide-react'; +import DraftLinkPreviewController from '@/actions/App/Http/Controllers/DraftLinkPreviewController'; +import { GifEmbed } from '@/components/gif-embed'; +import { LinkPreview } from '@/components/link-preview'; +import { useDraftLinkPreview } from '@/hooks/use-draft-link-preview'; +import { isGifUrl } from '@/lib/gifs'; +import { hostname } from '@/lib/urls'; +import type { LinkPreview as LinkPreviewData } from '@/types'; + +function DraftLinkPreviewFallback({ + url, + isLoading, +}: { + url: string; + isLoading: boolean; +}) { + return ( +
+ +
+

+ {url.replace(/^https?:\/\//i, '')} +

+

+ {isLoading + ? 'Loading link preview…' + : 'Link preview unavailable'} +

+
+
+ ); +} + +export function DraftLinkPreview({ url }: { url: string | null }) { + const { preview, isLoading } = useDraftLinkPreview(url); + + if (url === null) { + return null; + } + + return ( +
+ +
+ ); +} + +function DraftLinkPreviewContent({ + url, + preview, + isLoading, +}: { + url: string; + preview: LinkPreviewData | null; + isLoading: boolean; +}) { + if (preview) { + return ( + + } + /> + ); + } + + if (isGifUrl(url) || hostname(url) === 'giphy.com') { + return ( + + ); + } + + return ; +} diff --git a/resources/js/components/link-preview.tsx b/resources/js/components/link-preview.tsx index 700848f6d..979c38abb 100644 --- a/resources/js/components/link-preview.tsx +++ b/resources/js/components/link-preview.tsx @@ -1,85 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { GifEmbed } from '@/components/gif-embed'; import { LinkEmbed } from '@/components/link-embed'; import { Skeleton } from '@/components/ui/skeleton'; +import { useFetchedLinkPreview } from '@/hooks/use-fetched-link-preview'; import { isGifUrl } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; import type { LinkPreview as LinkPreviewData } from '@/types'; -const POLL_DELAYS = [1000, 2000, 3000, 5000, 8000, 13000]; - -/** - * A link is unfurled in the background after its commit or message is posted, so a pending preview is fetched again - * from the refresh URL until the page behind it has been read, instead of only showing up after a reload. - */ -function useFetchedLinkPreview( - refreshUrl: string, - preview: LinkPreviewData, -): { preview: LinkPreviewData; isWaiting: boolean } { - const [fetched, setFetched] = useState(null); - const [hasGivenUp, setHasGivenUp] = useState(false); - - useEffect(() => { - if (!preview.is_pending) { - return; - } - - const controller = new AbortController(); - let timeout = 0; - - const poll = async (attempt: number) => { - try { - const response = await fetch(refreshUrl, { - headers: { Accept: 'application/json' }, - cache: 'no-store', - signal: controller.signal, - }); - - if (!response.ok) { - setHasGivenUp(true); - - return; - } - - const { link_preview } = (await response.json()) as { - link_preview: LinkPreviewData | null; - }; - - if (link_preview && !link_preview.is_pending) { - setFetched(link_preview); - - return; - } - } catch { - if (controller.signal.aborted) { - return; - } - } - - if (attempt + 1 < POLL_DELAYS.length) { - timeout = window.setTimeout( - () => void poll(attempt + 1), - POLL_DELAYS[attempt + 1], - ); - } else { - setHasGivenUp(true); - } - }; - - timeout = window.setTimeout(() => void poll(0), POLL_DELAYS[0]); - - return () => { - window.clearTimeout(timeout); - controller.abort(); - }; - }, [refreshUrl, preview.is_pending]); - - const current = - preview.is_pending && fetched?.url === preview.url ? fetched : preview; - - return { preview: current, isWaiting: current.is_pending && !hasGivenUp }; -} - function LinkPreviewSkeleton() { return (
{ + try { + const response = await fetch( + DraftLinkPreviewController.show.url({ query: { url } }), + { headers: { Accept: 'application/json' }, signal }, + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as PreviewResponse; + + return data.link_preview; + } catch { + return null; + } +} + +export function useDraftLinkPreview(url: string | null) { + const [result, setResult] = useState(null); + + useEffect(() => { + if (url === null) { + return; + } + + const controller = new AbortController(); + const load = async () => { + const preview = await fetchDraftLinkPreview(url, controller.signal); + + if (controller.signal.aborted) { + return; + } + + setResult({ url, preview }); + }; + + const timeout = window.setTimeout(() => void load(), PREVIEW_DELAY_MS); + + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [url]); + + return { + preview: result?.url === url ? result.preview : null, + isLoading: url !== null && result?.url !== url, + }; +} diff --git a/resources/js/hooks/use-fetched-link-preview.ts b/resources/js/hooks/use-fetched-link-preview.ts new file mode 100644 index 000000000..f2c1d3824 --- /dev/null +++ b/resources/js/hooks/use-fetched-link-preview.ts @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import type { LinkPreview } from '@/types'; + +const POLL_DELAYS = [1000, 2000, 3000, 5000, 8000, 13000]; +const MAX_RETRY_AFTER_MS = 60_000; +const MILLISECONDS_PER_SECOND = 1000; +const HTTP_TOO_MANY_REQUESTS = 429; + +type PollCycle = { refreshUrl: string; url: string; isPending: boolean }; +type PollState = PollCycle & { + fetched: LinkPreview | null; + hasGivenUp: boolean; +}; +type PreviewResponse = { link_preview: LinkPreview | null }; +type PollResponse = { response: Response; preview: LinkPreview | null }; + +async function requestLinkPreview( + refreshUrl: string, + signal: AbortSignal, +): Promise { + try { + const response = await fetch(refreshUrl, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal, + }); + + if (signal.aborted) { + return null; + } + + if (!response.ok) { + return { response, preview: null }; + } + + const data = (await response.json()) as PreviewResponse; + + if (signal.aborted) { + return null; + } + + return { response, preview: data.link_preview }; + } catch { + return null; + } +} + +function nextPollDelay( + response: Response | null, + fallback: number | null, +): number | null { + if (response === null || response.ok) { + return fallback; + } + + if (response.status !== HTTP_TOO_MANY_REQUESTS || fallback === null) { + return null; + } + + return retryAfterDelay(response, fallback); +} + +function retryAfterDelay(response: Response, fallback: number): number | null { + const retryAfter = response.headers.get('Retry-After'); + + if (retryAfter === null) { + return fallback; + } + + const seconds = Number(retryAfter); + + if (Number.isFinite(seconds)) { + return retryAfterMilliseconds( + seconds * MILLISECONDS_PER_SECOND, + fallback, + ); + } + + const date = Date.parse(retryAfter); + + if (Number.isNaN(date)) { + return fallback; + } + + return retryAfterMilliseconds(Math.max(date - Date.now(), 0), fallback); +} + +function retryAfterMilliseconds( + delay: number, + fallback: number, +): number | null { + if (delay > MAX_RETRY_AFTER_MS) { + return null; + } + + return Math.max(fallback, delay); +} + +function isCurrentPollCycle(state: PollState, cycle: PollCycle): boolean { + return ( + state.refreshUrl === cycle.refreshUrl && + state.url === cycle.url && + state.isPending === cycle.isPending + ); +} + +function syncPollState(state: PollState, cycle: PollCycle): PollState { + if (isCurrentPollCycle(state, cycle)) { + return state; + } + + return { ...cycle, fetched: null, hasGivenUp: false }; +} + +export function useFetchedLinkPreview( + refreshUrl: string, + preview: LinkPreview, +): { preview: LinkPreview; isWaiting: boolean } { + const { url, is_pending: isPending } = preview; + const cycle = { refreshUrl, url, isPending }; + const [pollState, setPollState] = useState({ + ...cycle, + fetched: null, + hasGivenUp: false, + }); + const currentPollState = syncPollState(pollState, cycle); + + if (currentPollState !== pollState) { + setPollState(currentPollState); + } + + useEffect(() => { + if (!isPending) { + return; + } + + const activeCycle = { refreshUrl, url, isPending }; + const controller = new AbortController(); + let timeout = 0; + + const giveUp = () => { + setPollState((state) => { + if (!isCurrentPollCycle(state, activeCycle)) { + return state; + } + + return { ...state, hasGivenUp: true }; + }); + }; + + const poll = async (attempt: number) => { + const result = await requestLinkPreview( + refreshUrl, + controller.signal, + ); + + if (controller.signal.aborted) { + return; + } + + const fetched = result?.preview; + + if (fetched && !fetched.is_pending) { + setPollState((state) => { + if (!isCurrentPollCycle(state, activeCycle)) { + return state; + } + + return { ...state, fetched }; + }); + + return; + } + + const delay = nextPollDelay( + result?.response ?? null, + POLL_DELAYS[attempt + 1] ?? null, + ); + + if (delay === null) { + giveUp(); + + return; + } + + timeout = window.setTimeout(() => void poll(attempt + 1), delay); + }; + + timeout = window.setTimeout(() => void poll(0), POLL_DELAYS[0]); + + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [isPending, refreshUrl, url]); + + const current = + isPending && currentPollState.fetched?.url === url + ? currentPollState.fetched + : preview; + + return { + preview: current, + isWaiting: current.is_pending && !currentPollState.hasGivenUp, + }; +} diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 218a02af3..d3babea76 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -14,6 +14,7 @@ import { useComposerPreview, type ComposerPreview, } from '../../resources/js/components/composer-preview'; +import { DraftLinkPreview } from '../../resources/js/components/draft-link-preview'; import { hasMention } from '../../resources/js/components/linkified-text'; import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; import { bodySegments } from '../../resources/js/lib/code'; @@ -205,16 +206,8 @@ test('keeps the composer avatar unlinked inside the dialog', () => { }); test('previews the chosen link card instead of the first link in the body', () => { - const preview = previewFor({ - body: 'https://first.example.com https://second.example.com', - isSubmittable: true, - }); const markup = renderPage( - , + , ); const card = markup.match(/data-test="link-card-preview"[\s\S]*/)?.[0]; @@ -229,7 +222,10 @@ test('omits the preview card when the author chooses no card', () => { isSubmittable: true, }); const markup = renderPage( - , + <> + + + , ); assert.ok(markup.includes('href="https://first.example.com"')); @@ -253,6 +249,15 @@ test('retains the link card picker and scheduling beside preview', () => { assert.ok(!/\sdisabled(?:=|\s|>)/.test(schedule)); }); +test('shows a pending GIF card while writing before Preview or Commit', () => { + const markup = renderComposer('https://example.com/cat.gif'); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes('data-test="gif-animation"')); + assert.ok(!markup.includes('data-test="composer-preview"')); +}); + const secret = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD'; test('keeps token-shaped text editable with preview and no secret warning', () => { @@ -325,7 +330,6 @@ test('links the mentions the server found in the preview', () => { mentions: { alice: '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b' }, }} images={[]} - url={null} />, ); @@ -340,7 +344,7 @@ test('shows mentions as text until the server has found them', () => { isSubmittable: true, }); const markup = renderPage( - , + , ); assert.equal(preview.mentions, null); @@ -360,8 +364,7 @@ test('previews attached image descriptions without a published photo link', () = preview={previewFor({ isSubmittable: true })} images={images} descriptions={[' Build output in the terminal ', '']} - url={null} - />, + />, ); assert.ok(markup.includes('alt="Build output in the terminal"')); @@ -390,7 +393,6 @@ for (const pasteisMode of [false, true]) { ), pasteisMode, diff --git a/tests/Unit/DraftLinkPreview.test.tsx b/tests/Unit/DraftLinkPreview.test.tsx new file mode 100644 index 000000000..db9873cb9 --- /dev/null +++ b/tests/Unit/DraftLinkPreview.test.tsx @@ -0,0 +1,162 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview } from '../../resources/js/types/commit'; + +let result: { + preview: LinkPreview | null; + isLoading: boolean; +} = { preview: null, isLoading: true }; + +vi.doMock('../../resources/js/hooks/use-draft-link-preview', () => ({ + useDraftLinkPreview: () => result, +})); + +const { DraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); + +const directGif = 'https://images.example.test/cat.gif'; + +function render(url: string | null): string { + return renderToStaticMarkup(); +} + +afterEach(() => { + result = { preview: null, isLoading: true }; +}); + +test('renders nothing without a selected link card', () => { + assert.equal(render(null), ''); +}); + +test('keeps an unverified GIF pending without requesting media or exposing Play', () => { + const markup = render(directGif); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('aspect-square')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes(' { + const markup = render('https://www.giphy.com/gifs/JIX9t2j0ZTN9S'); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('aspect-square')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes(' { + let refreshUrl: string | null = null; + + vi.resetModules(); + vi.doMock('../../resources/js/hooks/use-draft-link-preview', () => ({ + useDraftLinkPreview: () => ({ + preview: pendingPreview('https://example.test/article'), + isLoading: false, + }), + })); + vi.doMock('../../resources/js/components/link-preview', () => ({ + LinkPreview: ({ refreshUrl: url }: { refreshUrl: string }) => { + refreshUrl = url; + + return
; + }, + })); + + const { DraftLinkPreview: PendingDraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); + const markup = renderToStaticMarkup( + , + ); + + assert.ok(markup.includes('data-test="shared-link-preview"')); + assert.equal( + refreshUrl, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', + ); + + vi.doUnmock('../../resources/js/components/link-preview'); + vi.resetModules(); +}); + +test('renders verified Giphy and direct GIFs through the shared GIF component', () => { + for (const preview of [ + verifiedGif('https://giphy.com/gifs/JIX9t2j0ZTN9S', { + provider: 'GIPHY', + src: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + poster: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy_s.gif', + }), + verifiedGif(directGif, { + provider: 'GIF', + src: directGif, + poster: null, + }), + ]) { + result = { preview, isLoading: false }; + const markup = render(preview.url); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes(`href="${preview.url}"`)); + } +}); + +test('shows loading and unavailable states for non-GIF links', () => { + const url = 'https://example.test/article'; + + assert.ok(render(url).includes('Loading link preview…')); + + result = { preview: null, isLoading: false }; + + assert.ok(render(url).includes('Link preview unavailable')); +}); + +test('keeps an unavailable card when a completed draft has no metadata', () => { + const url = 'https://example.test/unavailable'; + result = { + preview: { + ...pendingPreview(url), + is_pending: false, + }, + isLoading: false, + }; + + const markup = render(url); + + assert.ok(markup.includes('data-test="link-card-preview"')); + assert.ok(markup.includes('Link preview unavailable')); +}); + +function verifiedGif( + url: string, + embed: Omit< + NonNullable, + 'kind' | 'aspect_ratio' | 'height' + >, +): LinkPreview { + return { + url, + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: false, + embed: { kind: 'gif', aspect_ratio: null, height: null, ...embed }, + }; +} + +function pendingPreview(url: string): LinkPreview { + return { + url, + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: true, + embed: null, + }; +} diff --git a/tests/Unit/DraftLinkPreviewRequest.test.tsx b/tests/Unit/DraftLinkPreviewRequest.test.tsx new file mode 100644 index 000000000..586d3be58 --- /dev/null +++ b/tests/Unit/DraftLinkPreviewRequest.test.tsx @@ -0,0 +1,183 @@ +/// +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import * as React from 'react'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview } from '../../resources/js/types/commit'; + +type Effect = () => void | (() => void); +type StateUpdate = { url: string; preview: LinkPreview | null }; + +let effects: Effect[] = []; +let states: unknown[] = []; +let stateIndex = 0; +const updates: StateUpdate[] = []; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: Effect) => effects.push(effect), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [states[index], (update: StateUpdate) => updates.push(update)]; + }, +})); + +const { useDraftLinkPreview } = + await import('../../resources/js/hooks/use-draft-link-preview'); + +let nextTimer = 0; +const timers = new Map void>(); + +vi.stubGlobal('window', { + setTimeout: (callback: () => void) => { + nextTimer++; + timers.set(nextTimer, callback); + + return nextTimer; + }, + clearTimeout: (timer: number) => timers.delete(timer), +}); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + effects = []; + states = []; + stateIndex = 0; + updates.length = 0; + timers.clear(); + nextTimer = 0; +}); + +test('debounces one request for the chosen URL', () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + const url = 'https://example.test/a?tag=gif'; + + const cleanup = mount(url); + + assert.equal(fetch.mock.calls.length, 0); + runTimers(); + + assert.equal(fetch.mock.calls.length, 1); + assert.equal( + fetch.mock.calls[0]?.[0], + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Fa%3Ftag%3Dgif', + ); + + cleanup(); +}); + +test('aborts an in-flight request when the selected URL changes without applying its late result', async () => { + let resolveResponse: (response: Response) => void; + let signal: AbortSignal | undefined; + const fetch = vi.fn( + (_url: string, options: { signal: AbortSignal }) => + new Promise((resolve) => { + signal = options.signal; + resolveResponse = resolve; + }), + ); + vi.stubGlobal('fetch', fetch); + + const firstCleanup = mount('https://example.test/first'); + runTimers(); + + firstCleanup(); + mount('https://example.test/second'); + resolveResponse!({ + ok: true, + json: async () => ({ link_preview: null }), + } as Response); + await setImmediate(); + + assert.equal(signal?.aborted, true); + assert.deepEqual(updates, []); +}); + +test('aborts an in-flight request when the component unmounts', () => { + let signal: AbortSignal | undefined; + const fetch = vi.fn((_url: string, options: { signal: AbortSignal }) => { + signal = options.signal; + + return new Promise(() => {}); + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount('https://example.test/first'); + runTimers(); + + cleanup(); + + assert.equal(signal?.aborted, true); +}); + +test('applies the returned metadata to the selected preview', async () => { + const url = 'https://example.test/preview'; + const preview: LinkPreview = { + url, + title: 'Example', + description: null, + image_url: null, + site_name: null, + is_pending: false, + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ link_preview: preview }), + })), + ); + + mount(url); + runTimers(); + await setImmediate(); + + assert.deepEqual(updates, [{ url, preview }]); +}); + +test.each([ + [ + 'the request fails', + async () => { + throw new Error('Offline'); + }, + ], + ['the server rejects the request', async () => ({ ok: false })], + [ + 'the response is malformed', + async () => ({ + ok: true, + json: async () => { + throw new SyntaxError('Invalid JSON'); + }, + }), + ], +])('leaves the preview unavailable when %s', async (_reason, fetch) => { + const url = 'https://example.test/preview'; + vi.stubGlobal('fetch', vi.fn(fetch)); + + mount(url); + runTimers(); + await setImmediate(); + + assert.deepEqual(updates, [{ url, preview: null }]); +}); + +function mount(url: string | null): () => void { + stateIndex = 0; + useDraftLinkPreview(url); + const effect = effects.at(-1); + + return effect?.() ?? (() => {}); +} + +function runTimers(): void { + for (const callback of timers.values()) { + callback(); + } + + timers.clear(); +} diff --git a/tests/Unit/LinkPreviewRequest.test.tsx b/tests/Unit/LinkPreviewRequest.test.tsx new file mode 100644 index 000000000..2ef44c8c1 --- /dev/null +++ b/tests/Unit/LinkPreviewRequest.test.tsx @@ -0,0 +1,431 @@ +/// +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview as LinkPreviewData } from '../../resources/js/types/commit'; + +type Effect = () => void | (() => void); +type EffectState = { + dependencies: readonly unknown[] | undefined; + effect: Effect; + cleanup?: () => void; + shouldRun: boolean; +}; + +let effectStates: EffectState[] = []; +let states: unknown[] = []; +let stateIndex = 0; +let effectIndex = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: Effect, dependencies?: readonly unknown[]) => { + const index = effectIndex++; + const previous = effectStates[index]; + const shouldRun = + previous === undefined || + !haveSameDependencies(previous.dependencies, dependencies); + + effectStates[index] = { + cleanup: previous?.cleanup, + dependencies, + effect, + shouldRun, + }; + }, + 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; + + if (Object.is(states[index], next)) { + return; + } + + states[index] = next; + }, + ]; + }, +})); + +const { LinkPreview } = + await import('../../resources/js/components/link-preview'); + +type Timer = { callback: () => void; delay: number }; +let nextTimer = 0; +const timers = new Map(); + +vi.stubGlobal('window', { + setTimeout: (callback: () => void, delay: number) => { + nextTimer++; + timers.set(nextTimer, { callback, delay }); + + return nextTimer; + }, + clearTimeout: (timer: number) => timers.delete(timer), +}); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + cleanupEffects(); + effectStates = []; + states = []; + stateIndex = 0; + effectIndex = 0; + timers.clear(); + nextTimer = 0; +}); + +test('retries a rate-limited draft poll instead of hiding its pending card', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Headers({ 'Retry-After': '60' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Recovered preview', + }, + }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.ok(renderPreview().includes('data-test="link-preview-skeleton"')); + assert.equal(timers.size, 1); + assert.equal([...timers.values()][0]?.delay, 60000); + + await runNextTimer(); + + const markup = renderPreview(); + + assert.ok(markup.includes('Recovered preview')); + cleanup(); +}); + +test('gives up only after the bounded poll cycle remains pending', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + assert.equal(fetch.mock.calls.length, 6); + assert.equal(timers.size, 0); + const markup = renderPreview( + pendingPreview, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', +
+ Link preview unavailable +
, + ); + + assert.ok(markup.includes('data-test="draft-preview-unavailable"')); + runPendingEffects(); + assert.equal(timers.size, 0); + cleanup(); +}); + +test('starts a fresh poll cycle when a pending preview changes URL', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + const nextPreview = { + ...pendingPreview, + url: 'https://example.test/another-article', + }; + const markup = renderPreview(nextPreview); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('starts a fresh poll cycle when its refresh URL changes', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + const markup = renderPreview( + pendingPreview, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle&refresh=1', + ); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('starts a fresh poll cycle when a preview becomes pending again', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + mount({ ...pendingPreview, is_pending: false }); + + const markup = renderPreview(); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('ignores a previous cycle response that completes after a new render', async () => { + let resolvePreview: (value: unknown) => void; + const deferredPreview = new Promise((resolve) => { + resolvePreview = resolve; + }); + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => deferredPreview, + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + const nextPreview = { + ...pendingPreview, + url: 'https://example.test/another-article', + }; + + assert.ok( + renderPreview(nextPreview).includes( + 'data-test="link-preview-skeleton"', + ), + ); + + resolvePreview!({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Stale preview', + }, + }); + await setImmediate(); + + const markup = renderPreview(nextPreview); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + assert.equal(markup.includes('Stale preview'), false); + cleanup(); +}); + +test('gives up when a rate limit outlasts the visible retry window', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: new Headers({ 'Retry-After': '61' }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 0); + const markup = renderPreview( + pendingPreview, + undefined, +
+ Link preview unavailable +
, + ); + + assert.ok(markup.includes('data-test="draft-preview-unavailable"')); + cleanup(); +}); + +test('does not retry a rejected poll response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 403 }), + ); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 0); + assert.ok( + renderPreview( + pendingPreview, + undefined, +
Unavailable
, + ).includes('Unavailable'), + ); + cleanup(); +}); + +test.each([ + [ + 'a network failure', + async () => { + throw new Error('Offline'); + }, + ], + [ + 'invalid JSON', + async () => ({ + ok: true, + json: async () => { + throw new SyntaxError('Invalid JSON'); + }, + }), + ], +])('retries a pending preview after %s', async (_reason, request) => { + const fetch = vi + .fn() + .mockImplementationOnce(request) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Recovered preview', + }, + }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 1); + assert.ok(renderPreview().includes('data-test="link-preview-skeleton"')); + await runNextTimer(); + + assert.ok(renderPreview().includes('Recovered preview')); + cleanup(); +}); + +const pendingPreview: LinkPreviewData = { + url: 'https://example.test/article', + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: true, +}; + +function mount( + preview = pendingPreview, + refreshUrl = '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', +): () => void { + renderPreview(preview, refreshUrl); + + return runPendingEffects(); +} + +function renderPreview( + preview = pendingPreview, + refreshUrl = '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', + emptyState?: React.ReactNode, +): string { + stateIndex = 0; + effectIndex = 0; + + return renderToStaticMarkup( + LinkPreview({ + refreshUrl, + preview, + emptyState, + }), + ); +} + +function runPendingEffects(): () => void { + for (const effectState of effectStates) { + if (!effectState.shouldRun) { + continue; + } + + effectState.cleanup?.(); + effectState.cleanup = effectState.effect() ?? undefined; + effectState.shouldRun = false; + } + + return cleanupEffects; +} + +function cleanupEffects(): void { + for (const effectState of effectStates) { + effectState.cleanup?.(); + effectState.cleanup = undefined; + } +} + +function haveSameDependencies( + previous: readonly unknown[] | undefined, + next: readonly unknown[] | undefined, +): boolean { + if (previous === undefined || next === undefined) { + return previous === next; + } + + return ( + previous.length === next.length && + previous.every((dependency, index) => + Object.is(dependency, next[index]), + ) + ); +} + +async function runNextTimer(): Promise { + const [timer, value] = timers.entries().next().value as [number, Timer]; + timers.delete(timer); + value.callback(); + await setImmediate(); +} diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 125df1f92..e96448546 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -2,13 +2,14 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { test, vi } from 'vite-plus/test'; +import { afterEach, test, vi } from 'vite-plus/test'; import type { Commit } from '../../resources/js/types'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); const updateImages = vi.fn(); const drop = vi.fn(); const user = { id: 'viewer', handle: 'viewer' }; +let isPreviewing = false; const draft = { key: 'stash:viewer:quote', body: 'A restored quote', @@ -49,7 +50,7 @@ vi.doMock('../../resources/js/components/composer-preview', async () => ({ '../../resources/js/components/composer-preview', )), useComposerPreview: () => ({ - isPreviewing: false, + isPreviewing, handleShortcut: () => false, }), })); @@ -63,6 +64,8 @@ const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = await import('../../resources/js/components/link-card-picker'); +const { DraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); const { default: InputError } = await import('../../resources/js/components/input-error'); @@ -81,6 +84,55 @@ function elements(node: ReactNode): ReactElement>[] { ]; } +afterEach(() => { + isPreviewing = false; +}); + +for (const previewing of [false, true]) { + for (const url of [ + 'https://giphy.com/gifs/JIX9t2j0ZTN9S', + 'https://images.example.test/cat.gif', + null, + ]) { + test(`quotes keep the selected draft preview for ${url ?? 'no URL'} while ${previewing ? 'previewing' : 'writing'}`, () => { + draft.body = url ?? 'A quote without a link'; + isPreviewing = previewing; + const commit = { + id: 'quoted', + user_id: 'author', + user: { handle: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement; + const form = elements(renderComposer(composer.props)).find( + (element) => element.type === Form, + )!; + const renderFields = form.props.children as (props: { + processing: boolean; + errors: Record; + }) => ReactNode; + const fields = elements( + renderFields({ processing: false, errors: {} }), + ); + const preview = fields.find( + (element) => element.type === DraftLinkPreview, + ); + + assert.ok(preview); + assert.equal(preview.props.url, url); + }); + } +} + test('quotes submit restored images and clear the complete draft only on success or discard', async () => { draft.body = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD'; const commit = { diff --git a/tests/draft-images.config.ts b/tests/draft-images.config.ts index 31d9e490d..3167fdf1e 100644 --- a/tests/draft-images.config.ts +++ b/tests/draft-images.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ 'tests/Unit/GifEmbed.test.tsx', 'tests/Unit/GifEmbedPlayback.test.tsx', 'tests/Unit/GifPlaybackHook.test.tsx', + 'tests/Unit/DraftLinkPreview.test.tsx', + 'tests/Unit/DraftLinkPreviewRequest.test.tsx', + 'tests/Unit/LinkPreviewRequest.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 19288397a..cc08a6326 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -11,6 +11,10 @@ "Unit/GifEmbed.test.tsx", "Unit/GifEmbedPlayback.test.tsx", "Unit/GifPlaybackHook.test.tsx", + "Unit/ComposerPreview.test.tsx", + "Unit/DraftLinkPreview.test.tsx", + "Unit/DraftLinkPreviewRequest.test.tsx", + "Unit/LinkPreviewRequest.test.tsx", "Unit/CommitPhoto.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx",