From 9341706031e1b56a2227c9922152e9a024ae3f14 Mon Sep 17 00:00:00 2001 From: Rares S Date: Fri, 24 Jul 2026 21:35:07 +0200 Subject: [PATCH] feat(desktop): auto-replace ASCII emoticons in the composer Typing standalone smileys like :) :P ;) <3 now converts them to the matching unicode emoji as soon as the pattern completes, the same way :shortcode: custom emoji already do. Requires a preceding space/start so it doesn't fire mid-word, and is a no-op inside code blocks/inline code and on pasted text (Tiptap's input-rule plugin only hooks live keystrokes). Co-Authored-By: Claude Sonnet 5 Signed-off-by: Rares S --- desktop/playwright.config.ts | 1 + .../messages/lib/emoticonAutoReplace.ts | 44 ++++++ .../messages/lib/emoticonMap.test.mjs | 49 +++++++ .../src/features/messages/lib/emoticonMap.ts | 36 +++++ .../messages/lib/useRichTextEditor.ts | 2 + .../e2e/composer-emoticon-autoreplace.spec.ts | 138 ++++++++++++++++++ 6 files changed, 270 insertions(+) create mode 100644 desktop/src/features/messages/lib/emoticonAutoReplace.ts create mode 100644 desktop/src/features/messages/lib/emoticonMap.test.mjs create mode 100644 desktop/src/features/messages/lib/emoticonMap.ts create mode 100644 desktop/tests/e2e/composer-emoticon-autoreplace.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..d8c2ddeadb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/composer-emoticon-autoreplace.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/team-mentions.spec.ts", diff --git a/desktop/src/features/messages/lib/emoticonAutoReplace.ts b/desktop/src/features/messages/lib/emoticonAutoReplace.ts new file mode 100644 index 0000000000..178a09c85f --- /dev/null +++ b/desktop/src/features/messages/lib/emoticonAutoReplace.ts @@ -0,0 +1,44 @@ +import { Extension, textInputRule } from "@tiptap/core"; + +import { escapeRegExp } from "@/shared/lib/mentionPattern"; + +import { EMOTICON_MAP } from "./emoticonMap"; + +/** + * Build the `find` pattern for one emoticon: it must sit at a word boundary + * โ€” preceded by whitespace or the start of the input โ€” so the rule only + * fires on a standalone emoticon, not mid-word (e.g. `path:)`, or a + * `:shortcode:` still being typed like `:Dance`). The capturing group holds + * just the emoticon; `textInputRule` uses match[1] to leave the preceding + * whitespace/start untouched and only replace that inner range. + */ +export function buildEmoticonFindPattern(ascii: string): RegExp { + return new RegExp(`(?:^|\\s)(${escapeRegExp(ascii)})$`); +} + +/** + * Auto-replaces standalone ASCII emoticons (`:)`, `:P`, `<3`, ...) with their + * unicode emoji equivalent the instant the user finishes typing them โ€” same + * instant "convert on completion" behavior as the known-`:shortcode:` input + * rule in customEmojiNode.ts. + * + * Two things this deliberately does NOT do: + * - Fire inside code blocks or inline code: Tiptap's input-rule plugin + * already skips both (see `run()` in @tiptap/core's InputRule.ts). + * - Fire on pasted text: input rules hook the editor's `handleTextInput` + * prop, which only runs for real typed keystrokes (and IME composition + * end) โ€” paste goes through ProseMirror's `handlePaste`/slice-insertion + * path instead, which never calls `handleTextInput`. + */ +export const EmoticonAutoReplace = Extension.create({ + name: "emoticonAutoReplace", + + addInputRules() { + return Object.entries(EMOTICON_MAP).map(([ascii, emoji]) => + textInputRule({ + find: buildEmoticonFindPattern(ascii), + replace: emoji, + }), + ); + }, +}); diff --git a/desktop/src/features/messages/lib/emoticonMap.test.mjs b/desktop/src/features/messages/lib/emoticonMap.test.mjs new file mode 100644 index 0000000000..0e36dfe0ee --- /dev/null +++ b/desktop/src/features/messages/lib/emoticonMap.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildEmoticonFindPattern } from "./emoticonAutoReplace.ts"; +import { EMOTICON_MAP } from "./emoticonMap.ts"; + +// These exercise the actual pattern the composer's auto-replace extension +// builds (emoticonAutoReplace.ts), not a reimplementation, so they cover the +// real word-boundary matching behavior. + +test("every mapped emoticon matches when preceded by a space", () => { + for (const key of Object.keys(EMOTICON_MAP)) { + const m = buildEmoticonFindPattern(key).exec(`hello ${key}`); + assert.ok(m, `expected ${key} to match after a space`); + assert.equal(m[1], key); + } +}); + +test("matches at the very start of input (no preceding character)", () => { + const m = buildEmoticonFindPattern(":)").exec(":)"); + assert.ok(m); + assert.equal(m[1], ":)"); +}); + +test("does not match mid-word (no preceding space)", () => { + assert.equal(buildEmoticonFindPattern(":)").exec("hi:)"), null); + assert.equal(buildEmoticonFindPattern(":D").exec(":Dance"), null); +}); + +test("does not match an incomplete emoticon", () => { + assert.equal(buildEmoticonFindPattern(":-)").exec("hello :-"), null); + assert.equal(buildEmoticonFindPattern("<3").exec("hello <"), null); +}); + +test("maps common smileys to the expected emoji", () => { + assert.equal(EMOTICON_MAP[":)"], "๐Ÿ™‚"); + assert.equal(EMOTICON_MAP[":("], "๐Ÿ™"); + assert.equal(EMOTICON_MAP[":D"], "๐Ÿ˜€"); + assert.equal(EMOTICON_MAP[";)"], "๐Ÿ˜‰"); + assert.equal(EMOTICON_MAP[":P"], "๐Ÿ˜›"); + assert.equal(EMOTICON_MAP["<3"], "โค๏ธ"); +}); + +test("every value is non-empty and every key non-blank", () => { + for (const [key, value] of Object.entries(EMOTICON_MAP)) { + assert.ok(key.trim().length > 0); + assert.ok(value.trim().length > 0); + } +}); diff --git a/desktop/src/features/messages/lib/emoticonMap.ts b/desktop/src/features/messages/lib/emoticonMap.ts new file mode 100644 index 0000000000..ab062e0685 --- /dev/null +++ b/desktop/src/features/messages/lib/emoticonMap.ts @@ -0,0 +1,36 @@ +/** + * ASCII emoticon โ†’ unicode emoji map for composer auto-replace (Slack/Discord + * parity). Deliberately curated to unambiguous, widely recognized emoticons โ€” + * this is not meant to be exhaustive, just the common ones people type without + * thinking about it. + */ +export const EMOTICON_MAP: Record = { + ":-)": "๐Ÿ™‚", + ":)": "๐Ÿ™‚", + ":-(": "๐Ÿ™", + ":(": "๐Ÿ™", + ":'-(": "๐Ÿ˜ข", + ":'(": "๐Ÿ˜ข", + ":-D": "๐Ÿ˜€", + ":D": "๐Ÿ˜€", + ";-)": "๐Ÿ˜‰", + ";)": "๐Ÿ˜‰", + ":-P": "๐Ÿ˜›", + ":P": "๐Ÿ˜›", + ":-p": "๐Ÿ˜›", + ":p": "๐Ÿ˜›", + ":-O": "๐Ÿ˜ฎ", + ":O": "๐Ÿ˜ฎ", + ":-o": "๐Ÿ˜ฎ", + ":o": "๐Ÿ˜ฎ", + ":-|": "๐Ÿ˜", + ":|": "๐Ÿ˜", + ":-*": "๐Ÿ˜˜", + ":*": "๐Ÿ˜˜", + "B-)": "๐Ÿ˜Ž", + "B)": "๐Ÿ˜Ž", + XD: "๐Ÿ˜†", + xD: "๐Ÿ˜†", + " placeholderRef.current ?? "Write a messageโ€ฆ", }), diff --git a/desktop/tests/e2e/composer-emoticon-autoreplace.spec.ts b/desktop/tests/e2e/composer-emoticon-autoreplace.spec.ts new file mode 100644 index 0000000000..83b13d7b1b --- /dev/null +++ b/desktop/tests/e2e/composer-emoticon-autoreplace.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Typed ASCII emoticons (`:)`, `:P`, `<3`, ...) auto-replace with their +// unicode emoji the instant the pattern completes โ€” see +// features/messages/lib/emoticonAutoReplace.ts. + +async function openGeneral(page: Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +} + +test("typing :) auto-replaces with the emoji", async ({ page }) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("hello :)"); + + await expect(input).toHaveText("hello ๐Ÿ™‚"); +}); + +test("typing multiple emoticons in one message all convert", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":) :P <3"); + + await expect(input).toHaveText("๐Ÿ™‚ ๐Ÿ˜› โค๏ธ"); +}); + +test("does not convert an incomplete emoticon", async ({ page }) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("hello :-"); + + await expect(input).toHaveText("hello :-"); +}); + +test("does not convert an emoticon glued to a preceding word", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("hi:)"); + + await expect(input).toHaveText("hi:)"); +}); + +test("converts when the emoticon starts the message (nothing precedes it)", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":) hi"); + + await expect(input).toHaveText("๐Ÿ™‚ hi"); +}); + +test("converts once a space separates it from the preceding word", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("hi :)"); + + await expect(input).toHaveText("hi ๐Ÿ™‚"); +}); + +test("typing :) followed by more text converts and keeps typing normally", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("hi :) world"); + + await expect(input).toHaveText("hi ๐Ÿ™‚ world"); +}); + +test("pasting text containing an emoticon does not convert it", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + "pasted :) text", + ); + await input.click(); + await page.keyboard.press("ControlOrMeta+V"); + + await expect(input).toHaveText("pasted :) text"); +}); + +test("does not convert an emoticon typed inside inline code", async ({ + page, +}) => { + await installMockBridge(page); + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + // Toggle inline code formatting on (โŒ˜E), type the emoticon, toggle off. + await page.keyboard.press("ControlOrMeta+e"); + await input.pressSequentially(":)"); + await page.keyboard.press("ControlOrMeta+e"); + + await expect(input).toHaveText(":)"); +});