feat: Emoji reaction shorthand (#12650)

* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-06-11 21:51:11 -04:00
committed by GitHub
parent f0899f614b
commit 9811ab6aea
4 changed files with 197 additions and 2 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ export default class Suggestion<
: `(?:${triggers.map(escapeRegExp).join("|")})`;
this.openRegex = new RegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
@@ -8,6 +8,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
import type { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
@@ -157,6 +158,30 @@ function CommentForm({
return;
}
// "+:emoji:" shorthand: react to the comment above instead of replying.
if (thread && !thread.isNew) {
const emoji = parseReactionShorthand(draft);
if (emoji) {
const target = comments
.inThread(thread.id)
.filter((comment) => !comment.isNew)
.pop();
if (target) {
onSaveDraft(undefined);
setForceRender((s) => ++s);
void target.addReaction({ emoji, user });
onSubmit?.();
// re-focus the comment editor
setTimeout(() => {
editorRef.current?.focusAtStart();
}, 0);
return;
}
}
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
+95 -1
View File
@@ -1,4 +1,10 @@
import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
import type { ProsemirrorData } from "../../types";
import {
getNameFromEmoji,
getEmojiFromName,
loadEmojiData,
parseReactionShorthand,
} from "./emoji";
beforeAll(async () => {
await loadEmojiData();
@@ -15,3 +21,91 @@ describe("getEmojiFromName", () => {
expect(getEmojiFromName("thinking_face")).toBe("🤔");
});
});
describe("parseReactionShorthand", () => {
const doc = (content: ProsemirrorData[]): ProsemirrorData => ({
type: "doc",
content,
});
const paragraph = (content: ProsemirrorData[]): ProsemirrorData => ({
type: "paragraph",
content,
});
const text = (value: string): ProsemirrorData => ({
type: "text",
text: value,
});
const emoji = (name: string): ProsemirrorData => ({
type: "emoji",
attrs: { "data-name": name },
});
it("resolves a '+' followed by an emoji node", () => {
expect(
parseReactionShorthand(doc([paragraph([text("+"), emoji("thumbs_up")])]))
).toBe("👍");
});
it("ignores whitespace between the '+' and the emoji node", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+"), text(" "), emoji("thinking_face")])])
)
).toBe("🤔");
});
it("resolves a custom emoji UUID to its UUID", () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(
parseReactionShorthand(doc([paragraph([text("+"), emoji(uuid)])]))
).toBe(uuid);
});
it("resolves literal '+:shortcode:' text", () => {
expect(
parseReactionShorthand(doc([paragraph([text("+:thinking_face:")])]))
).toBe("🤔");
});
it("returns undefined for an unknown shortcode", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+"), emoji("not_an_emoji")])])
)
).toBeUndefined();
});
it("returns undefined when there is text alongside the emoji", () => {
expect(
parseReactionShorthand(
doc([paragraph([text("+ nice "), emoji("thumbs_up")])])
)
).toBeUndefined();
});
it("returns undefined for a regular comment", () => {
expect(
parseReactionShorthand(doc([paragraph([text("Looks good to me")])]))
).toBeUndefined();
});
it("returns undefined when the '+' prefix is missing", () => {
expect(
parseReactionShorthand(doc([paragraph([emoji("thumbs_up")])]))
).toBeUndefined();
});
it("returns undefined for multiple paragraphs", () => {
expect(
parseReactionShorthand(
doc([
paragraph([text("+"), emoji("thumbs_up")]),
paragraph([text("more")]),
])
)
).toBeUndefined();
});
});
+76
View File
@@ -1,4 +1,6 @@
import type { EmojiMartData } from "@emoji-mart/data";
import { isUUID } from "validator";
import type { ProsemirrorData } from "../../types";
export const emojiMartToGemoji: Record<string, string> = {
"+1": "thumbs_up",
@@ -74,3 +76,77 @@ export const getEmojiFromName = (name: string) =>
*/
export const getNameFromEmoji = (emoji: string) =>
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
/**
* Resolve an emoji node name to the value used to react with.
*
* @param name The emoji shortcode, or a UUID for a custom emoji.
* @returns the native emoji character, the UUID of a custom emoji, or undefined
* when the name does not resolve to a known emoji.
*/
function getReactionFromName(name: unknown): string | undefined {
if (typeof name !== "string") {
return undefined;
}
// Custom emojis are stored as UUIDs and reacted with directly.
if (isUUID(name)) {
return name;
}
const character = getEmojiFromName(name);
return character === "?" ? undefined : character;
}
/**
* Detect the "+:emoji:" reaction shorthand within a comment's document. When a
* comment consists solely of a leading "+" immediately followed by a single
* emoji it is treated as a request to react to the comment above rather than as
* a new comment, mirroring the Slack shorthand.
*
* @param data The Prosemirror document of the draft comment.
* @returns the emoji to react with — a native emoji character, or a UUID for a
* custom emoji — or undefined when the document is not a reaction shorthand.
*/
export function parseReactionShorthand(
data: ProsemirrorData
): string | undefined {
const blocks = data.content ?? [];
if (blocks.length !== 1) {
return undefined;
}
const paragraph = blocks[0];
if (paragraph.type !== "paragraph") {
return undefined;
}
// Ignore whitespace-only text nodes so that "+ :emoji:" still matches.
const inline = (paragraph.content ?? []).filter(
(node) => !(node.type === "text" && !node.text?.trim())
);
// The common case: a "+" text node followed by an emoji node inserted via
// the emoji menu.
if (inline.length === 2) {
const [prefix, emoji] = inline;
if (
prefix.type === "text" &&
prefix.text?.trim() === "+" &&
emoji.type === "emoji"
) {
return getReactionFromName(emoji.attrs?.["data-name"]);
}
return undefined;
}
// Fallback: literal "+:shortcode:" text that was never converted to a node.
if (inline.length === 1 && inline[0].type === "text") {
const match = inline[0].text?.trim().match(/^\+\s*:([\w-]+):$/);
if (match) {
return getReactionFromName(match[1]);
}
}
return undefined;
}