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 @@ -260,7 +260,7 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController

Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting, a **Wrap lines** toggle, and a copy control. A title after the language, as in ```` ```php title="app/Models/User.php" ````, labels the block in place of the language. Text outside code supports emoji shortcodes, clickable URLs, `@username` mentions, and `#hashtags`, which open commit search for signed-in members and stay plain text for guests. This is developer-oriented text formatting, not a full rich-text editor.

The smiley button in the commit and reply composers opens the same searchable emoji picker as reactions in remotes. Picking an emoji inserts it where the caret was, or over the selected text, and puts the caret right after it.
The smiley button in the commit, reply and quote composers opens the same searchable emoji picker as reactions in remotes. Picking an emoji inserts it where the caret was, or over the selected text, and puts the caret right after it.

In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal.

Expand Down
19 changes: 18 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 { EmojiPickerButton } from '@/components/emoji-picker';
import InputError from '@/components/input-error';
import { LinkCardPicker } from '@/components/link-card-picker';
import { MentionTextarea } from '@/components/mention-textarea';
Expand All @@ -32,6 +33,7 @@ import {
} from '@/components/ui/dialog';
import { UserAvatar } from '@/components/user-avatar';
import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources';
import { useEmojiInsertion } from '@/hooks/use-emoji-insertion';
import { useImageAttachments } from '@/hooks/use-image-attachments';
import { useLinkCard } from '@/hooks/use-link-card';
import { useOwnsCommit } from '@/hooks/use-owns-commit';
Expand Down Expand Up @@ -152,6 +154,12 @@ function QuoteComposer({
textareaRef,
isSubmittable: isQuotable,
});
const insertEmoji = useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength: maxRawBodyLength,
});

const submitOnModEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (preview.handleShortcut(event)) {
Expand Down Expand Up @@ -281,10 +289,19 @@ function QuoteComposer({
}
aria-describedby={`${id}-count`}
className={cn(
'col-start-1 row-start-1 block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none',
'col-start-1 row-start-1 block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 pr-9 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none',
{ invisible: preview.isPreviewing },
)}
/>
{!preview.isPreviewing && (
<div className="sticky bottom-0 col-start-1 row-start-1 self-end justify-self-end">
<EmojiPickerButton
disabled={processing}
onPick={insertEmoji}
className="size-8"
/>
</div>
)}
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
Expand Down
71 changes: 70 additions & 1 deletion tests/Unit/QuoteDraft.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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',
Expand Down Expand Up @@ -49,7 +50,7 @@ vi.doMock('../../resources/js/components/composer-preview', async () => ({
'../../resources/js/components/composer-preview',
)),
useComposerPreview: () => ({
isPreviewing: false,
isPreviewing,
handleShortcut: () => false,
}),
}));
Expand All @@ -63,6 +64,10 @@ const { StashLabel } =
await import('../../resources/js/components/stash-label');
const { LinkCardPicker } =
await import('../../resources/js/components/link-card-picker');
const { EmojiPickerButton } =
await import('../../resources/js/components/emoji-picker');
const { MentionTextarea } =
await import('../../resources/js/components/mention-textarea');
const { default: InputError } =
await import('../../resources/js/components/input-error');

Expand All @@ -81,6 +86,70 @@ function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
];
}

test('quotes insert picked emoji over the selection and hide the picker in preview', () => {
draft.body = 'Shipping today';
draft.updateBody.mockClear();
const commit = {
id: 'quoted',
author: { id: 'author', 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 renderFields = (processing = false) => {
const form = elements(renderComposer(composer.props)).find(
(element) => element.type === Form,
)!;
const children = form.props.children as (props: {
processing: boolean;
errors: Record<string, string>;
}) => ReactNode;

return elements(children({ processing, errors: {} }));
};
const fields = renderFields();
const picker = fields.find((element) => element.type === EmojiPickerButton);
assert.ok(picker);
assert.equal(picker.props.disabled, false);

const textarea = fields.find(
(element) => element.type === MentionTextarea,
)!;
const ref = textarea.props
.ref as React.RefObject<HTMLTextAreaElement | null>;
ref.current = {
selectionStart: 9,
selectionEnd: 14,
} as HTMLTextAreaElement;
(picker.props.onPick as (emoji: string) => void)('🚀');
assert.deepEqual(draft.updateBody.mock.calls, [['Shipping 🚀']]);
assert.equal(
renderFields(true).find((element) => element.type === EmojiPickerButton)
?.props.disabled,
true,
);

isPreviewing = true;
try {
assert.equal(
renderFields().some(
(element) => element.type === EmojiPickerButton,
),
false,
);
} finally {
isPreviewing = false;
}
});

test('quotes submit restored images and clear the complete draft only on success or discard', async () => {
draft.body = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD';
const commit = {
Expand Down