mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -46,7 +46,7 @@ export default class Suggestion<
|
|||||||
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
||||||
|
|
||||||
this.openRegex = new RegExp(
|
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.allowSpaces ? "\\s{1}" : ""
|
||||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||||
"u"
|
"u"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTheme } from "styled-components";
|
import { useTheme } from "styled-components";
|
||||||
|
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
|
||||||
import type { ProsemirrorData } from "@shared/types";
|
import type { ProsemirrorData } from "@shared/types";
|
||||||
import { getEventFiles } from "@shared/utils/files";
|
import { getEventFiles } from "@shared/utils/files";
|
||||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||||
@@ -157,6 +158,30 @@ function CommentForm({
|
|||||||
return;
|
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;
|
const commentDraft = draft;
|
||||||
onSaveDraft(undefined);
|
onSaveDraft(undefined);
|
||||||
setForceRender((s) => ++s);
|
setForceRender((s) => ++s);
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
|
import type { ProsemirrorData } from "../../types";
|
||||||
|
import {
|
||||||
|
getNameFromEmoji,
|
||||||
|
getEmojiFromName,
|
||||||
|
loadEmojiData,
|
||||||
|
parseReactionShorthand,
|
||||||
|
} from "./emoji";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await loadEmojiData();
|
await loadEmojiData();
|
||||||
@@ -15,3 +21,91 @@ describe("getEmojiFromName", () => {
|
|||||||
expect(getEmojiFromName("thinking_face")).toBe("🤔");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { EmojiMartData } from "@emoji-mart/data";
|
import type { EmojiMartData } from "@emoji-mart/data";
|
||||||
|
import { isUUID } from "validator";
|
||||||
|
import type { ProsemirrorData } from "../../types";
|
||||||
|
|
||||||
export const emojiMartToGemoji: Record<string, string> = {
|
export const emojiMartToGemoji: Record<string, string> = {
|
||||||
"+1": "thumbs_up",
|
"+1": "thumbs_up",
|
||||||
@@ -74,3 +76,77 @@ export const getEmojiFromName = (name: string) =>
|
|||||||
*/
|
*/
|
||||||
export const getNameFromEmoji = (emoji: string) =>
|
export const getNameFromEmoji = (emoji: string) =>
|
||||||
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user