diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts index b258b76cf1..97eb4ecf2b 100644 --- a/app/editor/extensions/Suggestion.ts +++ b/app/editor/extensions/Suggestion.ts @@ -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" diff --git a/app/scenes/Document/components/Comments/CommentForm.tsx b/app/scenes/Document/components/Comments/CommentForm.tsx index 3b594da18a..4ecb53a4a0 100644 --- a/app/scenes/Document/components/Comments/CommentForm.tsx +++ b/app/scenes/Document/components/Comments/CommentForm.tsx @@ -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); diff --git a/shared/editor/lib/emoji.test.ts b/shared/editor/lib/emoji.test.ts index e6bc1664e2..68a4c94d0e 100644 --- a/shared/editor/lib/emoji.test.ts +++ b/shared/editor/lib/emoji.test.ts @@ -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(); + }); +}); diff --git a/shared/editor/lib/emoji.ts b/shared/editor/lib/emoji.ts index f7cc870d97..988642dc8d 100644 --- a/shared/editor/lib/emoji.ts +++ b/shared/editor/lib/emoji.ts @@ -1,4 +1,6 @@ import type { EmojiMartData } from "@emoji-mart/data"; +import { isUUID } from "validator"; +import type { ProsemirrorData } from "../../types"; export const emojiMartToGemoji: Record = { "+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; +}