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 { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
import { getMemberAvatarMxc } from '../../utils/room';
|
import { getMemberAvatarMxc } from '../../utils/room';
|
||||||
|
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -134,6 +135,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [autoConvertEmoticons] = useSetting(settingsAtom, 'autoConvertEmoticons');
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
const commands = useCommands(mx, room);
|
const commands = useCommands(mx, room);
|
||||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -348,8 +350,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
if (plainText === '') return;
|
if (plainText === '') return;
|
||||||
|
|
||||||
const body = plainText;
|
let body = plainText;
|
||||||
const formattedBody = customHtml;
|
let formattedBody = customHtml;
|
||||||
|
|
||||||
|
// Apply emoticon conversion if enabled
|
||||||
|
if (autoConvertEmoticons) {
|
||||||
|
body = convertEmoticons(body);
|
||||||
|
formattedBody = convertEmoticonsInHtml(formattedBody);
|
||||||
|
}
|
||||||
|
|
||||||
const mentionData = getMentions(mx, roomId, editor);
|
const mentionData = getMentions(mx, roomId, editor);
|
||||||
|
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
@@ -385,7 +394,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, editorRef]);
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { GlobalPacks } from './GlobalPacks';
|
import { GlobalPacks } from './GlobalPacks';
|
||||||
import { UserPack } from './UserPack';
|
import { UserPack } from './UserPack';
|
||||||
import { TelegramImport } from './TelegramImport';
|
import { TelegramImport } from './TelegramImport';
|
||||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||||
import { ImagePackView } from '../../../components/image-pack-view';
|
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 = {
|
type EmojisStickersProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||||
const [imagePack, setImagePack] = useState<ImagePack>();
|
const [imagePack, setImagePack] = useState<ImagePack>();
|
||||||
|
const [autoConvertEmoticons, setAutoConvertEmoticons] = useSetting(settingsAtom, 'autoConvertEmoticons');
|
||||||
|
|
||||||
const handleImagePackViewClose = () => {
|
const handleImagePackViewClose = () => {
|
||||||
setImagePack(undefined);
|
setImagePack(undefined);
|
||||||
@@ -41,6 +46,22 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
|||||||
<Scroll hideTrack visibility="Hover">
|
<Scroll hideTrack visibility="Hover">
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<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} />
|
<UserPack onViewPack={setImagePack} />
|
||||||
<GlobalPacks onViewPack={setImagePack} />
|
<GlobalPacks onViewPack={setImagePack} />
|
||||||
<TelegramImport onViewPack={setImagePack} />
|
<TelegramImport onViewPack={setImagePack} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Settings {
|
|||||||
isMarkdown: boolean;
|
isMarkdown: boolean;
|
||||||
editorToolbar: boolean;
|
editorToolbar: boolean;
|
||||||
emojiStyle: EmojiStyle;
|
emojiStyle: EmojiStyle;
|
||||||
|
autoConvertEmoticons: boolean;
|
||||||
pageZoom: number;
|
pageZoom: number;
|
||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ const defaultSettings: Settings = {
|
|||||||
isMarkdown: true,
|
isMarkdown: true,
|
||||||
editorToolbar: false,
|
editorToolbar: false,
|
||||||
emojiStyle: EmojiStyle.Apple,
|
emojiStyle: EmojiStyle.Apple,
|
||||||
|
autoConvertEmoticons: true,
|
||||||
pageZoom: 100,
|
pageZoom: 100,
|
||||||
hideActivity: false,
|
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-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
/* Window rounding for borderless window */
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -88,9 +85,6 @@ body {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
/* Default to dark theme background for safe areas */
|
/* Default to dark theme background for safe areas */
|
||||||
background-color: #1A1A1A;
|
background-color: #1A1A1A;
|
||||||
/* Window rounding */
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
|
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
|
||||||
font-variant-ligatures: no-contextual;
|
font-variant-ligatures: no-contextual;
|
||||||
@@ -114,8 +108,6 @@ body.butter-theme {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user