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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/features/messages/lib/emoticonAutoReplace.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
},
});
49 changes: 49 additions & 0 deletions desktop/src/features/messages/lib/emoticonMap.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
36 changes: 36 additions & 0 deletions desktop/src/features/messages/lib/emoticonMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
":-)": "🙂",
":)": "🙂",
":-(": "🙁",
":(": "🙁",
":'-(": "😢",
":'(": "😢",
":-D": "😀",
":D": "😀",
";-)": "😉",
";)": "😉",
":-P": "😛",
":P": "😛",
":-p": "😛",
":p": "😛",
":-O": "😮",
":O": "😮",
":-o": "😮",
":o": "😮",
":-|": "😐",
":|": "😐",
":-*": "😘",
":*": "😘",
"B-)": "😎",
"B)": "😎",
XD: "😆",
xD: "😆",
"</3": "💔",
"<3": "❤️",
};
2 changes: 2 additions & 0 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
mentionHighlightKey,
} from "./mentionHighlightExtension";
import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode";
import { EmoticonAutoReplace } from "./emoticonAutoReplace";
import { useComposerCustomEmoji } from "./useComposerCustomEmoji";
import { buildPlainTextProjection } from "./plainTextProjection";
import { createLinkInteractionExtension } from "./linkInteractionExtension";
Expand Down Expand Up @@ -454,6 +455,7 @@ export function useRichTextEditor({
SpoilerMark,
MentionHighlightExtension,
customEmojiWiring.extension,
EmoticonAutoReplace,
Placeholder.configure({
placeholder: () => placeholderRef.current ?? "Write a message…",
}),
Expand Down
138 changes: 138 additions & 0 deletions desktop/tests/e2e/composer-emoticon-autoreplace.spec.ts
Original file line number Diff line number Diff line change
@@ -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(":)");
});