// SPDX-License-Identifier: AGPL-3.0-or-later import {Limits} from '@app/features/app/utils/UserLimits'; import {Autocomplete} from '@app/features/channel/components/Autocomplete'; import editingStyles from '@app/features/channel/components/EditingMessageInput.module.css'; import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter'; import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css'; import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton'; import styles from '@app/features/channel/components/textarea/TextareaInput.module.css'; import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField'; import type {Channel} from '@app/features/channel/models/Channel'; import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet'; import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout'; import GuildVerification, {VerificationFailureReason} from '@app/features/guild/state/GuildVerification'; import { CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR, EMOJIS_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import {useMarkdownFormattingShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds'; import {useTextareaAutocomplete} from '@app/features/messaging/hooks/useTextareaAutocomplete'; import {useTextareaEmojiPicker} from '@app/features/messaging/hooks/useTextareaEmojiPicker'; import {useTextareaPaste} from '@app/features/messaging/hooks/useTextareaPaste'; import {useTextareaSegments} from '@app/features/messaging/hooks/useTextareaSegments'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import MessageEdit from '@app/features/messaging/state/MessageEdit'; import TextareaSelection from '@app/features/messaging/state/TextareaSelection'; import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils'; import { captureTextareaSelection, focusTextareaWithSelection, } from '@app/features/messaging/utils/TextareaSelectionUtils'; import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {openPopout} from '@app/features/ui/popover/PopoverPopout'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import Users from '@app/features/user/state/Users'; import {MAX_MESSAGE_LENGTH_NON_PREMIUM, MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants'; import {Trans, useLingui} from '@lingui/react/macro'; import {SmileyIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'; export const EditingMessageInput = observer( ({ channel, message, onCancel, onSubmit, }: { channel: Channel; message: Message; onCancel: () => void; onSubmit: (actualContent?: string) => void; }) => { const {i18n} = useLingui(); const currentUser = Users.getCurrentUser(); const maxMessageLength = currentUser?.maxMessageLength ?? MAX_MESSAGE_LENGTH_NON_PREMIUM; const premiumMaxLength = Limits.getPremiumValue('max_message_length', MAX_MESSAGE_LENGTH_PREMIUM); const [value, setValue] = useState(() => { const persistedDraft = MessageEdit.getEditingContent(channel.id, message.id) ?? MessageEdit.getDraftContent(message.id); return persistedDraft ?? message.content; }); const textareaRef = useRef(null); const [expressionPickerOpen, setExpressionPickerOpen] = useState(false); const autocompleteListId = useId(); const hasInitializedRef = useRef(false); const containerRef = useRef(null); const scrollerRef = useRef(null); const mobileLayout = MobileLayout; const expressionPickerTriggerRef = useRef(null); const [isFocused, setIsFocused] = useState(false); useMarkdownKeybinds(isFocused); const [textareaHeight, setTextareaHeight] = useState(0); const hasScrolledInitiallyRef = useRef(false); const shouldStickToBottomRef = useRef(true); const hasFocusedInitiallyRef = useRef(false); const editingDisabled = channel.guildId != null && GuildVerification.getFailureReason(channel.guildId) === VerificationFailureReason.TIMED_OUT; const placeholderText = editingDisabled ? i18n._(CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR) : ''; const rememberTextareaSelection = useCallback(() => { const textarea = textareaRef.current; if (!textarea) { return; } if (!MessageEdit.isEditing(channel.id, message.id)) { return; } TextareaSelection.setEditingSelection(channel.id, message.id, captureTextareaSelection(textarea)); }, [channel.id, message.id]); const focusEditingTextarea = useCallback( (fallbackPosition?: number) => { const textarea = textareaRef.current; if (!textarea) { return; } focusTextareaWithSelection( textarea, TextareaSelection.getEditingSelection(channel.id, message.id), fallbackPosition, ); }, [channel.id, message.id], ); const handleScroll = useCallback(() => { const distance = scrollerRef.current?.getDistanceFromBottom?.(); if (distance == null) return; shouldStickToBottomRef.current = distance <= 8; }, []); const {segmentManagerRef, previousValueRef, displayToActual, prepareTextChange, handleTextChange} = useTextareaSegments(); const handleFormattingShortcut = useMarkdownFormattingShortcut({ textareaRef, value, setValue, handleTextChange, previousValueRef, }); const handleTextareaKeyDown = useCallback( (event: React.KeyboardEvent) => { handleFormattingShortcut(event); if (event.defaultPrevented) { return; } if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onCancel(); } }, [handleFormattingShortcut, onCancel], ); const {handleEmojiSelect} = useTextareaEmojiPicker({ setValue, textareaRef, segmentManagerRef, previousValueRef, prepareTextChange, }); const actualContent = useMemo(() => displayToActual(value), [displayToActual, value]); const { autocompleteQuery, autocompleteOptions, autocompleteType, selectedIndex, isAutocompleteAttached, setSelectedIndex, onCursorMove, handleSelect, } = useTextareaAutocomplete({ channel, value, setValue, textareaRef, segmentManagerRef, previousValueRef, prepareTextChange, allowedTriggers: ['emoji', 'emojiReaction', 'mention', 'channel'], }); useTextareaPaste({ channel, textareaRef, segmentManagerRef, setValue, previousValueRef, prepareTextChange, disabled: editingDisabled, }); useEffect(() => { if (editingDisabled) return; const textarea = textareaRef.current; if (!textarea) return; const rememberActiveTextareaSelection = () => { if (document.activeElement !== textarea) return; rememberTextareaSelection(); }; textarea.addEventListener('blur', rememberTextareaSelection); textarea.addEventListener('input', rememberActiveTextareaSelection); textarea.addEventListener('keyup', rememberActiveTextareaSelection); textarea.addEventListener('mouseup', rememberActiveTextareaSelection); textarea.addEventListener('select', rememberActiveTextareaSelection); document.addEventListener('selectionchange', rememberActiveTextareaSelection); return () => { rememberTextareaSelection(); textarea.removeEventListener('blur', rememberTextareaSelection); textarea.removeEventListener('input', rememberActiveTextareaSelection); textarea.removeEventListener('keyup', rememberActiveTextareaSelection); textarea.removeEventListener('mouseup', rememberActiveTextareaSelection); textarea.removeEventListener('select', rememberActiveTextareaSelection); document.removeEventListener('selectionchange', rememberActiveTextareaSelection); }; }, [editingDisabled, rememberTextareaSelection]); useEffect(() => { if (editingDisabled) { return; } if (hasFocusedInitiallyRef.current) { textareaRef.current?.focus(); return; } if (!hasInitializedRef.current && value) { hasInitializedRef.current = true; const displayText = applyMarkdownSegments(value, channel.guildId, segmentManagerRef.current); setValue(displayText); previousValueRef.current = displayText; hasFocusedInitiallyRef.current = true; requestAnimationFrame(() => { focusEditingTextarea(displayText.length); }); } else { hasFocusedInitiallyRef.current = true; focusEditingTextarea(); } }, [editingDisabled, value, channel.guildId, segmentManagerRef, previousValueRef, focusEditingTextarea]); useEffect(() => { if (!editingDisabled) { return; } textareaRef.current?.blur(); PopoutCommands.close(`editing-expression-picker-${channel.id}`); setExpressionPickerOpen(false); }, [channel.id, editingDisabled]); useEffect(() => { if (hasScrolledInitiallyRef.current) return; if (!scrollerRef.current) return; if (textareaHeight <= 0) return; scrollerRef.current.scrollToBottom({animate: false}); hasScrolledInitiallyRef.current = true; shouldStickToBottomRef.current = true; }, [textareaHeight]); useEffect(() => { if (hasInitializedRef.current) { MessageEdit.setEditingContent(channel.id, message.id, actualContent); } }, [channel.id, message.id, actualContent]); useEffect(() => { const unsubscribe = ComponentDispatch.subscribe('FOCUS_TEXTAREA', (payload?: unknown) => { const {channelId, enterKeyboardMode} = (payload ?? {}) as {channelId?: string; enterKeyboardMode?: boolean}; if (channelId && channelId !== channel.id) return; if (editingDisabled) return false; if (!MessageEdit.isEditing(channel.id, message.id)) return false; if (enterKeyboardMode) { KeyboardMode.enterKeyboardMode(true); } else { KeyboardMode.exitKeyboardMode(); } focusEditingTextarea(); return true; }); return unsubscribe; }, [channel.id, editingDisabled, focusEditingTextarea, message.id]); const handleSubmit = useCallback(() => { if (editingDisabled) { return; } if (actualContent.length > maxMessageLength) { return; } onSubmit(actualContent); }, [actualContent, editingDisabled, onSubmit, maxMessageLength]); const handleExpressionPickerToggle = useCallback(() => { if (editingDisabled) { return; } const triggerElement = expressionPickerTriggerRef.current; if (!triggerElement) return; const popoutKey = `editing-expression-picker-${channel.id}`; const isOpen = expressionPickerOpen; if (isOpen) { PopoutCommands.close(popoutKey); setExpressionPickerOpen(false); } else { openPopout( triggerElement, { render: ({onClose}) => ( ), position: 'top-end', animationType: 'none', offsetCrossAxis: 16, onOpen: () => setExpressionPickerOpen(true), onClose: () => setExpressionPickerOpen(false), returnFocusRef: textareaRef, }, popoutKey, ); } }, [channel.id, editingDisabled, expressionPickerOpen, handleEmojiSelect, textareaRef]); useEffect(() => { const unsubscribe = ComponentDispatch.subscribe('EDITING_EXPRESSION_PICKER_TAB_TOGGLE', (payload?: unknown) => { const data = payload as {channelId?: string; messageId?: string; tab?: string} | undefined; if (!data || data.channelId !== channel.id || data.messageId !== message.id || data.tab !== 'emojis') return; handleExpressionPickerToggle(); }); return unsubscribe; }, [channel.id, handleExpressionPickerToggle, message.id]); const showAutocomplete = !editingDisabled && isAutocompleteAttached; const displayValue = editingDisabled ? '' : value; const displayContentLength = editingDisabled ? 0 : actualContent.length; return ( <> {showAutocomplete && ( )}
setIsFocused(true)} onBlur={() => setIsFocused(false)} onChange={(newValue, inputType, hint) => { handleTextChange(newValue, previousValueRef.current, inputType, hint); setValue(newValue); }} onHeightChange={setTextareaHeight} onCursorMove={onCursorMove} onArrowUp={() => {}} onEnter={handleSubmit} onAutocompleteSelect={handleSelect} setSelectedIndex={setSelectedIndex} onKeyDown={handleTextareaKeyDown} data-flx="channel.editing-message-input.textarea-input-field.text-change" />
setExpressionPickerOpen(true) : handleExpressionPickerToggle} disabled={editingDisabled} data-expression-picker-tab="emojis" compact={true} data-flx="channel.editing-message-input.textarea-button.set-expression-picker-open" />
escape to{' '}
enter to{' '}
{mobileLayout.enabled && !editingDisabled && ( setExpressionPickerOpen(false)} channelId={channel.id} onEmojiSelect={handleEmojiSelect} visibleTabs={['emojis']} selectedTab="emojis" data-flx="channel.editing-message-input.expression-picker-sheet" /> )} ); }, );