1200 lines
40 KiB
TypeScript

import React, {
FC, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { GlobalState, MessageListType } from '../../../global/types';
import {
ApiAttachment,
ApiBotInlineResult,
ApiBotInlineMediaResult,
ApiSticker,
ApiVideo,
ApiNewPoll,
ApiMessage,
ApiFormattedText,
ApiChat,
ApiChatMember,
ApiUser,
MAIN_THREAD_ID,
ApiBotCommand,
} from '../../../api/types';
import { InlineBotSettings } from '../../../types';
import {
BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_ID, REPLIES_USER_ID, SCHEDULED_WHEN_ONLINE, SEND_MESSAGE_ACTION_INTERVAL,
} from '../../../config';
import { IS_VOICE_RECORDING_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT, IS_IOS } from '../../../util/environment';
import {
selectChat,
selectIsRightColumnShown,
selectIsInSelectMode,
selectNewestMessageWithBotKeyboardButtons,
selectDraft,
selectScheduledIds,
selectEditingMessage,
selectIsChatWithSelf,
selectChatBot,
selectChatUser,
selectChatMessage,
selectUserStatus,
} from '../../../modules/selectors';
import {
getAllowedAttachmentOptions,
getChatSlowModeOptions,
isUserId,
isChatAdmin,
isChatSuperGroup,
} from '../../../modules/helpers';
import { formatMediaDuration, formatVoiceRecordDuration, getDayStartAt } from '../../../util/dateFormat';
import focusEditableElement from '../../../util/focusEditableElement';
import parseMessageInput from '../../../util/parseMessageInput';
import buildAttachment from './helpers/buildAttachment';
import renderText from '../../common/helpers/renderText';
import insertHtmlInSelection from '../../../util/insertHtmlInSelection';
import deleteLastCharacterOutsideSelection from '../../../util/deleteLastCharacterOutsideSelection';
import buildClassName from '../../../util/buildClassName';
import windowSize from '../../../util/windowSize';
import { isSelectionInsideInput } from './helpers/selection';
import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix';
import { getServerTime } from '../../../util/serverTime';
import useFlag from '../../../hooks/useFlag';
import usePrevious from '../../../hooks/usePrevious';
import useStickerTooltip from './hooks/useStickerTooltip';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useLang from '../../../hooks/useLang';
import useSendMessageAction from '../../../hooks/useSendMessageAction';
import useInterval from '../../../hooks/useInterval';
import useOnChange from '../../../hooks/useOnChange';
import useVoiceRecording from './hooks/useVoiceRecording';
import useClipboardPaste from './hooks/useClipboardPaste';
import useDraft from './hooks/useDraft';
import useEditing from './hooks/useEditing';
import useEmojiTooltip from './hooks/useEmojiTooltip';
import useMentionTooltip from './hooks/useMentionTooltip';
import useInlineBotTooltip from './hooks/useInlineBotTooltip';
import useBotCommandTooltip from './hooks/useBotCommandTooltip';
import DeleteMessageModal from '../../common/DeleteMessageModal.async';
import Button from '../../ui/Button';
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import Spinner from '../../ui/Spinner';
import AttachMenu from './AttachMenu.async';
import SymbolMenu from './SymbolMenu.async';
import InlineBotTooltip from './InlineBotTooltip.async';
import MentionTooltip from './MentionTooltip.async';
import CustomSendMenu from './CustomSendMenu.async';
import StickerTooltip from './StickerTooltip.async';
import EmojiTooltip from './EmojiTooltip.async';
import BotCommandTooltip from './BotCommandTooltip.async';
import BotKeyboardMenu from './BotKeyboardMenu';
import MessageInput from './MessageInput';
import ComposerEmbeddedMessage from './ComposerEmbeddedMessage';
import AttachmentModal from './AttachmentModal.async';
import BotCommandMenu from './BotCommandMenu.async';
import PollModal from './PollModal.async';
import DropArea, { DropAreaState } from './DropArea.async';
import WebPagePreview from './WebPagePreview';
import Portal from '../../ui/Portal';
import CalendarModal from '../../common/CalendarModal.async';
import SendAsMenu from './SendAsMenu.async';
import Avatar from '../../common/Avatar';
import './Composer.scss';
type OwnProps = {
chatId: string;
threadId: number;
messageListType: MessageListType;
dropAreaState: string;
isReady: boolean;
onDropHide: NoneToVoidFunction;
};
type StateProps =
{
editingMessage?: ApiMessage;
chat?: ApiChat;
draft?: ApiFormattedText;
isChatWithBot?: boolean;
isChatWithSelf?: boolean;
isRightColumnShown?: boolean;
isSelectModeActive?: boolean;
isForwarding?: boolean;
isPollModalOpen?: boolean;
botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string;
withScheduledButton?: boolean;
shouldSchedule?: boolean;
canScheduleUntilOnline?: boolean;
stickersForEmoji?: ApiSticker[];
groupChatMembers?: ApiChatMember[];
currentUserId?: string;
usersById?: Record<string, ApiUser>;
recentEmojis: string[];
lastSyncTime?: number;
contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled'];
shouldSuggestStickers?: boolean;
baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>;
serverTimeOffset: number;
topInlineBotIds?: string[];
isInlineBotLoading: boolean;
inlineBots?: Record<string, false | InlineBotSettings>;
botCommands?: ApiBotCommand[] | false;
chatBotCommands?: ApiBotCommand[];
sendAsUser?: ApiUser;
sendAsChat?: ApiChat;
sendAsId?: string;
}
& Pick<GlobalState, 'connectionState'>;
enum MainButtonState {
Send = 'send',
Record = 'record',
Edit = 'edit',
Schedule = 'schedule',
}
const VOICE_RECORDING_FILENAME = 'wonderful-voice-message.ogg';
// When voice recording is active, composer placeholder will hide to prevent overlapping
const SCREEN_WIDTH_TO_HIDE_PLACEHOLDER = 600; // px
const MOBILE_KEYBOARD_HIDE_DELAY_MS = 100;
const SELECT_MODE_TRANSITION_MS = 200;
const MESSAGE_MAX_LENGTH = 4096;
const CAPTION_MAX_LENGTH = 1024;
const SENDING_ANIMATION_DURATION = 350;
// eslint-disable-next-line max-len
const APPENDIX = '<svg width="9" height="20" xmlns="http://www.w3.org/2000/svg"><defs><filter x="-50%" y="-14.7%" width="200%" height="141.2%" filterUnits="objectBoundingBox" id="a"><feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0.0621962482 0 0 0 0 0.138574144 0 0 0 0 0.185037364 0 0 0 0.15 0" in="shadowBlurOuter1"/></filter></defs><g fill="none" fill-rule="evenodd"><path d="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#000" filter="url(#a)"/><path d="M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z" fill="#FFF" class="corner"/></g></svg>';
const Composer: FC<OwnProps & StateProps> = ({
dropAreaState,
shouldSchedule,
canScheduleUntilOnline,
isReady,
onDropHide,
editingMessage,
chatId,
threadId,
messageListType,
draft,
chat,
connectionState,
isChatWithBot,
isChatWithSelf,
isRightColumnShown,
isSelectModeActive,
isForwarding,
isPollModalOpen,
botKeyboardMessageId,
botKeyboardPlaceholder,
withScheduledButton,
stickersForEmoji,
groupChatMembers,
topInlineBotIds,
currentUserId,
usersById,
lastSyncTime,
contentToBeScheduled,
shouldSuggestStickers,
baseEmojiKeywords,
emojiKeywords,
serverTimeOffset,
recentEmojis,
inlineBots,
isInlineBotLoading,
botCommands,
chatBotCommands,
sendAsUser,
sendAsChat,
sendAsId,
}) => {
const {
sendMessage,
clearDraft,
showDialog,
setStickerSearchQuery,
setGifSearchQuery,
forwardMessages,
openPollModal,
closePollModal,
loadScheduledHistory,
openChat,
addRecentEmoji,
sendInlineBotResult,
loadSendAs,
} = getDispatch();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const appendixRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState<string>('');
const lastMessageSendTimeSeconds = useRef<number>();
const prevDropAreaState = usePrevious(dropAreaState);
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
const [
scheduledMessageArgs, setScheduledMessageArgs,
] = useState<GlobalState['messages']['contentToBeScheduled'] | undefined>();
const { width: windowWidth } = windowSize.get();
const sendAsIds = chat?.sendAsIds;
const sendMessageAction = useSendMessageAction(chatId, threadId);
// Cache for frequently updated state
const htmlRef = useRef<string>(html);
useEffect(() => {
htmlRef.current = html;
}, [html]);
useEffect(() => {
lastMessageSendTimeSeconds.current = undefined;
}, [chatId]);
useEffect(() => {
if (chatId && lastSyncTime && threadId === MAIN_THREAD_ID && isReady) {
loadScheduledHistory({ chatId });
}
}, [isReady, chatId, loadScheduledHistory, lastSyncTime, threadId]);
useEffect(() => {
if (chatId && chat && lastSyncTime && !sendAsIds && isReady && isChatSuperGroup(chat)) {
loadSendAs({ chatId });
}
}, [chat, chatId, isReady, lastSyncTime, loadSendAs, sendAsIds]);
const shouldAnimateSendAsButtonRef = useRef(false);
useOnChange(([prevChatId, prevSendAsIds]) => {
// We only animate send-as button if `sendAsIds` was missing when opening the chat
shouldAnimateSendAsButtonRef.current = Boolean(chatId === prevChatId && sendAsIds && !prevSendAsIds);
}, [chatId, sendAsIds]);
useLayoutEffect(() => {
if (!appendixRef.current) return;
appendixRef.current.innerHTML = APPENDIX;
}, []);
useEffect(() => {
if (contentToBeScheduled) {
setScheduledMessageArgs(contentToBeScheduled);
openCalendar();
}
}, [contentToBeScheduled, openCalendar]);
const [attachments, setAttachments] = useState<ApiAttachment[]>([]);
const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag();
const [isBotCommandMenuOpen, openBotCommandMenu, closeBotCommandMenu] = useFlag();
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [isSendAsMenuOpen, openSendAsMenu, closeSendAsMenu] = useFlag();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isSymbolMenuLoaded, onSymbolMenuLoadingComplete] = useFlag();
const [isHoverDisabled, disableHover, enableHover] = useFlag();
const {
startRecordingVoice,
stopRecordingVoice,
pauseRecordingVoice,
activeVoiceRecording,
currentRecordTime,
recordButtonRef: mainButtonRef,
startRecordTimeRef,
} = useVoiceRecording();
useInterval(() => {
sendMessageAction({ type: 'recordAudio' });
}, activeVoiceRecording && SEND_MESSAGE_ACTION_INTERVAL);
useEffect(() => {
if (!activeVoiceRecording) {
sendMessageAction({ type: 'cancel' });
}
}, [activeVoiceRecording, sendMessageAction]);
const mainButtonState = editingMessage ? MainButtonState.Edit
: (!IS_VOICE_RECORDING_SUPPORTED || activeVoiceRecording || (html && !attachments.length) || isForwarding)
? (shouldSchedule ? MainButtonState.Schedule : MainButtonState.Send)
: MainButtonState.Record;
const canShowCustomSendMenu = !shouldSchedule;
const {
isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers,
} = useMentionTooltip(
!attachments.length,
html,
setHtml,
undefined,
groupChatMembers,
topInlineBotIds,
currentUserId,
);
const {
isOpen: isInlineBotTooltipOpen,
id: inlineBotId,
isGallery: isInlineBotTooltipGallery,
switchPm: inlineBotSwitchPm,
results: inlineBotResults,
closeTooltip: closeInlineBotTooltip,
help: inlineBotHelp,
loadMore: loadMoreForInlineBot,
} = useInlineBotTooltip(
Boolean(!attachments.length && lastSyncTime),
chatId,
html,
inlineBots,
);
const {
isOpen: isBotCommandTooltipOpen,
close: closeBotCommandTooltip,
filteredBotCommands: botTooltipCommands,
} = useBotCommandTooltip(
Boolean((botCommands && botCommands.length) || (chatBotCommands && chatBotCommands.length)),
html,
botCommands,
chatBotCommands,
);
const {
isContextMenuOpen: isCustomSendMenuOpen,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu));
const allowedAttachmentOptions = useMemo(() => {
return getAllowedAttachmentOptions(chat, isChatWithBot);
}, [chat, isChatWithBot]);
const isAdmin = chat && isChatAdmin(chat);
const slowMode = getChatSlowModeOptions(chat);
const { isStickerTooltipOpen, closeStickerTooltip } = useStickerTooltip(
Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length),
html,
stickersForEmoji,
!isReady,
);
const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji,
} = useEmojiTooltip(
Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length),
html,
recentEmojis,
undefined,
setHtml,
baseEmojiKeywords,
emojiKeywords,
!isReady,
);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const selection = window.getSelection()!;
const messageInput = document.getElementById(inputId)!;
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('')
.replace(/\u200b+/g, '\u200b');
if (selection.rangeCount) {
const selectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(selectionRange, inputId)) {
insertHtmlInSelection(newHtml);
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
}
setHtml(`${htmlRef.current!}${newHtml}`);
// If selection is outside of input, set cursor at the end of input
requestAnimationFrame(() => {
focusEditableElement(messageInput);
});
}, []);
const removeSymbol = useCallback(() => {
const selection = window.getSelection()!;
if (selection.rangeCount) {
const selectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(selectionRange, EDITABLE_INPUT_ID)) {
document.execCommand('delete', false);
return;
}
}
setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!));
}, []);
const resetComposer = useCallback((shouldPreserveInput = false) => {
if (!shouldPreserveInput) {
setHtml('');
}
setAttachments([]);
closeStickerTooltip();
closeCalendar();
setScheduledMessageArgs(undefined);
closeMentionTooltip();
closeEmojiTooltip();
if (IS_SINGLE_COLUMN_LAYOUT) {
// @optimization
setTimeout(() => closeSymbolMenu(), SENDING_ANIMATION_DURATION);
} else {
closeSymbolMenu();
}
}, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]);
// Handle chat change (ref is used to avoid redundant effect calls)
const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>();
stopRecordingVoiceRef.current = stopRecordingVoice;
useEffect(() => {
return () => {
stopRecordingVoiceRef.current!();
resetComposer();
};
}, [chatId, resetComposer, stopRecordingVoiceRef]);
const handleEditComplete = useEditing(htmlRef, setHtml, editingMessage, resetComposer, openDeleteModal);
useDraft(draft, chatId, threadId, html, htmlRef, setHtml, editingMessage);
useClipboardPaste(insertTextAndUpdateCursor, setAttachments, editingMessage);
const handleFileSelect = useCallback(async (files: File[], isQuick: boolean) => {
setAttachments(await Promise.all(files.map((file) => buildAttachment(file.name, file, isQuick))));
}, []);
const handleAppendFiles = useCallback(async (files: File[], isQuick: boolean) => {
setAttachments([
...attachments,
...await Promise.all(files.map((file) => buildAttachment(file.name, file, isQuick))),
]);
}, [attachments]);
const handleClearAttachment = useCallback(() => {
setAttachments([]);
}, []);
const handleSend = useCallback(async (isSilent = false, scheduledAt?: number) => {
if (connectionState !== 'connectionStateReady') {
return;
}
let currentAttachments = attachments;
if (activeVoiceRecording) {
const record = await stopRecordingVoice();
if (record) {
const { blob, duration, waveform } = record;
currentAttachments = [await buildAttachment(
VOICE_RECORDING_FILENAME,
blob,
false,
{ voice: { duration, waveform } },
)];
}
}
const { text, entities } = parseMessageInput(htmlRef.current!);
if (!currentAttachments.length && !text && !isForwarding) {
return;
}
const maxLength = currentAttachments.length ? CAPTION_MAX_LENGTH : MESSAGE_MAX_LENGTH;
if (text?.length > maxLength) {
const extraLength = text.length - maxLength;
showDialog({
data: {
message: 'MESSAGE_TOO_LONG_PLEASE_REMOVE_CHARACTERS',
textParams: {
'{EXTRA_CHARS_COUNT}': extraLength,
'{PLURAL_S}': extraLength > 1 ? 's' : '',
},
hasErrorKey: true,
},
});
return;
}
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
if (currentAttachments.length || text) {
if (slowMode && !isAdmin) {
const nowSeconds = getServerTime(serverTimeOffset);
const secondsSinceLastMessage = lastMessageSendTimeSeconds.current
&& Math.floor(nowSeconds - lastMessageSendTimeSeconds.current);
const nextSendDateNotReached = slowMode.nextSendDate && slowMode.nextSendDate > nowSeconds;
if (
(secondsSinceLastMessage && secondsSinceLastMessage < slowMode.seconds)
|| nextSendDateNotReached
) {
const secondsRemaining = nextSendDateNotReached
? slowMode.nextSendDate! - nowSeconds
: slowMode.seconds - secondsSinceLastMessage!;
showDialog({
data: {
message: lang('SlowModeHint', formatMediaDuration(secondsRemaining)),
isSlowMode: true,
hasErrorKey: false,
},
});
messageInput.blur();
return;
}
}
sendMessage({
text,
entities,
attachments: currentAttachments,
scheduledAt,
isSilent,
});
}
if (isForwarding) {
forwardMessages({
scheduledAt,
isSilent,
});
}
lastMessageSendTimeSeconds.current = getServerTime(serverTimeOffset);
clearDraft({ chatId, localOnly: true });
if (IS_IOS && messageInput === document.activeElement) {
applyIosAutoCapitalizationFix(messageInput);
}
// Wait until message animation starts
requestAnimationFrame(() => {
resetComposer();
});
}, [
connectionState, attachments, activeVoiceRecording, isForwarding, clearDraft, chatId, serverTimeOffset,
resetComposer, stopRecordingVoice, showDialog, slowMode, isAdmin, sendMessage, forwardMessages, lang,
]);
const handleActivateBotCommandMenu = useCallback(() => {
closeSymbolMenu();
openBotCommandMenu();
}, [closeSymbolMenu, openBotCommandMenu]);
const handleActivateSymbolMenu = useCallback(() => {
closeBotCommandMenu();
closeSendAsMenu();
openSymbolMenu();
}, [closeBotCommandMenu, closeSendAsMenu, openSymbolMenu]);
const handleStickerSelect = useCallback((sticker: ApiSticker, shouldPreserveInput = false) => {
sticker = {
...sticker,
isPreloadedGlobally: true,
};
if (shouldSchedule) {
setScheduledMessageArgs({ sticker });
openCalendar();
} else {
sendMessage({ sticker });
requestAnimationFrame(() => {
resetComposer(shouldPreserveInput);
});
}
}, [shouldSchedule, openCalendar, sendMessage, resetComposer]);
const handleGifSelect = useCallback((gif: ApiVideo) => {
if (shouldSchedule) {
setScheduledMessageArgs({ gif });
openCalendar();
} else {
sendMessage({ gif });
requestAnimationFrame(() => {
resetComposer(true);
});
}
}, [shouldSchedule, openCalendar, sendMessage, resetComposer]);
const handleInlineBotSelect = useCallback((inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult) => {
if (connectionState !== 'connectionStateReady') {
return;
}
sendInlineBotResult({
id: inlineResult.id,
queryId: inlineResult.queryId,
});
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
if (IS_IOS && messageInput === document.activeElement) {
applyIosAutoCapitalizationFix(messageInput);
}
clearDraft({ chatId, localOnly: true });
requestAnimationFrame(() => {
resetComposer();
});
}, [chatId, clearDraft, connectionState, resetComposer, sendInlineBotResult]);
const handleBotCommandSelect = useCallback(() => {
clearDraft({ chatId, localOnly: true });
requestAnimationFrame(() => {
resetComposer();
});
}, [chatId, clearDraft, resetComposer]);
const handlePollSend = useCallback((poll: ApiNewPoll) => {
if (shouldSchedule) {
setScheduledMessageArgs({ poll });
closePollModal();
openCalendar();
} else {
sendMessage({ poll });
closePollModal();
}
}, [closePollModal, openCalendar, sendMessage, shouldSchedule]);
const handleSilentSend = useCallback(() => {
if (shouldSchedule) {
setScheduledMessageArgs({ isSilent: true });
openCalendar();
} else {
void handleSend(true);
}
}, [handleSend, openCalendar, shouldSchedule]);
const handleMessageSchedule = useCallback((date: Date, isWhenOnline = false) => {
const { isSilent, ...restArgs } = scheduledMessageArgs || {};
// Scheduled time can not be less than 10 seconds in future
const scheduledAt = Math.round(Math.max(date.getTime(), Date.now() + 60 * 1000) / 1000)
+ (isWhenOnline ? 0 : serverTimeOffset);
if (!scheduledMessageArgs || Object.keys(restArgs).length === 0) {
void handleSend(!!isSilent, scheduledAt);
} else {
sendMessage({
...scheduledMessageArgs,
scheduledAt,
});
requestAnimationFrame(() => {
resetComposer();
});
}
closeCalendar();
}, [closeCalendar, handleSend, resetComposer, scheduledMessageArgs, sendMessage, serverTimeOffset]);
const handleMessageScheduleUntilOnline = useCallback(() => {
handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000), true);
}, [handleMessageSchedule]);
const handleCloseCalendar = useCallback(() => {
closeCalendar();
setScheduledMessageArgs(undefined);
}, [closeCalendar]);
const handleSearchOpen = useCallback((type: 'stickers' | 'gifs') => {
if (type === 'stickers') {
setStickerSearchQuery({ query: '' });
setGifSearchQuery({ query: undefined });
} else {
setGifSearchQuery({ query: '' });
setStickerSearchQuery({ query: undefined });
}
}, [setStickerSearchQuery, setGifSearchQuery]);
const handleSymbolMenuOpen = useCallback(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
if (!IS_SINGLE_COLUMN_LAYOUT || messageInput !== document.activeElement) {
openSymbolMenu();
return;
}
messageInput.blur();
setTimeout(() => {
closeBotCommandMenu();
openSymbolMenu();
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
}, [openSymbolMenu, closeBotCommandMenu]);
const handleSendAsMenuOpen = useCallback(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
if (!IS_SINGLE_COLUMN_LAYOUT || messageInput !== document.activeElement) {
closeBotCommandMenu();
closeSymbolMenu();
openSendAsMenu();
return;
}
messageInput.blur();
setTimeout(() => {
closeBotCommandMenu();
closeSymbolMenu();
openSendAsMenu();
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
}, [closeBotCommandMenu, closeSymbolMenu, openSendAsMenu]);
const handleAllScheduledClick = useCallback(() => {
openChat({ id: chatId, threadId, type: 'scheduled' });
}, [openChat, chatId, threadId]);
useEffect(() => {
if (isRightColumnShown && IS_SINGLE_COLUMN_LAYOUT) {
closeSymbolMenu();
}
}, [isRightColumnShown, closeSymbolMenu]);
useEffect(() => {
if (!isReady) return;
if (isSelectModeActive) {
disableHover();
} else {
setTimeout(() => {
enableHover();
}, SELECT_MODE_TRANSITION_MS);
}
}, [isSelectModeActive, enableHover, disableHover, isReady]);
const mainButtonHandler = useCallback(() => {
switch (mainButtonState) {
case MainButtonState.Send:
handleSend();
break;
case MainButtonState.Record:
void startRecordingVoice();
break;
case MainButtonState.Edit:
handleEditComplete();
break;
case MainButtonState.Schedule:
if (activeVoiceRecording) {
pauseRecordingVoice();
}
openCalendar();
break;
default:
break;
}
}, [
mainButtonState, handleSend, startRecordingVoice, handleEditComplete,
activeVoiceRecording, openCalendar, pauseRecordingVoice,
]);
const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record
&& !allowedAttachmentOptions.canAttachMedia;
const prevEditedMessage = usePrevious(editingMessage, true);
const renderedEditedMessage = editingMessage || prevEditedMessage;
const scheduledDefaultDate = new Date();
scheduledDefaultDate.setSeconds(0);
scheduledDefaultDate.setMilliseconds(0);
const scheduledMaxDate = new Date();
scheduledMaxDate.setFullYear(scheduledMaxDate.getFullYear() + 1);
let sendButtonAriaLabel = 'SendMessage';
switch (mainButtonState) {
case MainButtonState.Edit:
sendButtonAriaLabel = 'Save edited message';
break;
case MainButtonState.Record:
sendButtonAriaLabel = areVoiceMessagesNotAllowed
? 'Conversation.DefaultRestrictedMedia'
: 'AccDescrVoiceMessage';
}
const className = buildClassName(
'Composer',
!isSelectModeActive && 'shown',
isHoverDisabled && 'hover-disabled',
);
const symbolMenuButtonClassName = buildClassName(
'mobile-symbol-menu-button',
!isReady && 'not-ready',
isSymbolMenuLoaded
? (isSymbolMenuOpen && 'menu-opened')
: (isSymbolMenuOpen && 'is-loading'),
);
const onSend = mainButtonState === MainButtonState.Edit
? handleEditComplete
: mainButtonState === MainButtonState.Schedule ? openCalendar
: handleSend;
return (
<div className={className}>
{allowedAttachmentOptions.canAttachMedia && isReady && (
<Portal containerId="#middle-column-portals">
<DropArea
isOpen={dropAreaState !== DropAreaState.None}
withQuick={[dropAreaState, prevDropAreaState].includes(DropAreaState.QuickFile)}
onHide={onDropHide}
onFileSelect={handleFileSelect}
/>
</Portal>
)}
<AttachmentModal
chatId={chatId}
threadId={threadId}
attachments={attachments}
caption={attachments.length ? html : ''}
groupChatMembers={groupChatMembers}
currentUserId={currentUserId}
usersById={usersById}
recentEmojis={recentEmojis}
isReady={isReady}
onCaptionUpdate={setHtml}
baseEmojiKeywords={baseEmojiKeywords}
emojiKeywords={emojiKeywords}
addRecentEmoji={addRecentEmoji}
onSend={shouldSchedule ? openCalendar : handleSend}
onFileAppend={handleAppendFiles}
onClear={handleClearAttachment}
/>
<PollModal
isOpen={Boolean(isPollModalOpen)}
onClear={closePollModal}
onSend={handlePollSend}
/>
{renderedEditedMessage && (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
message={renderedEditedMessage}
/>
)}
<SendAsMenu
isOpen={isSendAsMenuOpen}
onClose={closeSendAsMenu}
chatId={chatId}
selectedSendAsId={sendAsId}
sendAsIds={sendAsIds}
/>
<MentionTooltip
isOpen={isMentionTooltipOpen}
onClose={closeMentionTooltip}
onInsertUserName={insertMention}
filteredUsers={mentionFilteredUsers}
usersById={usersById}
/>
<InlineBotTooltip
isOpen={isInlineBotTooltipOpen}
botId={inlineBotId}
allowedAttachmentOptions={allowedAttachmentOptions}
isGallery={isInlineBotTooltipGallery}
inlineBotResults={inlineBotResults}
switchPm={inlineBotSwitchPm}
onSelectResult={handleInlineBotSelect}
loadMore={loadMoreForInlineBot}
onClose={closeInlineBotTooltip}
/>
<BotCommandTooltip
isOpen={isBotCommandTooltipOpen}
withUsername={Boolean(chatBotCommands)}
botCommands={botTooltipCommands}
onClick={handleBotCommandSelect}
onClose={closeBotCommandTooltip}
/>
<div id="message-compose">
<div className="svg-appendix" ref={appendixRef} />
<ComposerEmbeddedMessage />
<WebPagePreview
chatId={chatId}
threadId={threadId}
messageText={!attachments.length ? html : ''}
disabled={!allowedAttachmentOptions.canAttachEmbedLinks}
/>
<div className="message-input-wrapper">
{isChatWithBot && botCommands !== false && !activeVoiceRecording && !editingMessage && (
<ResponsiveHoverButton
className={buildClassName('bot-commands', isBotCommandMenuOpen && 'activated')}
round
disabled={botCommands === undefined}
color="translucent"
onActivate={handleActivateBotCommandMenu}
ariaLabel="Open bot command keyboard"
>
<i className="icon-bot-commands-filled" />
</ResponsiveHoverButton>
)}
{!!sendAsIds?.length && (sendAsUser || sendAsChat) && (
<Button
round
color="translucent"
onClick={isSendAsMenuOpen ? closeSendAsMenu : handleSendAsMenuOpen}
ariaLabel={lang('SendMessageAsTitle')}
className={buildClassName('send-as-button', shouldAnimateSendAsButtonRef.current && 'appear-animation')}
>
<Avatar
user={sendAsUser}
chat={sendAsChat}
size="tiny"
/>
</Button>
)}
{IS_SINGLE_COLUMN_LAYOUT ? (
<Button
className={symbolMenuButtonClassName}
round
color="translucent"
onClick={isSymbolMenuOpen ? closeSymbolMenu : handleSymbolMenuOpen}
ariaLabel="Choose emoji, sticker or GIF"
>
<i className="icon-smile" />
<i className="icon-keyboard" />
{isSymbolMenuOpen && !isSymbolMenuLoaded && <Spinner color="gray" />}
</Button>
) : (
<ResponsiveHoverButton
className={isSymbolMenuOpen ? 'activated' : ''}
round
color="translucent"
onActivate={handleActivateSymbolMenu}
ariaLabel="Choose emoji, sticker or GIF"
>
<i className="icon-smile" />
</ResponsiveHoverButton>
)}
<MessageInput
id="message-input-text"
chatId={chatId}
threadId={threadId}
html={!attachments.length ? html : ''}
placeholder={
activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER
? ''
: botKeyboardPlaceholder || lang('Message')
}
forcedPlaceholder={inlineBotHelp}
canAutoFocus={isReady && !attachments.length}
shouldSuppressFocus={IS_SINGLE_COLUMN_LAYOUT && isSymbolMenuOpen}
shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen}
onUpdate={setHtml}
onSend={onSend}
onSuppressedFocus={closeSymbolMenu}
/>
{isInlineBotLoading && Boolean(inlineBotId) && (
<Spinner color="gray" />
)}
{withScheduledButton && (
<Button
round
faded
className="scheduled-button"
color="translucent"
onClick={handleAllScheduledClick}
ariaLabel="Open scheduled messages"
>
<i className="icon-schedule" />
</Button>
)}
{botKeyboardMessageId && !activeVoiceRecording && !editingMessage && (
<ResponsiveHoverButton
className={isBotKeyboardOpen ? 'activated' : ''}
round
color="translucent"
onActivate={openBotKeyboard}
ariaLabel="Open bot command keyboard"
>
<i className="icon-bot-command" />
</ResponsiveHoverButton>
)}
{!activeVoiceRecording && !editingMessage && (
<ResponsiveHoverButton
className={isAttachMenuOpen ? 'activated' : ''}
round
color="translucent"
onActivate={openAttachMenu}
ariaLabel="Add an attachment"
>
<i className="icon-attach" />
</ResponsiveHoverButton>
)}
{activeVoiceRecording && currentRecordTime && (
<span className="recording-state">
{formatVoiceRecordDuration(currentRecordTime - startRecordTimeRef.current!)}
</span>
)}
<StickerTooltip
chatId={chatId}
threadId={threadId}
isOpen={isStickerTooltipOpen}
onStickerSelect={handleStickerSelect}
/>
<EmojiTooltip
isOpen={isEmojiTooltipOpen}
emojis={filteredEmojis}
onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji}
addRecentEmoji={addRecentEmoji}
/>
<AttachMenu
isOpen={isAttachMenuOpen}
allowedAttachmentOptions={allowedAttachmentOptions}
onFileSelect={handleFileSelect}
onPollCreate={openPollModal}
onClose={closeAttachMenu}
/>
{botKeyboardMessageId && (
<BotKeyboardMenu
messageId={botKeyboardMessageId}
isOpen={isBotKeyboardOpen}
onClose={closeBotKeyboard}
/>
)}
{botCommands && (
<BotCommandMenu
isOpen={isBotCommandMenuOpen}
botCommands={botCommands}
onClose={closeBotCommandMenu}
/>
)}
<SymbolMenu
chatId={chatId}
threadId={threadId}
isOpen={isSymbolMenuOpen}
allowedAttachmentOptions={allowedAttachmentOptions}
onLoad={onSymbolMenuLoadingComplete}
onClose={closeSymbolMenu}
onEmojiSelect={insertTextAndUpdateCursor}
onStickerSelect={handleStickerSelect}
onGifSelect={handleGifSelect}
onRemoveSymbol={removeSymbol}
onSearchOpen={handleSearchOpen}
addRecentEmoji={addRecentEmoji}
/>
</div>
</div>
{activeVoiceRecording && (
<Button
round
color="danger"
className="cancel"
onClick={stopRecordingVoice}
ariaLabel="Cancel voice recording"
>
<i className="icon-delete" />
</Button>
)}
<Button
ref={mainButtonRef}
round
color="secondary"
className={buildClassName(mainButtonState, !isReady && 'not-ready', activeVoiceRecording && 'recording')}
disabled={areVoiceMessagesNotAllowed}
ariaLabel={lang(sendButtonAriaLabel)}
onClick={mainButtonHandler}
onContextMenu={
mainButtonState === MainButtonState.Send && canShowCustomSendMenu ? handleContextMenu : undefined
}
>
<i className="icon-send" />
<i className="icon-schedule" />
<i className="icon-microphone-alt" />
<i className="icon-check" />
</Button>
{canShowCustomSendMenu && (
<CustomSendMenu
isOpen={isCustomSendMenuOpen}
onSilentSend={!isChatWithSelf ? handleSilentSend : undefined}
onScheduleSend={!shouldSchedule ? openCalendar : undefined}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
<CalendarModal
isOpen={isCalendarOpen}
withTimePicker
selectedAt={scheduledDefaultDate.getTime()}
maxAt={getDayStartAt(scheduledMaxDate)}
isFutureMode
secondButtonLabel={canScheduleUntilOnline ? lang('Schedule.SendWhenOnline') : undefined}
onClose={handleCloseCalendar}
onSubmit={handleMessageSchedule}
onSecondButtonClick={canScheduleUntilOnline ? handleMessageScheduleUntilOnline : undefined}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId, messageListType }): StateProps => {
const chat = selectChat(global, chatId);
const chatUser = chat && selectChatUser(global, chat);
const chatBot = chatId !== REPLIES_USER_ID ? selectChatBot(global, chatId) : undefined;
const isChatWithBot = Boolean(chatBot);
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId);
const scheduledIds = selectScheduledIds(global, chatId);
const { language, shouldSuggestStickers } = global.settings.byKey;
const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined;
const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined;
const usersById = global.users.byId;
const chatsById = global.chats.byId;
const { currentUserId } = global;
const sendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined;
const sendAsUser = sendAsId ? usersById?.[sendAsId] : undefined;
const sendAsChat = !sendAsUser && sendAsId ? chatsById?.[sendAsId] : undefined;
return {
editingMessage: selectEditingMessage(global, chatId, threadId, messageListType),
connectionState: global.connectionState,
draft: selectDraft(global, chatId, threadId),
chat,
isChatWithBot,
isChatWithSelf,
canScheduleUntilOnline: Boolean(
!isChatWithSelf && !isChatWithBot && chat && chatUser
&& isUserId(chatId) && selectUserStatus(global, chatId)?.wasOnline,
),
isRightColumnShown: selectIsRightColumnShown(global),
isSelectModeActive: selectIsInSelectMode(global),
withScheduledButton: (
threadId === MAIN_THREAD_ID
&& messageListType === 'thread'
&& Boolean(scheduledIds?.length)
),
shouldSchedule: messageListType === 'scheduled',
botKeyboardMessageId,
botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder,
isForwarding: chatId === global.forwardMessages.toChatId,
isPollModalOpen: global.isPollModalOpen,
stickersForEmoji: global.stickers.forEmoji.stickers,
groupChatMembers: chat?.fullInfo?.members,
topInlineBotIds: global.topInlineBots?.userIds,
currentUserId,
usersById,
lastSyncTime: global.lastSyncTime,
contentToBeScheduled: global.messages.contentToBeScheduled,
shouldSuggestStickers,
recentEmojis: global.recentEmojis,
baseEmojiKeywords: baseEmojiKeywords?.keywords,
emojiKeywords: emojiKeywords?.keywords,
serverTimeOffset: global.serverTimeOffset,
inlineBots: global.inlineBots.byUsername,
isInlineBotLoading: global.inlineBots.isLoading,
chatBotCommands: chat && chat.fullInfo && chat.fullInfo.botCommands,
botCommands: chatBot && chatBot.fullInfo ? (chatBot.fullInfo.botCommands || false) : undefined,
sendAsUser,
sendAsChat,
sendAsId,
};
},
)(Composer));