Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion resources/js/components/commit-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -286,14 +287,15 @@ function CommitComposerBody({
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
url={linkCard.link}
images={attachments.images}
descriptions={attachments.descriptions}
className="col-start-1 row-start-1 py-1.5"
/>
)}
</div>

<DraftLinkPreview url={isIssue ? null : linkCard.link} />

{issue.hint && (
<IssueCommandHint
command={issue.hint}
Expand Down
4 changes: 3 additions & 1 deletion resources/js/components/commit-quote-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PreviewToggle,
useComposerPreview,
} from '@/components/composer-preview';
import { DraftLinkPreview } from '@/components/draft-link-preview';
import InputError from '@/components/input-error';
import { LinkCardPicker } from '@/components/link-card-picker';
import { MentionTextarea } from '@/components/mention-textarea';
Expand Down Expand Up @@ -285,14 +286,15 @@ function QuoteComposer({
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
url={linkCard.link}
images={attachments.images}
descriptions={attachments.descriptions}
className="col-start-1 row-start-1 py-1.5"
/>
)}
</div>

<DraftLinkPreview url={linkCard.link} />

<InputError
message={
errors.body ??
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/commit-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
PreviewToggle,
useComposerPreview,
} from '@/components/composer-preview';
import { DraftLinkPreview } from '@/components/draft-link-preview';
import {
graphDash,
graphStrokeWidth,
Expand Down Expand Up @@ -1500,7 +1501,6 @@ function ReplyComposer({
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
url={linkCard.link}
images={attachments.images}
descriptions={attachments.descriptions}
className="col-start-1 row-start-1 px-1"
Expand All @@ -1511,6 +1511,7 @@ function ReplyComposer({
message={errors.body ?? errors.link_preview_url}
className="px-1"
/>
<DraftLinkPreview url={linkCard.link} />
{!preview.isPreviewing && (
<ImagePickerPreviews
images={attachments.images}
Expand Down
37 changes: 2 additions & 35 deletions resources/js/components/composer-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useHttp, usePage } from '@inertiajs/react';
import { Eye, Link2 } from 'lucide-react';
import { Eye } from 'lucide-react';
import type { KeyboardEvent, RefObject } from 'react';
import { useId, useState } from 'react';
import CommitPreviewController from '@/actions/App/Http/Controllers/CommitPreviewController';
Expand Down Expand Up @@ -29,11 +29,6 @@ const isPreviewShortcut = (event: KeyboardEvent) =>

export type ComposerPreview = ReturnType<typeof useComposerPreview>;

/**
* 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,
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
if (!isPreviewShortcut(event)) {
return false;
Expand Down Expand Up @@ -172,13 +165,11 @@ export function ComposerPreviewBody({
preview,
images,
descriptions = [],
url,
className,
}: {
preview: ComposerPreview;
images: File[];
descriptions?: string[];
url: string | null;
className?: string;
}) {
return (
Expand Down Expand Up @@ -211,34 +202,10 @@ export function ComposerPreviewBody({
description: descriptions[index]?.trim() || null,
}))}
/>

{url && (
<div
className="mt-3 flex items-center gap-3 rounded-lg border border-dashed px-3 py-2"
data-test="link-card-preview"
>
<Link2
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0">
<p className="truncate font-mono text-[13px]">
{url.replace(/^https?:\/\//i, '')}
</p>
<p className="text-sm leading-snug text-muted-foreground">
Unfurls into a link card after you commit.
</p>
</div>
</div>
)}
</div>
);
}

/**
* 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,
Expand Down
98 changes: 98 additions & 0 deletions resources/js/components/draft-link-preview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-3 flex items-center gap-3 rounded-lg border border-dashed px-3 py-2">
<Link2
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0">
<p className="truncate font-mono text-[13px]">
{url.replace(/^https?:\/\//i, '')}
</p>
<p
className="text-sm leading-snug text-muted-foreground"
role="status"
>
{isLoading
? 'Loading link preview…'
: 'Link preview unavailable'}
</p>
</div>
</div>
);
}

export function DraftLinkPreview({ url }: { url: string | null }) {
const { preview, isLoading } = useDraftLinkPreview(url);

if (url === null) {
return null;
}

return (
<div data-test="link-card-preview" aria-busy={isLoading}>
<DraftLinkPreviewContent
url={url}
preview={preview}
isLoading={isLoading}
/>
</div>
);
}

function DraftLinkPreviewContent({
url,
preview,
isLoading,
}: {
url: string;
preview: LinkPreviewData | null;
isLoading: boolean;
}) {
if (preview) {
return (
<LinkPreview
key={preview.url}
preview={preview}
refreshUrl={DraftLinkPreviewController.show.url({
query: { url },
})}
emptyState={
<DraftLinkPreviewFallback url={url} isLoading={false} />
}
/>
);
}

if (isGifUrl(url) || hostname(url) === 'giphy.com') {
return (
<GifEmbed
preview={{
url,
title: null,
description: null,
image_url: null,
site_name: null,
is_pending: isLoading,
}}
/>
);
}

return <DraftLinkPreviewFallback url={url} isLoading={isLoading} />;
}
81 changes: 5 additions & 76 deletions resources/js/components/link-preview.tsx
Original file line number Diff line number Diff line change
@@ -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<LinkPreviewData | null>(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 (
<div
Expand Down Expand Up @@ -109,10 +36,12 @@ export function LinkPreview({
refreshUrl,
preview: initialPreview,
isStatic = false,
emptyState = null,
}: {
refreshUrl: string;
preview: LinkPreviewData;
isStatic?: boolean;
emptyState?: ReactNode;
}) {
const { preview, isWaiting } = useFetchedLinkPreview(
refreshUrl,
Expand Down Expand Up @@ -142,7 +71,7 @@ export function LinkPreview({
}

if (!preview.title) {
return null;
return emptyState;
}

const content = (
Expand Down
Loading