From 2c6d02d5a1eabbb3f621da2ffd248bc5a95d057d Mon Sep 17 00:00:00 2001 From: Joseph Miclaus Date: Wed, 4 Nov 2026 10:59:11 +0100 Subject: [PATCH 1/3] feat: add emoji picker to quote composer --- .../js/components/commit-quote-button.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 17173604d5..3b9d66f89b 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -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'; @@ -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'; @@ -152,6 +154,12 @@ function QuoteComposer({ textareaRef, isSubmittable: isQuotable, }); + const insertEmoji = useEmojiInsertion({ + body, + updateBody, + textareaRef, + maxLength: maxRawBodyLength, + }); const submitOnModEnter = (event: KeyboardEvent) => { if (preview.handleShortcut(event)) { @@ -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 && ( +
+ +
+ )} {preview.isPreviewing && ( Date: Wed, 4 Nov 2026 10:59:11 +0100 Subject: [PATCH 2/3] test: cover emoji picker in quote composer --- tests/Unit/QuoteDraft.test.tsx | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index d05c689e0f..94523587cd 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -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', @@ -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,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'); @@ -81,6 +86,70 @@ function elements(node: ReactNode): ReactElement>[] { ]; } +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; + }) => 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; + 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 = { From e24321ec92b3dfb990d0e1ee155c34b1677fda27 Mon Sep 17 00:00:00 2001 From: Joseph Miclaus Date: Wed, 4 Nov 2026 10:59:11 +0100 Subject: [PATCH 3/3] docs: document emoji picker in quote composer --- KNOWLEDGE_BASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d29acd2c7b..90923984f4 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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.