feat: add emoticon conversion feature for text and HTML inputs
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
164
src/app/utils/emoticonConverter.ts
Normal file
164
src/app/utils/emoticonConverter.ts
Normal 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 < and >
|
||||
* 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, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user