fluxer/fluxer_app/src/components/channel/ChannelTextarea.tsx
2026-01-01 21:05:54 +00:00

902 lines
31 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {PlusCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as DraftActionCreators from '~/actions/DraftActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import * as ScheduledMessageActionCreators from '~/actions/ScheduledMessageActionCreators';
import {MAX_MESSAGE_LENGTH_NON_PREMIUM, Permissions} from '~/Constants';
import {TooManyAttachmentsModal} from '~/components/alerts/TooManyAttachmentsModal';
import {Autocomplete} from '~/components/channel/Autocomplete';
import {ChannelAttachmentArea} from '~/components/channel/ChannelAttachmentArea';
import {ChannelStickersArea} from '~/components/channel/ChannelStickersArea';
import {EditBar} from '~/components/channel/EditBar';
import {
getMentionDescription,
getMentionTitle,
MentionEveryonePopout,
} from '~/components/channel/MentionEveryonePopout';
import {MessageCharacterCounter} from '~/components/channel/MessageCharacterCounter';
import {ReplyBar} from '~/components/channel/ReplyBar';
import {ScheduledMessageEditBar} from '~/components/channel/ScheduledMessageEditBar';
import {MessageInputButtonsContextMenu} from '~/components/channel/textarea/MessageInputButtonsContextMenu';
import {TextareaButton} from '~/components/channel/textarea/TextareaButton';
import {TextareaButtons} from '~/components/channel/textarea/TextareaButtons';
import {TextareaInputField} from '~/components/channel/textarea/TextareaInputField';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import {ScheduleMessageModal} from '~/components/modals/ScheduleMessageModal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {openPopout} from '~/components/uikit/Popout/Popout';
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
import {useTextareaAttachments} from '~/hooks/useCloudUpload';
import {doesEventMatchShortcut, MARKDOWN_FORMATTING_SHORTCUTS, useMarkdownKeybinds} from '~/hooks/useMarkdownKeybinds';
import {useMessageSubmission} from '~/hooks/useMessageSubmission';
import {useSlowmode} from '~/hooks/useSlowmode';
import {useTextareaAutocomplete} from '~/hooks/useTextareaAutocomplete';
import {useTextareaDraftAndTyping} from '~/hooks/useTextareaDraftAndTyping';
import {useTextareaEditing} from '~/hooks/useTextareaEditing';
import {useTextareaEmojiPicker} from '~/hooks/useTextareaEmojiPicker';
import {useTextareaExpressionHandlers} from '~/hooks/useTextareaExpressionHandlers';
import {useTextareaExpressionPicker} from '~/hooks/useTextareaExpressionPicker';
import {useTextareaKeyboard} from '~/hooks/useTextareaKeyboard';
import {useTextareaPaste} from '~/hooks/useTextareaPaste';
import {useTextareaSegments} from '~/hooks/useTextareaSegments';
import {type MentionConfirmationInfo, useTextareaSubmit} from '~/hooks/useTextareaSubmit';
import {CloudUpload} from '~/lib/CloudUpload';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {safeFocus} from '~/lib/InputFocusManager';
import type {ChannelRecord} from '~/records/ChannelRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ChannelStickerStore from '~/stores/ChannelStickerStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import DraftStore from '~/stores/DraftStore';
import FeatureFlagStore from '~/stores/FeatureFlagStore';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import MessageEditMobileStore from '~/stores/MessageEditMobileStore';
import MessageEditStore from '~/stores/MessageEditStore';
import MessageReplyStore from '~/stores/MessageReplyStore';
import MessageStore from '~/stores/MessageStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import ScheduledMessageEditorStore from '~/stores/ScheduledMessageEditorStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {openFilePicker} from '~/utils/FilePickerUtils';
import * as FileUploadUtils from '~/utils/FileUploadUtils';
import {normalizeMessageContent} from '~/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '~/utils/MessageSubmitUtils';
import * as PlaceholderUtils from '~/utils/PlaceholderUtils';
import wrapperStyles from './textarea/InputWrapper.module.css';
import styles from './textarea/TextareaInput.module.css';
const ChannelTextareaContent = observer(
({
channel,
draft,
disabled,
canAttachFiles,
}: {
channel: ChannelRecord;
draft: string | null;
disabled: boolean;
canAttachFiles: boolean;
}) => {
const {t, i18n} = useLingui();
const [isFocused, setIsFocused] = React.useState(false);
const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false);
const [value, setValue] = React.useState('');
const [showAllButtons, setShowAllButtons] = React.useState(true);
const [_textareaHeight, setTextareaHeight] = React.useState(0);
const [containerWidth, setContainerWidth] = React.useState(0);
const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState<MentionConfirmationInfo | null>(
null,
);
const mentionPopoutKey = React.useMemo(() => `mention-everyone-${channel.id}`, [channel.id]);
const mentionModalKey = React.useMemo(() => `mention-everyone-modal-${channel.id}`, [channel.id]);
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const expressionPickerTriggerRef = React.useRef<HTMLButtonElement>(null);
const invisibleExpressionPickerTriggerRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<ScrollerHandle>(null);
useMarkdownKeybinds(isFocused);
const showGiftButton = AccessibilityStore.showGiftButton;
const showGifButton = AccessibilityStore.showGifButton;
const showMemesButton = AccessibilityStore.showMemesButton;
const showStickersButton = AccessibilityStore.showStickersButton;
const showEmojiButton = AccessibilityStore.showEmojiButton;
const showUploadButton = AccessibilityStore.showUploadButton;
const showMessageSendButton = AccessibilityStore.showMessageSendButton;
const editingMessageId = MessageEditStore.getEditingMessageId(channel.id);
const editingMobileMessageId = MessageEditMobileStore.getEditingMobileMessageId(channel.id);
const mobileLayout = MobileLayoutStore;
const replyingMessage = MessageReplyStore.getReplyingMessage(channel.id);
const referencedMessage = replyingMessage ? MessageStore.getMessage(channel.id, replyingMessage.messageId) : null;
const editingMessage = editingMobileMessageId ? MessageStore.getMessage(channel.id, editingMobileMessageId) : null;
const currentUser = UserStore.getCurrentUser();
const maxMessageLength = currentUser?.maxMessageLength ?? MAX_MESSAGE_LENGTH_NON_PREMIUM;
const uploadAttachments = useTextareaAttachments(channel.id);
const {isSlowmodeActive} = useSlowmode(channel);
const {segmentManagerRef, previousValueRef, displayToActual, insertSegment, handleTextChange, clearSegments} =
useTextareaSegments();
const {handleEmojiSelect} = useTextareaEmojiPicker({
setValue,
textareaRef,
insertSegment,
previousValueRef,
channelId: channel.id,
});
const scheduledMessageEditorState = ScheduledMessageEditorStore.getEditingState();
const isEditingScheduledMessage = ScheduledMessageEditorStore.isEditingChannel(channel.id);
const editingScheduledMessage = isEditingScheduledMessage ? scheduledMessageEditorState : null;
const selectedGuildId = SelectedGuildStore.selectedGuildId;
const hasMessageSchedulingAccess = FeatureFlagStore.isMessageSchedulingEnabled(selectedGuildId ?? undefined);
const {sendMessage, sendOptimisticMessage} = useMessageSubmission({
channel,
referencedMessage: referencedMessage ?? null,
replyingMessage,
clearSegments,
});
const handleCancelScheduledEdit = React.useCallback(() => {
ScheduledMessageEditorStore.stopEditing();
DraftActionCreators.deleteDraft(channel.id);
setValue('');
clearSegments();
}, [channel.id, clearSegments, setValue]);
const handleSendMessage = React.useCallback(
(...args: Parameters<typeof sendMessage>) => {
setValue('');
clearSegments();
sendMessage(...args);
},
[sendMessage, clearSegments],
);
const handleMentionConfirmationNeeded = React.useCallback((info: MentionConfirmationInfo) => {
setPendingMentionConfirmation(info);
}, []);
const handleMentionConfirm = React.useCallback(() => {
if (pendingMentionConfirmation) {
handleSendMessage(pendingMentionConfirmation.content, false, pendingMentionConfirmation.tts);
setPendingMentionConfirmation(null);
}
}, [pendingMentionConfirmation, handleSendMessage]);
const handleMentionCancel = React.useCallback(() => {
setPendingMentionConfirmation(null);
textareaRef.current?.focus();
}, []);
React.useEffect(() => {
if (!pendingMentionConfirmation) {
PopoutActionCreators.close(mentionPopoutKey);
ModalActionCreators.popWithKey(mentionModalKey);
return;
}
if (mobileLayout.enabled) {
const index = pendingMentionConfirmation.mentionType;
const title = getMentionTitle(index, pendingMentionConfirmation.roleName);
const description = getMentionDescription(
index,
pendingMentionConfirmation.memberCount,
pendingMentionConfirmation.roleName,
);
ModalActionCreators.pushWithKey(
modal(() => (
<ConfirmModal
title={title}
description={description}
primaryText={t`Continue`}
secondaryText={t`Cancel`}
onPrimary={() => {
handleMentionConfirm();
}}
onSecondary={() => {
handleMentionCancel();
}}
/>
)),
mentionModalKey,
);
return () => {
ModalActionCreators.popWithKey(mentionModalKey);
};
}
const containerElement = containerRef.current;
if (!containerElement) {
return;
}
openPopout(
containerElement,
{
render: ({onClose}) => (
<MentionEveryonePopout
mentionType={pendingMentionConfirmation.mentionType}
memberCount={pendingMentionConfirmation.memberCount}
roleName={pendingMentionConfirmation.roleName}
onConfirm={() => {
handleMentionConfirm();
onClose();
}}
onCancel={() => {
handleMentionCancel();
onClose();
}}
/>
),
position: 'top-start',
offsetMainAxis: 8,
shouldAutoUpdate: true,
returnFocusRef: textareaRef,
onCloseRequest: () => {
handleMentionCancel();
return true;
},
},
mentionPopoutKey,
);
return () => {
PopoutActionCreators.close(mentionPopoutKey);
};
}, [
pendingMentionConfirmation,
mentionPopoutKey,
mentionModalKey,
handleMentionConfirm,
handleMentionCancel,
textareaRef,
mobileLayout.enabled,
]);
const {
autocompleteQuery,
autocompleteOptions,
autocompleteType,
selectedIndex,
isAutocompleteAttached,
setSelectedIndex,
onCursorMove,
handleSelect,
} = useTextareaAutocomplete({
channel,
value,
setValue,
textareaRef,
segmentManagerRef,
previousValueRef,
});
React.useEffect(() => {
ComponentDispatch.safeDispatch('TEXTAREA_AUTOCOMPLETE_CHANGED', {
channelId: channel.id,
open: isAutocompleteAttached,
});
}, [channel.id, isAutocompleteAttached]);
const trimmedMessageContent = displayToActual(value).trim();
const hasScheduleContent = trimmedMessageContent.length > 0 || uploadAttachments.length > 0;
const canScheduleMessage = hasMessageSchedulingAccess && !disabled && hasScheduleContent;
useTextareaPaste({
channel,
textareaRef,
segmentManagerRef,
setValue,
previousValueRef,
});
const handleOpenScheduleModal = React.useCallback(() => {
if (!hasMessageSchedulingAccess) {
return;
}
setIsScheduleModalOpen(true);
}, [hasMessageSchedulingAccess]);
const handleScheduleSubmit = React.useCallback(
async (scheduledLocalAt: string, timezone: string) => {
const actualContent = displayToActual(value).trim();
if (!actualContent && uploadAttachments.length === 0) {
return;
}
const normalized = normalizeMessageContent(actualContent, undefined);
if (editingScheduledMessage) {
await ScheduledMessageActionCreators.updateScheduledMessage(i18n, {
channelId: channel.id,
scheduledMessageId: editingScheduledMessage.scheduledMessageId,
scheduledLocalAt,
timezone,
normalized,
payload: editingScheduledMessage.payload,
replyMentioning: replyingMessage?.mentioning,
});
ScheduledMessageEditorStore.stopEditing();
} else {
await ScheduledMessageActionCreators.scheduleMessage(i18n, {
channelId: channel.id,
content: actualContent,
scheduledLocalAt,
timezone,
messageReference: MessageSubmitUtils.prepareMessageReference(channel.id, referencedMessage),
replyMentioning: replyingMessage?.mentioning,
favoriteMemeId: undefined,
stickers: undefined,
tts: false,
hasAttachments: uploadAttachments.length > 0,
});
}
setValue('');
clearSegments();
setIsScheduleModalOpen(false);
},
[
channel.id,
clearSegments,
displayToActual,
editingScheduledMessage,
referencedMessage,
replyingMessage?.mentioning,
setIsScheduleModalOpen,
setValue,
uploadAttachments.length,
value,
],
);
const handleFileButtonClick = async () => {
const files = await openFilePicker({multiple: true});
const result = await FileUploadUtils.handleFileUpload(channel.id, files, uploadAttachments.length);
if (!result.success && result.error === 'too_many_attachments') {
ModalActionCreators.push(modal(() => <TooManyAttachmentsModal />));
}
};
useTextareaExpressionHandlers({
setValue,
textareaRef,
insertSegment,
previousValueRef,
sendOptimisticMessage,
});
const {expressionPickerOpen, setExpressionPickerOpen, handleExpressionPickerTabToggle, selectedTab} =
useTextareaExpressionPicker({
channelId: channel.id,
onEmojiSelect: handleEmojiSelect,
expressionPickerTriggerRef,
invisibleExpressionPickerTriggerRef,
textareaRef,
});
useTextareaEditing({
channelId: channel.id,
editingMessageId: editingMessageId ?? null,
editingMessage: editingMessage ?? null,
isMobileEditMode: mobileLayout.enabled,
replyingMessage,
value,
setValue,
textareaRef,
previousValueRef,
});
const hasPendingSticker = ChannelStickerStore.getPendingSticker(channel.id) !== null;
const hasAttachments = uploadAttachments.length > 0;
const showAttachments = hasAttachments;
const showStickers = hasPendingSticker;
const isComposing = !!value.trim() || hasAttachments || hasPendingSticker;
const isOverCharacterLimit = value.length > maxMessageLength;
const shouldShowMobileGiftButton = mobileLayout.enabled && showGiftButton && containerWidth > 540;
const {onSubmit} = useTextareaSubmit({
channelId: channel.id,
guildId: channel.guildId ?? null,
editingMessage: editingMessage ?? null,
isMobileEditMode: mobileLayout.enabled,
uploadAttachmentsLength: uploadAttachments.length,
hasPendingSticker,
value,
setValue,
displayToActual,
clearSegments,
isSlowmodeActive,
handleSendMessage,
onMentionConfirmationNeeded: handleMentionConfirmationNeeded,
i18n: i18n,
});
const handleEscapeKey = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Escape') return;
if (hasAttachments || hasPendingSticker || replyingMessage) {
event.preventDefault();
if (hasAttachments) {
CloudUpload.clearTextarea(channel.id);
}
if (hasPendingSticker) {
ChannelStickerStore.removePendingSticker(channel.id);
}
if (replyingMessage) {
MessageActionCreators.stopReply(channel.id);
}
return;
}
if (isInputAreaFocused && KeyboardModeStore.keyboardModeEnabled) {
event.preventDefault();
KeyboardModeStore.exitKeyboardMode();
return;
}
if (AccessibilityStore.escapeExitsKeyboardMode) {
KeyboardModeStore.exitKeyboardMode();
}
},
[
channel.id,
hasAttachments,
hasPendingSticker,
replyingMessage,
isInputAreaFocused,
KeyboardModeStore.keyboardModeEnabled,
AccessibilityStore.escapeExitsKeyboardMode,
],
);
const handleFormattingShortcut = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
for (const {combo: shortcutCombo, wrapper} of MARKDOWN_FORMATTING_SHORTCUTS) {
if (!doesEventMatchShortcut(event, shortcutCombo)) {
continue;
}
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const selectionStart = textarea.selectionStart ?? 0;
const selectionEnd = textarea.selectionEnd ?? 0;
if (selectionStart === selectionEnd) {
return;
}
const selectedText = value.slice(selectionStart, selectionEnd);
const wrapperLength = wrapper.length;
const alreadyWrappedInside =
selectedText.length >= wrapperLength * 2 &&
selectedText.startsWith(wrapper) &&
selectedText.endsWith(wrapper);
const hasPrefixWrapper =
wrapperLength > 0 &&
selectionStart >= wrapperLength &&
value.slice(selectionStart - wrapperLength, selectionStart) === wrapper;
const hasSuffixWrapper =
wrapperLength > 0 &&
selectionEnd + wrapperLength <= value.length &&
value.slice(selectionEnd, selectionEnd + wrapperLength) === wrapper;
let newValue: string;
let newSelectionStart: number;
let newSelectionEnd: number;
if (alreadyWrappedInside) {
const unwrappedText = selectedText.slice(wrapperLength, selectedText.length - wrapperLength);
newValue = value.slice(0, selectionStart) + unwrappedText + value.slice(selectionEnd);
newSelectionStart = selectionStart;
newSelectionEnd = selectionStart + unwrappedText.length;
} else if (hasPrefixWrapper && hasSuffixWrapper) {
newValue =
value.slice(0, selectionStart - wrapperLength) + selectedText + value.slice(selectionEnd + wrapperLength);
newSelectionStart = selectionStart - wrapperLength;
newSelectionEnd = selectionEnd - wrapperLength;
} else {
const wrappedText = `${wrapper}${selectedText}${wrapper}`;
newValue = value.slice(0, selectionStart) + wrappedText + value.slice(selectionEnd);
newSelectionStart = selectionStart + wrapperLength;
newSelectionEnd = selectionEnd + wrapperLength;
}
handleTextChange(newValue, previousValueRef.current);
setValue(newValue);
const updateSelection = () => {
textarea.setSelectionRange(newSelectionStart, newSelectionEnd);
};
window.requestAnimationFrame(updateSelection);
event.preventDefault();
event.stopPropagation();
return;
}
},
[handleTextChange, previousValueRef, setValue, textareaRef, value],
);
const handleTextareaKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
handleFormattingShortcut(event);
handleEscapeKey(event);
},
[handleFormattingShortcut, handleEscapeKey],
);
const handleSubmit = React.useCallback(() => {
if (isOverCharacterLimit || isEditingScheduledMessage) {
return;
}
onSubmit();
}, [isOverCharacterLimit, onSubmit, isEditingScheduledMessage]);
useTextareaDraftAndTyping({
channelId: channel.id,
value,
setValue,
draft,
previousValueRef,
isAutocompleteAttached,
enabled: !disabled,
});
const {handleArrowUp} = useTextareaKeyboard({
channelId: channel.id,
isFocused,
textareaRef,
value,
setValue,
handleTextChange,
previousValueRef,
clearSegments,
replyingMessage,
editingMessage: editingMessage || null,
getLastEditableMessage: () => MessageStore.getLastEditableMessage(channel.id) || null,
enabled: !disabled,
});
const placeholderText = disabled
? t`You do not have permission to send messages in this channel.`
: channel.guildId != null
? PlaceholderUtils.getChannelPlaceholder(channel.name || t`channel`, t`Message #`, Number.MAX_SAFE_INTEGER)
: PlaceholderUtils.getDMPlaceholder(
ChannelUtils.getDMDisplayName(channel),
channel.isDM() ? t`Message @` : t`Message `,
Number.MAX_SAFE_INTEGER,
);
React.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 (disabled) return;
const textarea = textareaRef.current;
if (textarea) {
if (enterKeyboardMode) {
KeyboardModeStore.enterKeyboardMode(true);
} else {
KeyboardModeStore.exitKeyboardMode();
}
safeFocus(textarea, true);
}
});
return unsubscribe;
}, [channel.id]);
React.useEffect(() => {
if (!canAttachFiles) return;
const unsubscribe = ComponentDispatch.subscribe('TEXTAREA_UPLOAD_FILE', (payload?: unknown) => {
const {channelId} = (payload ?? {}) as {channelId?: string};
if (channelId && channelId !== channel.id) return;
handleFileButtonClick();
});
return unsubscribe;
}, [channel.id, canAttachFiles]);
React.useLayoutEffect(() => {
if (!containerRef.current) return;
const checkButtonVisibility = () => {
if (!containerRef.current) return;
const containerWidthLocal = containerRef.current.offsetWidth;
const shouldShowAll = containerWidthLocal > 500;
setShowAllButtons(shouldShowAll);
setContainerWidth(containerWidthLocal);
};
const resizeObserver = new ResizeObserver(checkButtonVisibility);
resizeObserver.observe(containerRef.current);
checkButtonVisibility();
return () => {
resizeObserver.disconnect();
};
}, [mobileLayout.enabled]);
const handleCancelEdit = React.useCallback(() => {
setValue('');
clearSegments();
}, [clearSegments]);
const handleMessageInputButtonContextMenu = React.useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, () => (
<MessageInputButtonsContextMenu canSchedule={canScheduleMessage} onSchedule={handleOpenScheduleModal} />
));
},
[canScheduleMessage, handleOpenScheduleModal],
);
const hasStackedSections = Boolean(
referencedMessage ||
(editingMessage && mobileLayout.enabled) ||
uploadAttachments.length > 0 ||
hasPendingSticker,
);
const topBarContent =
editingMessage && mobileLayout.enabled ? (
<EditBar channel={channel} onCancel={handleCancelEdit} />
) : (
referencedMessage && (
<ReplyBar
replyingMessageObject={referencedMessage}
shouldReplyMention={replyingMessage?.mentioning ?? false}
setShouldReplyMention={(mentioning) => MessageActionCreators.setReplyMentioning(channel.id, mentioning)}
channel={channel}
/>
)
);
const renderSection = (content: React.ReactNode) => <div className={wrapperStyles.stackSection}>{content}</div>;
return (
<>
{topBarContent && renderSection(<div className={wrapperStyles.topBarContainer}>{topBarContent}</div>)}
{hasMessageSchedulingAccess &&
editingScheduledMessage &&
renderSection(
<ScheduledMessageEditBar
scheduledLocalAt={editingScheduledMessage.scheduledLocalAt}
timezone={editingScheduledMessage.timezone}
onCancel={handleCancelScheduledEdit}
/>,
)}
<FocusRing
focusTarget={textareaRef}
ringTarget={containerRef}
offset={0}
enabled={!disabled && AccessibilityStore.showTextareaFocusRing}
ringClassName={styles.textareaFocusRing}
>
<div
ref={containerRef}
className={clsx(
wrapperStyles.box,
wrapperStyles.wrapperSides,
styles.textareaOuter,
hasStackedSections ? wrapperStyles.roundedBottom : wrapperStyles.roundedAll,
wrapperStyles.bottomSpacing,
disabled && wrapperStyles.disabled,
)}
style={{minHeight: 'var(--input-container-min-height)'}}
>
{showAttachments && renderSection(<ChannelAttachmentArea channelId={channel.id} />)}
{showStickers &&
renderSection(<ChannelStickersArea channelId={channel.id} hasAttachments={hasAttachments} />)}
{renderSection(
<div className={clsx(styles.mainWrapperDense, disabled && wrapperStyles.disabled)}>
{!disabled && showUploadButton && canAttachFiles && (
<div className={clsx(styles.uploadButtonColumn, styles.sideButtonPadding)}>
<TextareaButton
icon={PlusCircleIcon}
label={t`Upload file`}
onClick={handleFileButtonClick}
onContextMenu={handleMessageInputButtonContextMenu}
keybindAction="upload_file"
/>
</div>
)}
<div className={styles.contentAreaDense}>
<Scroller ref={scrollerRef} fade={true} className={styles.scroller} key="channel-textarea-scroller">
<div style={{display: 'flex', flexDirection: 'column'}}>
<TextareaInputField
disabled={disabled}
isMobile={mobileLayout.enabled}
value={value}
placeholder={placeholderText}
textareaRef={textareaRef}
scrollerRef={scrollerRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
onFocus={() => {
setIsFocused(true);
setIsInputAreaFocused(true);
}}
onBlur={() => {
setIsFocused(false);
setIsInputAreaFocused(false);
}}
onChange={(newValue) => {
handleTextChange(newValue, previousValueRef.current);
setValue(newValue);
}}
onHeightChange={setTextareaHeight}
onCursorMove={onCursorMove}
onArrowUp={handleArrowUp}
onEnter={handleSubmit}
onAutocompleteSelect={handleSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={handleTextareaKeyDown}
/>
</div>
</Scroller>
</div>
<TextareaButtons
disabled={disabled}
showAllButtons={showAllButtons}
showUploadButton={showUploadButton}
showGiftButton={showGiftButton}
showGifButton={showGifButton}
showMemesButton={showMemesButton}
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
shouldShowMobileGiftButton={shouldShowMobileGiftButton}
isComposing={isComposing}
isSlowmodeActive={isSlowmodeActive}
isOverLimit={isOverCharacterLimit}
hasContent={!!value.trim()}
hasAttachments={uploadAttachments.length > 0}
expressionPickerTriggerRef={expressionPickerTriggerRef}
invisibleExpressionPickerTriggerRef={invisibleExpressionPickerTriggerRef}
onExpressionPickerToggle={handleExpressionPickerTabToggle}
onSubmit={handleSubmit}
disableSendButton={isEditingScheduledMessage}
onContextMenu={handleMessageInputButtonContextMenu}
/>
{isScheduleModalOpen && hasMessageSchedulingAccess && (
<ScheduleMessageModal
onClose={() => setIsScheduleModalOpen(false)}
onSubmit={handleScheduleSubmit}
initialScheduledLocalAt={editingScheduledMessage?.scheduledLocalAt}
initialTimezone={editingScheduledMessage?.timezone}
title={isEditingScheduledMessage ? t`Reschedule Message` : undefined}
submitLabel={isEditingScheduledMessage ? t`Update` : undefined}
helpText={
isEditingScheduledMessage
? t`This will modify the existing scheduled message rather than sending immediately.`
: undefined
}
/>
)}
</div>,
)}
<MessageCharacterCounter
currentLength={value.length}
maxLength={maxMessageLength}
isPremium={currentUser?.isPremium() ?? false}
/>
{isAutocompleteAttached && (
<Autocomplete
type={autocompleteType}
onSelect={handleSelect}
selectedIndex={selectedIndex}
options={autocompleteOptions}
setSelectedIndex={setSelectedIndex}
referenceElement={containerRef.current}
query={autocompleteQuery}
attached={true}
/>
)}
</div>
</FocusRing>
{mobileLayout.enabled && (
<ExpressionPickerSheet
isOpen={expressionPickerOpen}
onClose={() => setExpressionPickerOpen(false)}
channelId={channel.id}
onEmojiSelect={handleEmojiSelect}
/>
)}
</>
);
},
);
export const ChannelTextarea = observer(({channel}: {channel: ChannelRecord}) => {
const draft = DraftStore.getDraft(channel.id);
const forceNoSendMessages = DeveloperOptionsStore.forceNoSendMessages;
const forceNoAttachFiles = DeveloperOptionsStore.forceNoAttachFiles;
const disabled = channel.isPrivate()
? forceNoSendMessages
: forceNoSendMessages || !PermissionStore.can(Permissions.SEND_MESSAGES, channel);
const canAttachFiles = channel.isPrivate()
? !forceNoAttachFiles
: !forceNoAttachFiles && PermissionStore.can(Permissions.ATTACH_FILES, channel);
return (
<ChannelTextareaContent
key={channel.id}
channel={channel}
disabled={disabled}
canAttachFiles={canAttachFiles}
draft={draft}
/>
);
});