feat: add emoticon conversion feature for text and HTML inputs

This commit is contained in:
2026-02-21 23:55:15 +11:00
parent f00e20b1ac
commit e218ab5c37
5 changed files with 200 additions and 12 deletions

View File

@@ -119,6 +119,7 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
interface RoomInputProps {
editor: Editor;
@@ -134,6 +135,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [autoConvertEmoticons] = useSetting(settingsAtom, 'autoConvertEmoticons');
const direct = useIsDirectRoom();
const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
@@ -348,8 +350,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (plainText === '') return;
const body = plainText;
const formattedBody = customHtml;
let body = plainText;
let formattedBody = customHtml;
// Apply emoticon conversion if enabled
if (autoConvertEmoticons) {
body = convertEmoticons(body);
formattedBody = convertEmoticonsInHtml(formattedBody);
}
const mentionData = getMentions(mx, roomId, editor);
const content: IContent = {
@@ -385,7 +394,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditorHistory(editor);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, editorRef]);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {

View File

@@ -1,17 +1,22 @@
import React, { useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Box, Text, IconButton, Icon, Icons, Scroll, config, Switch } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { GlobalPacks } from './GlobalPacks';
import { UserPack } from './UserPack';
import { TelegramImport } from './TelegramImport';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
type EmojisStickersProps = {
requestClose: () => void;
};
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
const [imagePack, setImagePack] = useState<ImagePack>();
const [autoConvertEmoticons, setAutoConvertEmoticons] = useSetting(settingsAtom, 'autoConvertEmoticons');
const handleImagePackViewClose = () => {
setImagePack(undefined);
@@ -41,6 +46,22 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard variant="SurfaceVariant" direction="Column">
<SettingTile
title="Auto-convert Text Emoticons"
description="Automatically convert text emoticons like :) :D :( to emoji when sending messages."
after={
<Switch
variant="Primary"
value={autoConvertEmoticons}
onChange={setAutoConvertEmoticons}
/>
}
/>
</SequenceCard>
</Box>
<UserPack onViewPack={setImagePack} />
<GlobalPacks onViewPack={setImagePack} />
<TelegramImport onViewPack={setImagePack} />

View File

@@ -24,6 +24,7 @@ export interface Settings {
isMarkdown: boolean;
editorToolbar: boolean;
emojiStyle: EmojiStyle;
autoConvertEmoticons: boolean;
pageZoom: number;
hideActivity: boolean;
@@ -65,6 +66,7 @@ const defaultSettings: Settings = {
isMarkdown: true,
editorToolbar: false,
emojiStyle: EmojiStyle.Apple,
autoConvertEmoticons: true,
pageZoom: 100,
hideActivity: false,

View File

@@ -0,0 +1,164 @@
/**
* Text emoticon to emoji converter
* Automatically converts common text emoticons like :) :D etc. to their emoji equivalents
*/
// Map of text emoticons to emoji
// Using common patterns that don't interfere with markdown links or other syntax
const EMOTICON_MAP: Record<string, string> = {
// Happy faces
':D': '😀',
':-D': '😀',
':)': '🙂',
':-)': '🙂',
'=)': '😊',
':]': '😊',
':>': '😊',
// Sad faces
':(': '☹️',
':-(': '☹️',
'=[': '☹️',
':<': '☹️',
// Other emotions
';)': '😉',
';-)': '😉',
':P': '😛',
':-P': '😛',
':p': '😛',
':-p': '😛',
'xD': '😆',
'XD': '😆',
':|': '😐',
':-|': '😐',
':O': '😮',
':-O': '😮',
':o': '😮',
':-o': '😮',
'O:)': '😇',
'O:-)': '😇',
':*': '😘',
':-*': '😘',
'<3': '❤️',
'</3': '💔',
// Surprised/Shocked
':0': '😯',
':-0': '😯',
// Cool
'8)': '😎',
'8-)': '😎',
'B)': '😎',
'B-)': '😎',
// Crying
':\'(': '😢',
':\')': '😂',
// Neutral
':/': '😕',
':-/': '😕',
':\\': '😕',
':-\\': '😕',
};
/**
* Converts text emoticons in a string to their emoji equivalents
* Use backslash to escape: \:) will remain as :) without converting
* @param text The text to process
* @returns The text with emoticons converted to emoji
*/
export function convertEmoticons(text: string): string {
let result = text;
// Sort emoticons by length (longest first) to handle overlapping patterns
// e.g., :-) before :)
const emoticons = Object.keys(EMOTICON_MAP).sort((a, b) => b.length - a.length);
for (const emoticon of emoticons) {
// Escape special regex characters
const escapedEmoticon = emoticon.replace(/[.*+?^${}()|[\]\\<>]/g, '\\$&');
// Match emoticons with optional backslash escape
// Matches either \:) (escaped) or :) (not escaped, and not preceded by backslash)
const regex = new RegExp(
`(^|\\s)(\\\\)?(${escapedEmoticon})(?=\\s|$)`,
'g'
);
result = result.replace(regex, (match, prefix, backslash, emote) => {
// If backslash is present, remove it but keep the emoticon literal
if (backslash) {
return prefix + emote;
}
// Otherwise convert to emoji
return prefix + EMOTICON_MAP[emoticon];
});
}
return result;
}
/**
* Converts text emoticons in HTML to their emoji equivalents
* Handles HTML entities like &lt; and &gt;
* Use backslash to escape: \:) will remain as :) without converting
* @param html The HTML text to process
* @returns The HTML with emoticons converted to emoji
*/
export function convertEmoticonsInHtml(html: string): string {
let result = html;
// Sort emoticons by length (longest first) to handle overlapping patterns
const emoticons = Object.keys(EMOTICON_MAP).sort((a, b) => b.length - a.length);
for (const emoticon of emoticons) {
// Convert emoticon to HTML entity version (for < and >)
const htmlEmoticon = emoticon
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Escape special regex characters
const escapedEmoticon = htmlEmoticon.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match emoticons at start of string, after whitespace, or after HTML tags
// With optional backslash escape
const regex = new RegExp(
`(^|\\s|>)(\\\\)?(${escapedEmoticon})(?=\\s|<|$)`,
'g'
);
result = result.replace(regex, (match, prefix, backslash, emote) => {
// If backslash is present, remove it but keep the emoticon literal
if (backslash) {
return prefix + emote;
}
// Otherwise convert to emoji
return prefix + EMOTICON_MAP[emoticon];
});
}
return result;
}
/**
* Checks if a string contains any text emoticons
* @param text The text to check
* @returns True if the text contains emoticons
*/
export function containsEmoticons(text: string): boolean {
const emoticons = Object.keys(EMOTICON_MAP);
for (const emoticon of emoticons) {
const escapedEmoticon = emoticon.replace(/[.*+?^${}()|[\]\\<>]/g, '\\$&');
const regex = new RegExp(`(^|\\s)(${escapedEmoticon})(?=\\s|$)`);
if (regex.test(text)) {
return true;
}
}
return false;
}

View File

@@ -69,9 +69,6 @@ html {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
/* Window rounding for borderless window */
border-radius: 4px;
overflow: hidden;
}
body {
@@ -88,9 +85,6 @@ body {
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #1A1A1A;
/* Window rounding */
border-radius: 4px;
overflow: hidden;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
@@ -114,8 +108,6 @@ body.butter-theme {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
}
*,