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: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"dayjs": "^1.11.21",
"domhandler": "^5.0.3",
"dompurify": "^3.4.10",
"emoji-regex": "^10.6.0",
"emojibase": "^17.0.0",
"emojibase-data": "^17.0.0",
"eventemitter3": "^5.0.4",
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 7 additions & 8 deletions src/app/components/message/MsgTypeRenderers.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { CSSProperties, ReactNode } from 'react';
import { useMemo } from 'react';
import { Box, Chip, Text, toRem } from 'folds';
import { type CSSProperties, type ReactNode, useMemo } from 'react';
import { ArrowSquareOut, sizedIcon, Link } from '$components/icons/phosphor';
import type { IContent, IPreviewUrlResponse, MatrixClient } from '$types/matrix-sdk';
import { JUMBO_EMOJI_REG } from '$utils/regex';
import { Box, Chip, Text, toRem } from 'folds';
import { type IContent, type IPreviewUrlResponse, type MatrixClient } from '$types/matrix-sdk';
import { isJumboEmojiText } from '$utils/emojiDetection';
import { trimReplyFromBody } from '$utils/room';
import type {
IAudioContent,
Expand Down Expand Up @@ -227,7 +226,7 @@ export function MText({
)
)
return true;
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return false;
if (!isJumboEmojiText(trimmedBody)) return false;

if (trimmedBody.includes(':')) {
const hasImage = customBody && /<img[^>]*>/i.test(customBody);
Expand Down Expand Up @@ -338,7 +337,7 @@ export function MEmote({
return <BrokenContent body={typeof customBody === 'string' ? customBody : undefined} />;
}
const trimmedBody = trimReplyFromBody(body);
const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody);
const isJumbo = isJumboEmojiText(trimmedBody);

const { urls, bundleContent } = getUrlsFromContent(content, renderUrlsPreview);

Expand Down Expand Up @@ -393,7 +392,7 @@ export function MNotice({
return <BrokenContent body={typeof customBody === 'string' ? customBody : undefined} />;
}
const trimmedBody = trimReplyFromBody(body);
const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody);
const isJumbo = isJumboEmojiText(trimmedBody);

const { urls, bundleContent } = getUrlsFromContent(content, renderUrlsPreview);

Expand Down
27 changes: 21 additions & 6 deletions src/app/components/power/PowerIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { JUMBO_EMOJI_REG } from '$utils/regex';
import { isJumboEmojiText } from '$utils/emojiDetection';
import * as css from './style.css';

type PowerIconProps = css.PowerIconVariants & {
iconSrc: string;
name?: string;
};

const ALLOWED_ICON_PROTOCOLS = new Set(['http:', 'https:']);

function getSafeIconUrl(iconSrc: string): string | undefined {
try {
const parsed = new URL(iconSrc);
return ALLOWED_ICON_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined;
} catch {
return undefined;
}
}

export function PowerIcon({ size, iconSrc, name }: PowerIconProps) {
return JUMBO_EMOJI_REG.test(iconSrc) ? (
<span className={css.PowerIcon({ size })}>{iconSrc}</span>
) : (
<img className={css.PowerIcon({ size })} src={iconSrc} alt={name} />
);
if (isJumboEmojiText(iconSrc, 1)) {
return <span className={css.PowerIcon({ size })}>{iconSrc}</span>;
}

const safeIconUrl = getSafeIconUrl(iconSrc);
if (!safeIconUrl) return null;

return <img className={css.PowerIcon({ size })} src={safeIconUrl} alt={name} />;
}
14 changes: 14 additions & 0 deletions src/app/plugins/react-custom-html-parser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getReactCustomHtmlParser,
makeMentionCustomProps,
renderMatrixMention,
scaleSystemEmoji,
} from './react-custom-html-parser';
import { registerMatrixUriProtocol } from './matrix-uri';
import { markdownToHtml } from './markdown/markdownToHtml';
Expand Down Expand Up @@ -167,6 +168,19 @@ describe('react custom html parser', () => {
expect(img).toHaveAttribute('height', '64');
});

it.each(['🫩', '🫪', '🫯', '🇩🇪', '🙂‍↔️'])(
'wraps modern emoji text %s in emoticon markup',
(emoji) => {
const result = scaleSystemEmoji(emoji);
expect(result).toHaveLength(1);
expect(typeof result[0]).not.toBe('string');
}
);

it('does not wrap emojis inside urls', () => {
expect(scaleSystemEmoji('https://example.com/🫩')).toEqual(['https://example.com/🫩']);
});

it('renders same-origin raw settings links as mention-style chips through the factory link render path', () => {
const renderLink = factoryRenderLinkifyWithMention(
settingsLinkBaseUrl,
Expand Down
69 changes: 51 additions & 18 deletions src/app/plugins/react-custom-html-parser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import {
import type { IntermediateRepresentation, OptFn, Opts as LinkifyOpts } from 'linkifyjs';
import Linkify from 'linkify-react';
import type { ChildNode } from 'domhandler';
import emojiRegex from 'emoji-regex';

import * as css from '$styles/CustomHtml.css';
import {
getCanonicalAliasRoomId,
Expand All @@ -27,8 +25,9 @@ import {
mxcUrlToHttp,
} from '$utils/matrix';
import { getMemberDisplayName } from '$utils/room';
import type { Nicknames } from '$state/nicknames';
import { sanitizeForRegex, URL_NEG_LB } from '$utils/regex';
import { type Nicknames } from '$state/nicknames';
import { sanitizeForRegex, URL_REG } from '$utils/regex';
import { splitEmojiText } from '$utils/emojiDetection';
import { findAndReplace } from '$utils/findAndReplace';
import { onEnterOrSpace } from '$utils/keyboard';
import { copyToClipboard } from '$utils/dom';
Expand All @@ -47,12 +46,9 @@ import {
import { isRedundantMatrixUriAnchorText, parseMatrixUri, testMatrixUri } from './matrix-uri';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';

const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${emojiRegex().source})`, 'g');

const shouldLinkifyDomText = (domNode: DOMText): boolean =>
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a');

export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
Expand Down Expand Up @@ -349,19 +345,56 @@ export const factoryRenderLinkifyWithMention = (
return renderLink;
};

export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => (
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
const scaleEmojiChunk = (text: string, output: (string | JSX.Element)[]) => {
splitEmojiText(text).forEach((part) => {
if (part.type === 'text') {
output.push(part.value);
return;
}

output.push(
<span key={`scaleSystemEmoji-${output.length}`} className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(part.value))}>
{part.value}
</span>
</span>
),
(txt) => txt
);
);
});
};

export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] => {
const parts: (string | JSX.Element)[] = [];
const urlReg = new RegExp(URL_REG);
let lastIndex = 0;

[...text.matchAll(urlReg)].forEach((match) => {
const start = match.index ?? 0;
scaleEmojiChunk(text.slice(lastIndex, start), parts);
parts.push(match[0]);
lastIndex = start + match[0].length;
});

scaleEmojiChunk(text.slice(lastIndex), parts);

const normalized: (string | JSX.Element)[] = [];
parts.forEach((part) => {
if (typeof part !== 'string') {
normalized.push(part);
return;
}

if (part === '') return;
const previous = normalized.at(-1);
if (typeof previous === 'string') {
normalized[normalized.length - 1] = `${previous}${part}`;
return;
}

normalized.push(part);
});

return normalized.length > 0 ? normalized : [''];
};

export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
const pattern = highlights.map(sanitizeForRegex).join('|');
Expand Down
44 changes: 44 additions & 0 deletions src/app/utils/emojiDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { isEmojiGrapheme, isJumboEmojiText, splitEmojiText } from './emojiDetection';

describe('isEmojiGrapheme', () => {
it.each(['🫩', '🫪', '🫯', '🇩🇪', '🙂‍↔️', '™️'])('matches emoji grapheme %s', (emoji) => {
expect(isEmojiGrapheme(emoji)).toBe(true);
});

it.each(['a', '12', 'http'])('does not match plain text segment %s', (value) => {
expect(isEmojiGrapheme(value)).toBe(false);
});
});

describe('splitEmojiText', () => {
it('preserves newer emoji as standalone parts', () => {
expect(splitEmojiText('a🫪b')).toEqual([
{ type: 'text', value: 'a' },
{ type: 'emoji', value: '🫪' },
{ type: 'text', value: 'b' },
]);
});

it('keeps emoji sequences whole', () => {
expect(splitEmojiText('🙂‍↔️')).toEqual([
{ type: 'text', value: '' },
{ type: 'emoji', value: '🙂‍↔️' },
{ type: 'text', value: '' },
]);
});
});

describe('isJumboEmojiText', () => {
it.each(['🫩', '🫪', '🫯', '🇩🇪', '🙂‍↔️'])('matches modern emoji sequence %s', (emoji) => {
expect(isJumboEmojiText(emoji)).toBe(true);
});

it.each(['123', 'hello', 'abc 123'])('does not match non-emoji text %s', (value) => {
expect(isJumboEmojiText(value)).toBe(false);
});

it('still matches shortcode-only content', () => {
expect(isJumboEmojiText(':blobcat:')).toBe(true);
});
});
88 changes: 88 additions & 0 deletions src/app/utils/emojiDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Emoji detection works on grapheme clusters, not raw code points.
* Intl.Segmenter keeps ZWJ sequences, flags, and keycaps intact as single user-visible units.
* Each grapheme is treated as emoji-like if it is a keycap sequence, an emoji forced by Variation Selector-16, or contains Emoji_Presentation, Extended_Pictographic, or Regional_Indicator.
* This is intentionally broader than `\p{RGI_Emoji}` because browsers can lag on that property for newer emojis like `🫪`.
* The goal here is UI rendering, so broad emoji-like detection is more useful than strict Unicode interchange validation.
*/

const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

const SHORTCODE_TOKEN_REG = /^:[^:\s]+:/u;
const EMOJI_GRAPHEME_REG =
/[#*0-9]\uFE0F?\u20E3|\p{Emoji}\uFE0F|[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}]/u;

export type EmojiTextPart =
| {
type: 'text';
value: string;
}
| {
type: 'emoji';
value: string;
};

export const getFirstGrapheme = (text: string): string => {
const first = graphemeSegmenter.segment(text)[Symbol.iterator]().next();
return first.done ? '' : first.value.segment;
};

export const isEmojiGrapheme = (segment: string): boolean => {
if (!segment) return false;
return EMOJI_GRAPHEME_REG.test(segment);
};

export const splitEmojiText = (text: string): EmojiTextPart[] => {
const parts: EmojiTextPart[] = [];
let buffer = '';
let foundEmoji = false;

[...graphemeSegmenter.segment(text)].forEach(({ segment }) => {
if (isEmojiGrapheme(segment)) {
foundEmoji = true;
parts.push({ type: 'text', value: buffer });
buffer = '';
parts.push({ type: 'emoji', value: segment });
} else {
buffer += segment;
}
});

if (!foundEmoji) {
return [{ type: 'text', value: buffer }];
}

parts.push({ type: 'text', value: buffer });
return parts;
};

export const isJumboEmojiText = (text: string, maxTokens = 10): boolean => {
if (!text) return false;

let tokenCount = 0;
let index = 0;

while (index < text.length) {
const remainder = text.slice(index);
const whitespaceMatch = /^\s+/u.exec(remainder);
if (whitespaceMatch) {
index += whitespaceMatch[0].length;
} else {
const shortcodeMatch = SHORTCODE_TOKEN_REG.exec(remainder);
if (shortcodeMatch) {
tokenCount += 1;
if (tokenCount > maxTokens) return false;
index += shortcodeMatch[0].length;
} else {
const grapheme = getFirstGrapheme(remainder);
if (!isEmojiGrapheme(grapheme)) return false;

tokenCount += 1;
if (tokenCount > maxTokens) return false;
index += grapheme.length;
}
}
}

return tokenCount > 0;
};
Loading
Loading