From f6df3169cac905b6ae8cb57ec3b4b9ffe82b4158 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 28 Sep 2026 00:48:23 +0200 Subject: [PATCH] fix(app): use +:shortcode: for reactions, no space before emoji (#3001) --- .../LexicalChannelTextareaContent.tsx | 2 +- .../composer/ComposerEmojiShortcode.ts | 5 +- .../lexical/composer/ComposerInsertion.ts | 22 ++++++-- .../composer/useLexicalAutocomplete.ts | 20 +++---- .../messaging/hooks/useTextareaSubmit.ts | 2 +- .../messaging/utils/ReactionShorthandUtils.ts | 56 +++++++++++++------ 6 files changed, 68 insertions(+), 39 deletions(-) diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index 92b25ceb6..597b4f51f 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -326,7 +326,7 @@ export const LexicalChannelTextareaContent = observer( }, [value, segmentManagerRef]); const handleEmojiSelect = useCallback( (emoji: FlatEmoji, shiftKey?: boolean): boolean => { - const didInsert = insertComposerEmoji(handleRef.current, emoji); + const didInsert = insertComposerEmoji(handleRef.current, emoji, {reactionShorthand: true}); if (didInsert && !shiftKey) { ExpressionPickerCommands.close(); PopoutCommands.close(`expression-picker-${channel.id}`); diff --git a/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts b/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts index c61d08d10..b955a21d8 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts @@ -15,8 +15,9 @@ import { } from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode'; import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; import {findTypedEmojiShortcode, type TypedEmojiMatch} from '@app/features/messaging/utils/markdown/TypedEmojiMatch'; +import {isReactionShorthandText} from '@app/features/messaging/utils/ReactionShorthandUtils'; import type {ResolvedTypedEmoji} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils'; -import {type LexicalEditor, TextNode} from 'lexical'; +import {$getRoot, type LexicalEditor, TextNode} from 'lexical'; export type ComposerEmojiResolver = (shortcodeName: string) => ResolvedTypedEmoji | null; @@ -71,7 +72,7 @@ function isEscapedAt(text: string, index: number): boolean { } export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver): void { - if ($isSyntaxMarkerNode(node) || node.hasFormat('code')) { + if ($isSyntaxMarkerNode(node) || node.hasFormat('code') || isReactionShorthandText($getRoot().getTextContent())) { return; } const parent = node.getParent(); diff --git a/fluxer_app/src/features/lexical/composer/ComposerInsertion.ts b/fluxer_app/src/features/lexical/composer/ComposerInsertion.ts index 3ff4a1f24..0c9d9c882 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerInsertion.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerInsertion.ts @@ -6,6 +6,10 @@ import * as EmojiImageUtils from '@app/features/expressions/utils/EmojiUtils'; import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils'; import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle'; import type {ComposerInsertPayload, ComposerInsertSpacing} from '@app/features/lexical/composer/composerOffsets'; +import { + getReactionShortcodeName, + isReactionShorthandPrefix, +} from '@app/features/messaging/utils/ReactionShorthandUtils'; import {type MentionSegment, TextareaSegmentManager} from '@app/features/messaging/utils/TextareaSegmentManager'; export interface ComposerReplacementPlan { @@ -24,6 +28,10 @@ export interface ComposerReplacementLimit { onExceedMaxLength?: () => void; } +export interface ComposerEmojiInsertOptions extends ComposerReplacementLimit { + reactionShorthand?: boolean; +} + interface ComposerPayloadSegment { type: MentionSegment['type']; id: string; @@ -162,23 +170,27 @@ export function applyComposerReplacement( export function insertComposerEmoji( handle: ComposerHandle | null, emoji: FlatEmoji, - limit: ComposerReplacementLimit = {}, + options: ComposerEmojiInsertOptions = {}, ): boolean { if (handle == null) { return false; } const display = handle.getDisplayValue(); const selection = normalizeSelection(display, handle.getSelection()); - const charBefore = selection.start > 0 ? display[selection.start - 1] : ''; const charAfter = selection.end < display.length ? display[selection.end] : ''; + const payload: ComposerInsertPayload = + options.reactionShorthand === true && + isReactionShorthandPrefix(display.slice(0, selection.start), display.slice(selection.end)) + ? {kind: 'text', text: `:${getReactionShortcodeName(emoji)}:`} + : createComposerEmojiPayload(emoji); return applyComposerReplacement( handle, selection, - createComposerEmojiPayload(emoji), + payload, { - leading: charBefore !== '' && !/\s/.test(charBefore), + leading: false, trailing: charAfter === '' || !/\s/.test(charAfter), }, - limit, + options, ); } diff --git a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts index 20434234f..9ecb49cd3 100644 --- a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts +++ b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts @@ -57,8 +57,6 @@ import type {GuildMember} from '@app/features/member/models/GuildMember'; import GuildMembers from '@app/features/member/state/GuildMembers'; import type {SearchContext} from '@app/features/member/state/MemberSearch'; import * as HighlightCommands from '@app/features/messaging/commands/HighlightCommands'; -import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; -import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands'; import Messages from '@app/features/messaging/state/MessagingMessages'; import { filterAutocompleteMediaOptions, @@ -77,8 +75,7 @@ import { SPECIAL_MENTIONS, } from '@app/features/messaging/utils/AutocompleteOptionBuilders'; import {isAutocompleteTriggerAllowed, type TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy'; -import {toReactionEmoji} from '@app/features/messaging/utils/MessageReactionUtils'; -import {getReactionShorthandTargetId} from '@app/features/messaging/utils/ReactionShorthandUtils'; +import {getReactionShortcodeName} from '@app/features/messaging/utils/ReactionShorthandUtils'; import { type AutocompleteTrigger, detectAutocompleteTrigger, @@ -706,14 +703,13 @@ export function useLexicalAutocomplete({ const caret = currentTextUpToCursor.length; const matchStart = getComposerAutocompleteReplacementStart(currentTextUpToCursor, trigger.type, trigger.match); if (trigger.type === 'emojiReaction' && isEmoji(option)) { - if (channel != null) { - const targetId = getReactionShorthandTargetId(channel.id); - if (targetId !== null) { - ReactionCommands.addReaction(i18n, channel.id, targetId, toReactionEmoji(option.emoji)); - MessageCommands.stopReply(channel.id); - } - } - handle.clear(); + applyComposerReplacement( + handle, + {start: matchStart, end: caret}, + {kind: 'text', text: `+:${getReactionShortcodeName(option.emoji)}:`}, + {trailing: true}, + {maxWireLength: maxActualLength, onExceedMaxLength}, + ); return; } if (isCommand(option)) { diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts index dc69421b9..1359d6fa6 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts @@ -465,7 +465,7 @@ export const useTextareaSubmit = ({ const replaceCommand = ReplaceCommandUtils.parseReplaceCommand(actualContent); const reactionShorthand = editingMessage === null && uploadAttachmentsLength === 0 && !hasPendingSticker - ? parseReactionShorthand(resolvedContent) + ? parseReactionShorthand(actualContent, Channels.getChannel(channelId) ?? null, guildId, i18n) : null; const reactionTargetId = reactionShorthand === null ? null : getReactionShorthandTargetId(channelId); if (reactionShorthand !== null && reactionTargetId !== null) { diff --git a/fluxer_app/src/features/messaging/utils/ReactionShorthandUtils.ts b/fluxer_app/src/features/messaging/utils/ReactionShorthandUtils.ts index 27fa9f629..b7a8fa864 100644 --- a/fluxer_app/src/features/messaging/utils/ReactionShorthandUtils.ts +++ b/fluxer_app/src/features/messaging/utils/ReactionShorthandUtils.ts @@ -1,34 +1,54 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {Channel} from '@app/features/channel/models/Channel'; +import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; +import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils'; import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis'; import MessageReply from '@app/features/messaging/state/MessageReply'; import Messages from '@app/features/messaging/state/MessagingMessages'; -import {type ReactionEmoji, toReactionEmoji} from '@app/features/messaging/utils/MessageReactionUtils'; +import type {ReactionEmoji} from '@app/features/messaging/utils/MessageReactionUtils'; +import {resolveTypedEmojiToken} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils'; +import type {I18n} from '@lingui/core'; -const REACTION_SHORTHAND_PATTERN = /^\+(\S+)$/u; -const CUSTOM_EMOJI_MARKDOWN_PATTERN = /^<(a)?:([a-zA-Z0-9_+-]{2,}):(\d+)>$/; -const SHORTCODE_PATTERN = /^:([^\s:]+):$/; +const REACTION_SHORTHAND_PATTERN = /^\s*\+:([^\s:]+(?:::skin-tone-[1-5])?):\s*$/u; +const REACTION_SHORTHAND_PREFIX_PATTERN = /^\s*\+$/; -export function parseReactionShorthand(content: string): ReactionEmoji | null { - const match = REACTION_SHORTHAND_PATTERN.exec(content.trim()); +export function isReactionShorthandText(text: string): boolean { + return REACTION_SHORTHAND_PATTERN.test(text); +} + +export function isReactionShorthandPrefix(textBefore: string, textAfter: string): boolean { + return REACTION_SHORTHAND_PREFIX_PATTERN.test(textBefore) && textAfter.trim() === ''; +} + +export function getReactionShortcodeName(emoji: FlatEmoji): string { + if (emoji.id) { + return emoji.name; + } + const name = UnicodeEmojis.nameForSurrogate(getSkinTonedSurrogate(emoji), false); + return name === '' ? emoji.name : name; +} + +export function parseReactionShorthand( + content: string, + channel: Channel | null, + guildId: string | null, + i18n: I18n, +): ReactionEmoji | null { + const match = REACTION_SHORTHAND_PATTERN.exec(content); if (match === null) { return null; } - const token = match[1]; - const customMatch = CUSTOM_EMOJI_MARKDOWN_PATTERN.exec(token); - if (customMatch !== null) { - return {id: customMatch[3], name: customMatch[2], animated: customMatch[1] === 'a'}; + const shortcodeName = match[1]; + const unicodeEmoji = UnicodeEmojis.findEmojiByShortcodeName(shortcodeName); + if (unicodeEmoji !== null) { + return {name: unicodeEmoji.surrogates}; } - const shortcodeMatch = SHORTCODE_PATTERN.exec(token); - if (shortcodeMatch !== null) { - const emoji = UnicodeEmojis.findEmojiByShortcodeName(shortcodeMatch[1]); - return emoji === null ? null : toReactionEmoji(emoji); - } - const name = UnicodeEmojis.nameForSurrogate(token, false); - if (name === '') { + const resolved = resolveTypedEmojiToken(shortcodeName, channel, guildId, i18n); + if (resolved === null || resolved.kind !== 'custom') { return null; } - return {name: UnicodeEmojis.surrogateForName(name, token)}; + return {id: resolved.emojiId, name: shortcodeName.replace(/~\d+$/, ''), animated: resolved.animated}; } export function getReactionShorthandTargetId(channelId: string): string | null {