Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/components/Message/emojiRegex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import emojiRegex from 'emoji-regex';

/**
* `emojiRegex()` compiles a ~15KB regex source on every call, so we build a
* single shared instance at module load and reuse it everywhere an emoji match
* is needed.
*
* The instance is global (carries the `g` flag and therefore a mutable
* `lastIndex`), so it is only safe to reuse with consumers that reset
* `lastIndex` before scanning:
* - `String#match` / `String#replace` reset it internally;
* - `hast-util-find-and-replace` resets it before each use.
*
* Do NOT use it directly with stateful `.test()` / `.exec()` loops.
*/
export const EMOJI_REGEX = emojiRegex();
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Element, Nodes, Root } from 'hast';
import { findAndReplace, type ReplaceFunction } from 'hast-util-find-and-replace';
import emojiRegex from 'emoji-regex';

import { emojiMarkdownPlugin } from '../rehypePlugins/emojiMarkdownPlugin';

// `findAndReplace` only visits text nodes that live inside an element, so the
// text is wrapped in a <p>. The plugin mutates the tree in place, replacing any
// matched emoji with an `<emoji>` element node whose single child is the emoji.
const buildTree = (text: string): Root => ({
children: [
{
children: [{ type: 'text', value: text }],
properties: {},
tagName: 'p',
type: 'element',
},
],
type: 'root',
});

const collectEmojiNodes = (node: Nodes): Element[] => {
const found: Element[] = [];
const visit = (current: Nodes) => {
if (current.type === 'element' && current.tagName === 'emoji') {
found.push(current);
}
if ('children' in current) {
current.children.forEach(visit);
}
};
visit(node);
return found;
};

describe('emojiMarkdownPlugin', () => {
it('wraps an emoji in an <emoji> element node', () => {
const transform = emojiMarkdownPlugin();
const tree = buildTree('hello 😀');

transform(tree);

const emojiNodes = collectEmojiNodes(tree);
expect(emojiNodes).toHaveLength(1);
expect(emojiNodes[0].children[0]).toMatchObject({ type: 'text', value: '😀' });
});

it('wraps every emoji in a string', () => {
const transform = emojiMarkdownPlugin();
const tree = buildTree('😀 a 🎉 b 🚀');

transform(tree);

expect(collectEmojiNodes(tree)).toHaveLength(3);
});

// Regression guard for the shared module-scope emoji RegExp in
// emojiMarkdownPlugin.ts. That RegExp is global, so a stale `lastIndex`
// leaking between transform() calls would make later calls miss matches.
// `hast-util-find-and-replace` resets `lastIndex` before each use; if a future
// version of that library stops doing so, the assertions below start failing.
it('keeps matching across repeated calls regardless of emoji position', () => {
const transform = emojiMarkdownPlugin();

// Position the emoji differently each call: a leaked `lastIndex` that began
// the search mid-string would skip an earlier-positioned emoji.
const inputs = ['😀 leading', 'trailing 😀', 'mid 😀 dle', '😀', 'tail end 😀'];

for (const input of inputs) {
const tree = buildTree(input);
transform(tree);
expect(collectEmojiNodes(tree)).toHaveLength(1);
}
});

// The reason reusing a single global RegExp is safe: `findAndReplace` resets
// the regexp's `lastIndex` before scanning. This asserts that behaviour
// directly by poisoning `lastIndex` first — if a future version of
// `hast-util-find-and-replace` stops resetting it, `exec` would start past the
// input, the emoji would go unmatched, and this test fails.
it('findAndReplace resets a global regexp lastIndex before scanning', () => {
const regex = emojiRegex();
// Simulate stale state leaked from a previous use: point lastIndex past the input.
regex.lastIndex = 999;

let matched = false;
const replace: ReplaceFunction = (match) => {
if (match === '😀') matched = true;
return { type: 'text', value: typeof match === 'string' ? match : '' };
};

findAndReplace(buildTree('😀'), [regex, replace]);

expect(matched).toBe(true);
});
});
11 changes: 8 additions & 3 deletions src/components/Message/renderText/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ export function escapeRegExp(text: string) {

export const detectHttp = /(http(s?):\/\/)?(www\.)?/;

// Regexes are hoisted to module scope so they are compiled once rather than on
// every call. `codeRegex`/`regexMdLinks` are only used with `String#match`
// (which resets `lastIndex`) and `singleMatch` is non-global (`.exec` ignores
// `lastIndex`), so sharing the instances is safe.
const codeRegex = /```[a-z]*\n[\s\S]*?\n```|`[a-z]*[\s\S]*?`/gm;
const regexMdLinks = /\[([^[]+)\](\(.*\))/gm;
const singleMatch = /\[([^[]+)\]\((.*)\)/;

export const messageCodeBlocks = (message: string) => {
const codeRegex = /```[a-z]*\n[\s\S]*?\n```|`[a-z]*[\s\S]*?`/gm;
const matches = message.match(codeRegex);
return matches || [];
};

export const matchMarkdownLinks = (message: string) => {
const regexMdLinks = /\[([^[]+)\](\(.*\))/gm;
const matches = message.match(regexMdLinks);
const singleMatch = /\[([^[]+)\]\((.*)\)/;

const links = matches
? matches.map((match) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { ReplaceFunction } from 'hast-util-find-and-replace';
import { findAndReplace } from 'hast-util-find-and-replace';
import { u } from 'unist-builder';
import emojiRegex from 'emoji-regex';
import type { Nodes } from 'hast';

import { EMOJI_REGEX } from '../../emojiRegex';

export const emojiMarkdownPlugin = () => {
const replace: ReplaceFunction = (match) =>
u('element', { properties: {}, tagName: 'emoji' }, [u('text', match)]);

const transform = (node: Nodes) => findAndReplace(node, [emojiRegex(), replace]);
const transform = (node: Nodes) => findAndReplace(node, [EMOJI_REGEX, replace]);

return transform;
};
7 changes: 4 additions & 3 deletions src/components/Message/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import deepequal from 'react-fast-compare';
import emojiRegex from 'emoji-regex';

import { EMOJI_REGEX } from './emojiRegex';

import type { TFunction } from 'i18next';
import type {
Expand Down Expand Up @@ -377,14 +378,14 @@ export const getReadByTooltipText = (
};

export const countEmojis = (text?: string) => {
const matches = text?.match(emojiRegex());
const matches = text?.match(EMOJI_REGEX);
return matches ? matches.length : 0;
};

export const messageTextHasEmojisOnly = (message: LocalMessage) => {
if (!message.text) return false;

const noEmojis = message.text.replace(emojiRegex(), '');
const noEmojis = message.text.replace(EMOJI_REGEX, '');
const noSpace = noEmojis.replace(/[\s\n]/gm, '');

return !noSpace;
Expand Down