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
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ 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. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. 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.

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.

Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies nobody: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions.
Expand All @@ -221,7 +223,7 @@ Typing **#123** in a composer looks up that exact issue or pull request in the /

A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text.

Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
Sources: [body renderer](resources/js/components/commit-body.tsx), [emoji picker button](resources/js/components/emoji-picker.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).

### Draft previews

Expand Down
6 changes: 6 additions & 0 deletions resources/js/components/chat/message-reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,12 @@ export function EmojiPicker({
};

const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
// Enter also confirms an input method's candidate, which is not a pick. Safari reports that Enter with keyCode
// 229 instead of isComposing.
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;
}

const moves: Record<string, number> = {
ArrowRight: 1,
ArrowLeft: -1,
Expand Down
15 changes: 14 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 { EmojiPickerButton } from '@/components/emoji-picker';
import { GraphRail, RailAvatar } from '@/components/graph-rail';
import InputError from '@/components/input-error';
import type { UseIssueCommandReturn } from '@/components/issue-command';
Expand All @@ -38,6 +39,7 @@ import {
} from '@/components/schedule-picker';
import { StashLabel } from '@/components/stash-label';
import { Button } from '@/components/ui/button';
import { useEmojiInsertion } from '@/hooks/use-emoji-insertion';
import { useImageAttachments } from '@/hooks/use-image-attachments';
import { useLinkCard } from '@/hooks/use-link-card';
import type { UseLinkCardReturn } from '@/hooks/use-link-card';
Expand Down Expand Up @@ -126,7 +128,7 @@ function CommitComposerBody({
drop: () => void;
}) {
const keys = useKeyLabels();
const { body, isRestored } = stash;
const { body, isRestored, updateBody } = stash;
const { auth } = usePage().props;
const {
isScheduling,
Expand Down Expand Up @@ -177,6 +179,13 @@ function CommitComposerBody({
return () => observer.disconnect();
}, [isInDialog]);

const insertEmoji = useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength: isIssue ? maxBodyLength : maxRawBodyLength,
});

return (
<div
className={cn('relative flex', { 'min-h-0': isInDialog })}
Expand Down Expand Up @@ -415,6 +424,10 @@ function CommitComposerBody({
onPick={attachments.pick}
className="-ml-2"
/>
<EmojiPickerButton
disabled={processing}
onPick={insertEmoji}
/>
<ActionTooltip
label={
hasImages
Expand Down
14 changes: 14 additions & 0 deletions 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 { EmojiPickerButton } from '@/components/emoji-picker';
import {
graphDash,
graphStrokeWidth,
Expand Down Expand Up @@ -69,6 +70,7 @@ import {
useCommitNotFound,
useIsCommitDeleted,
} from '@/hooks/use-deleted-resources';
import { useEmojiInsertion } from '@/hooks/use-emoji-insertion';
import { useImageAttachments } from '@/hooks/use-image-attachments';
import { focusCommit } from '@/hooks/use-keyboard-shortcuts';
import { useLinkCard } from '@/hooks/use-link-card';
Expand Down Expand Up @@ -1387,6 +1389,13 @@ function ReplyComposer({
isSubmittable: isReplyable,
});

const insertEmoji = useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength: maxRawBodyLength,
});

const returnFocusToCommit = () => {
const commit =
textareaRef.current?.closest<HTMLElement>('[data-commit]');
Expand Down Expand Up @@ -1540,6 +1549,11 @@ function ReplyComposer({
onPick={attachments.pick}
className="size-8"
/>
<EmojiPickerButton
disabled={processing}
onPick={insertEmoji}
className="-ml-2 size-8"
/>
<PreviewToggle preview={preview} className="-ml-2 size-8" />
<LinkCardPicker
linkCard={linkCard}
Expand Down
73 changes: 73 additions & 0 deletions resources/js/components/emoji-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Smile } from 'lucide-react';
import { useRef, useState } from 'react';
import { ActionTooltip } from '@/components/action-tooltip';
import { EmojiPicker } from '@/components/chat/message-reactions';
import { buttonVariants } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';

const noReactions = new Set<string>();

/**
* The reactions' emoji picker, for composers. The emoji is handed over once the picker has closed, so `onPick` can put
* the focus back in the textarea instead of on this button.
*/
export function EmojiPickerButton({
disabled,
onPick,
className,
}: {
disabled?: boolean;
onPick: (emoji: string) => void;
className?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const pickedEmoji = useRef<string | null>(null);

return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<ActionTooltip label="Add emoji">
<PopoverTrigger
type="button"
disabled={disabled}
aria-label="Add emoji"
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'text-muted-foreground',
className,
)}
data-test="emoji-picker-button"
>
<Smile className="size-5" />
</PopoverTrigger>
</ActionTooltip>
<PopoverContent
align="start"
collisionPadding={8}
className="flex h-[min(25rem,var(--radix-popover-content-available-height))] w-76 flex-col overflow-hidden rounded-lg p-0 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.35)]"
onCloseAutoFocus={(event) => {
const emoji = pickedEmoji.current;

if (emoji !== null) {
event.preventDefault();
pickedEmoji.current = null;
onPick(emoji);
}
}}
>
<EmojiPicker
usage={[]}
reacted={noReactions}
onPick={(emoji) => {
pickedEmoji.current = emoji;
setIsOpen(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}}
/>
</PopoverContent>
</Popover>
);
}
54 changes: 54 additions & 0 deletions resources/js/hooks/use-emoji-insertion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RefObject } from 'react';
import { useLayoutEffect, useRef } from 'react';
import { insertAtSelection } from '@/lib/text-selection';

/**
* Inserts a picked emoji where the caret was in the composer's textarea. A textarea keeps its selection while the
* picker has the focus, so this works whether the picker was opened with the pointer or the keyboard. Once React has
* written the new body, the focus goes back to the textarea with the caret right after the emoji.
*/
export function useEmojiInsertion({
body,
updateBody,
textareaRef,
maxLength,
}: {
body: string;
updateBody: (body: string) => void;
textareaRef: RefObject<HTMLTextAreaElement | null>;
maxLength: number;
}): (emoji: string) => void {
const caret = useRef<number | null>(null);

useLayoutEffect(() => {
const element = textareaRef.current;

if (caret.current === null || element === null) {
return;
}

element.focus();
element.setSelectionRange(caret.current, caret.current);
caret.current = null;
}, [body, textareaRef]);

return (emoji) => {
const element = textareaRef.current;
const inserted = insertAtSelection(body, element, emoji, maxLength);

if (inserted === null) {
// Focusing a restored draft moves a caret at its start to the end, so the selection is put back after.
if (element !== null) {
const { selectionStart, selectionEnd } = element;

element.focus();
element.setSelectionRange(selectionStart, selectionEnd);
}

return;
}

caret.current = inserted.caret;
updateBody(inserted.value);
};
}
25 changes: 25 additions & 0 deletions resources/js/lib/text-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type TextSelection = Pick<
HTMLTextAreaElement,
'selectionStart' | 'selectionEnd'
>;

/**
* Inserts the text over the selection, or at the end without one. Null when the result would be longer than
* `maxLength` characters, the same cap the textarea puts on typing.
*/
export function insertAtSelection(
value: string,
selection: TextSelection | null,
text: string,
maxLength: number,
): { value: string; caret: number } | null {
const start = selection?.selectionStart ?? value.length;
const end = selection?.selectionEnd ?? value.length;
const next = value.slice(0, start) + text + value.slice(end);

if (Array.from(next).length > maxLength) {
return null;
}

return { value: next, caret: start + text.length };
}
120 changes: 120 additions & 0 deletions tests/Unit/EmojiInsertion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/// <reference types="node" />
import assert from 'node:assert/strict';
import * as React from 'react';
import { afterAll, beforeEach, test, vi } from 'vite-plus/test';

let layoutEffect: () => void = () => {};

// The layout effect runs when the test says React has written the new body.
vi.doMock('react', () => ({
...React,
useLayoutEffect: (effect: () => void) => {
layoutEffect = effect;
},
useRef: (initial: unknown) => ({ current: initial }),
}));

afterAll(() => {
vi.doUnmock('react');
});

const { useEmojiInsertion } =
await import('../../resources/js/hooks/use-emoji-insertion');

/**
* A textarea that kept its selection after the picker took the focus, as browsers do.
*/
function blurredTextarea(
selectionStart: number,
selectionEnd = selectionStart,
) {
const focus = vi.fn();
const setSelectionRange = vi.fn();
const textarea = {
selectionStart,
selectionEnd,
focus,
setSelectionRange,
} as unknown as HTMLTextAreaElement;

return { textarea, focus, setSelectionRange };
}

const updateBody = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

test('inserts where the caret was and puts the focus back right after the emoji', () => {
const { textarea, focus, setSelectionRange } = blurredTextarea(8);
const insertEmoji = useEmojiInsertion({
body: 'shipped it',
updateBody,
textareaRef: { current: textarea },
maxLength: 280,
});

insertEmoji('🚀');
layoutEffect();

assert.deepEqual(updateBody.mock.calls, [['shipped 🚀it']]);
assert.equal(focus.mock.calls.length, 1);
assert.deepEqual(setSelectionRange.mock.calls, [[10, 10]]);
});

test('replaces the text that was selected', () => {
const { textarea } = blurredTextarea(8, 10);

useEmojiInsertion({
body: 'shipped it',
updateBody,
textareaRef: { current: textarea },
maxLength: 280,
})('🎉');

assert.deepEqual(updateBody.mock.calls, [['shipped 🎉']]);
});

test('leaves a full composer as it is and returns to it', () => {
const { textarea, focus, setSelectionRange } = blurredTextarea(3);

useEmojiInsertion({
body: 'abc',
updateBody,
textareaRef: { current: textarea },
maxLength: 3,
})('🔥');
layoutEffect();

assert.equal(updateBody.mock.calls.length, 0);
assert.equal(focus.mock.calls.length, 1);
assert.deepEqual(setSelectionRange.mock.calls, [[3, 3]]);
});

test('keeps the caret at the start of a full restored draft', () => {
const { textarea, focus, setSelectionRange } = blurredTextarea(0);

useEmojiInsertion({
body: 'abc',
updateBody,
textareaRef: { current: textarea },
maxLength: 3,
})('🔥');

assert.equal(updateBody.mock.calls.length, 0);
assert.equal(focus.mock.calls.length, 1);
assert.deepEqual(setSelectionRange.mock.calls, [[0, 0]]);
});

test('appends when the textarea is gone', () => {
useEmojiInsertion({
body: 'shipped',
updateBody,
textareaRef: { current: null },
maxLength: 280,
})(' 🔥');
layoutEffect();

assert.deepEqual(updateBody.mock.calls, [['shipped 🔥']]);
});
Loading