Skip to content
Merged
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
27 changes: 21 additions & 6 deletions resources/js/components/commit-quote-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -135,6 +136,7 @@ function QuoteComposer({
updateImages,
stashKey,
);
const linkCard = useLinkCard(body);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const commitRules = useCommitRules();
const keys = useKeyLabels();
Expand Down Expand Up @@ -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
}
Expand All @@ -275,7 +279,7 @@ function QuoteComposer({
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
url={typedLinks(body)[0] ?? null}
url={linkCard.link}
images={attachments.images}
descriptions={attachments.descriptions}
className="col-start-1 row-start-1 py-1.5"
Expand All @@ -284,7 +288,11 @@ function QuoteComposer({
</div>

<InputError
message={errors.body ?? errors.quoted_commit_id}
message={
errors.body ??
errors.link_preview_url ??
errors.quoted_commit_id
}
/>

<CommitRulesDialog
Expand All @@ -309,8 +317,8 @@ function QuoteComposer({

<QuotedCommit commit={commit} isPreview />

<div className="sticky -bottom-4 -mb-4 flex items-center justify-end gap-3 bg-background pt-3 pb-4 sm:-bottom-5 sm:-mb-5 sm:pb-5">
<div className="mr-auto flex items-center gap-2">
<div className="sticky -bottom-4 -mb-4 flex flex-wrap items-center justify-end gap-x-3 gap-y-2 bg-background pt-3 pb-4 sm:-bottom-5 sm:-mb-5 sm:pb-5">
<div className="mr-auto flex min-w-0 items-center gap-2">
<ImagePickerButton
count={images.length}
disabled={processing}
Expand All @@ -322,11 +330,18 @@ function QuoteComposer({
preview={preview}
className="-ml-2 size-8"
/>
<LinkCardPicker
linkCard={linkCard}
disabled={processing}
className="-ml-1"
labelClassName="max-sm:sr-only"
/>
{isRestored && (
<StashLabel
onDrop={() => {
attachments.clear();
drop();
linkCard.reset();
document
.getElementById(id)
?.focus();
Expand Down
52 changes: 52 additions & 0 deletions tests/Unit/QuoteDraft.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>[] {
if (!React.isValidElement(node)) {
Expand Down Expand Up @@ -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<string, string>;
}) => 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,
),
);
});
Loading