From 92fad3cf55f28a35828c362ee730c6312b5c0fe0 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:26:36 +0200 Subject: [PATCH 1/2] feat: choose the link card when quoting --- KNOWLEDGE_BASE.md | 4 +- .../js/components/commit-quote-button.tsx | 25 +++++++-- tests/Unit/QuoteDraft.test.tsx | 52 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 6e3cb41ae..125007498 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -233,7 +233,7 @@ Sources: [image picker](resources/js/components/commit-image-picker.tsx), [image Each link in a commit or reply counts as at most **23 characters** toward the 4,000-character allowance; shorter links count at their actual length. This also applies to quotes, scheduled commits, previews, and terminal and app submissions. The full URL is stored and followed directly, not through a redirect service. Prose URLs longer than **2,048 characters** are rejected, including the inferred `https://` scheme for bare domains. Code still counts literally toward the 4,000-character allowance. Bodies are additionally capped at 40,000 raw characters to bound storage and processing. Private messages and issue drafts keep their existing literal character limits. -URLs and bare domains in commits and replies become clickable links. Long URLs are shortened visually; hover reveals the full destination. GitHub repository links show as `owner/repo`, and issue or pull request links as `owner/repo#123`, with the GitHub mark. Links to /dev/null commits show as the author's username and a short hash. Code keeps its literal text. The first link normally gets a preview card with available title, description, and image. In the commit and reply composers, choose another link from the body or choose no card; quotes, terminal commits, and app commits always use the first link. Missing site metadata may leave a link without a rich preview. +URLs and bare domains in commits and replies become clickable links. Long URLs are shortened visually; hover reveals the full destination. GitHub repository links show as `owner/repo`, and issue or pull request links as `owner/repo#123`, with the GitHub mark. Links to /dev/null commits show as the author's username and a short hash. Code keeps its literal text. The first link normally gets a preview card with available title, description, and image. In the commit, reply, and quote composers, choose another link from the body or choose no card; terminal commits and app commits always use the first link. Missing site metadata may leave a link without a rich preview. 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. @@ -253,7 +253,7 @@ Sources: [reply validation](app/Http/Requests/CreateCommitReplyRequest.php), [co ### Quoting commits -Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview but no link-card choice, poll, or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. +Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. The embedded original only shows content the viewer may see. Deleted originals appear as `[deleted]`; otherwise-inaccessible originals appear as `[unavailable]` without exposing their author or body. There is no comment-free repost action. diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 5646e4e85..25f672ba7 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -18,7 +18,7 @@ import { useComposerPreview, } from '@/components/composer-preview'; import InputError from '@/components/input-error'; -import { typedLinks } from '@/components/linkified-text'; +import { LinkCardPicker } from '@/components/link-card-picker'; import { MentionTextarea } from '@/components/mention-textarea'; import { QuotedCommit } from '@/components/quoted-commit'; import { StashLabel } from '@/components/stash-label'; @@ -32,6 +32,7 @@ import { import { UserAvatar } from '@/components/user-avatar'; import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources'; import { useImageAttachments } from '@/hooks/use-image-attachments'; +import { useLinkCard } from '@/hooks/use-link-card'; import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; import { signIn } from '@/lib/auth'; @@ -135,6 +136,7 @@ function QuoteComposer({ updateImages, stashKey, ); + const linkCard = useLinkCard(body); const textareaRef = useRef(null); const commitRules = useCommitRules(); const keys = useKeyLabels(); @@ -262,7 +264,9 @@ function QuoteComposer({ placeholder="Add your take" autoComplete="off" aria-invalid={ - errors.body || errors.quoted_commit_id + errors.body || + errors.link_preview_url || + errors.quoted_commit_id ? true : undefined } @@ -275,7 +279,7 @@ function QuoteComposer({ {preview.isPreviewing && (
-
+
+ {isRestored && ( { attachments.clear(); drop(); + linkCard.reset(); document .getElementById(id) ?.focus(); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 632987e54..4e764be32 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -61,6 +61,10 @@ const { ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); const { StashLabel } = await import('../../resources/js/components/stash-label'); +const { LinkCardPicker } = + await import('../../resources/js/components/link-card-picker'); +const { default: InputError } = + await import('../../resources/js/components/input-error'); function elements(node: ReactNode): ReactElement>[] { if (!React.isValidElement(node)) { @@ -237,3 +241,51 @@ test('escape in the preview returns to writing without closing the quote', () => vi.unstubAllGlobals(); } }); + +test('quotes let the author pick which link gets the card', () => { + draft.body = + 'Read https://laravel.com/docs first, then https://github.com/laravel/framework'; + const commit = { + id: 'quoted', + user_id: 'author', + user: { username: '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 message = 'The link card must be one of the links in the body.'; + const fields = elements( + renderFields({ + processing: false, + errors: { link_preview_url: message }, + }), + ); + + const picker = fields.find((element) => element.type === LinkCardPicker)!; + assert.deepEqual((picker.props.linkCard as { links: string[] }).links, [ + 'https://laravel.com/docs', + 'https://github.com/laravel/framework', + ]); + assert.ok( + fields.some( + (element) => + element.type === InputError && + element.props.message === message, + ), + ); +}); From 16db645756e2163f92539b3d93d6dbf4fba031d6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:51:41 +0200 Subject: [PATCH 2/2] fix: wrap the quote footer when its controls do not fit --- resources/js/components/commit-quote-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 25f672ba7..a96223f6d 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -317,7 +317,7 @@ function QuoteComposer({ -
+