966 lines
32 KiB
TypeScript
966 lines
32 KiB
TypeScript
import React, {
|
|
FC, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
|
} from '../../../lib/teact/teact';
|
|
import { withGlobal } from '../../../lib/teact/teactn';
|
|
|
|
import { GlobalActions, GlobalState, MessageListType } from '../../../global/types';
|
|
import {
|
|
ApiAttachment,
|
|
ApiSticker,
|
|
ApiVideo,
|
|
ApiNewPoll,
|
|
ApiMessage,
|
|
ApiFormattedText,
|
|
ApiChat,
|
|
ApiChatMember,
|
|
ApiUser,
|
|
MAIN_THREAD_ID,
|
|
} from '../../../api/types';
|
|
|
|
import { EDITABLE_INPUT_ID, SCHEDULED_WHEN_ONLINE } from '../../../config';
|
|
import { IS_VOICE_RECORDING_SUPPORTED, IS_MOBILE_SCREEN, IS_EMOJI_SUPPORTED } from '../../../util/environment';
|
|
import {
|
|
selectChat,
|
|
selectIsChatWithBot,
|
|
selectIsRightColumnShown,
|
|
selectIsInSelectMode,
|
|
selectNewestMessageWithBotKeyboardButtons,
|
|
selectDraft,
|
|
selectScheduledIds,
|
|
selectEditingMessage,
|
|
selectIsChatWithSelf,
|
|
selectChatUser,
|
|
} from '../../../modules/selectors';
|
|
import {
|
|
getAllowedAttachmentOptions,
|
|
getChatSlowModeOptions,
|
|
isChatGroup,
|
|
isChatPrivate,
|
|
isChatAdmin,
|
|
} from '../../../modules/helpers';
|
|
import { formatVoiceRecordDuration, getDayStartAt } from '../../../util/dateFormat';
|
|
import focusEditableElement from '../../../util/focusEditableElement';
|
|
import parseMessageInput from './helpers/parseMessageInput';
|
|
import buildAttachment from './helpers/buildAttachment';
|
|
import renderText from '../../common/helpers/renderText';
|
|
import insertHtmlInSelection from '../../../util/insertHtmlInSelection';
|
|
import deleteLastCharacterOutsideSelection from '../../../util/deleteLastCharacterOutsideSelection';
|
|
import { pick } from '../../../util/iteratees';
|
|
import buildClassName from '../../../util/buildClassName';
|
|
import { isSelectionInsideInput } from './helpers/selection';
|
|
|
|
import useFlag from '../../../hooks/useFlag';
|
|
import useVoiceRecording from './hooks/useVoiceRecording';
|
|
import useClipboardPaste from './hooks/useClipboardPaste';
|
|
import useDraft from './hooks/useDraft';
|
|
import useEditing from './hooks/useEditing';
|
|
import usePrevious from '../../../hooks/usePrevious';
|
|
import useStickerTooltip from './hooks/useStickerTooltip';
|
|
import useEmojiTooltip from './hooks/useEmojiTooltip';
|
|
import useMentionTooltip from './hooks/useMentionTooltip';
|
|
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
|
|
import useLang from '../../../hooks/useLang';
|
|
|
|
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 MentionTooltip from './MentionTooltip.async';
|
|
import CustomSendMenu from './CustomSendMenu.async';
|
|
import StickerTooltip from './StickerTooltip.async';
|
|
import EmojiTooltip from './EmojiTooltip.async';
|
|
import BotKeyboardMenu from './BotKeyboardMenu.async';
|
|
import MessageInput from './MessageInput';
|
|
import ComposerEmbeddedMessage from './ComposerEmbeddedMessage';
|
|
import AttachmentModal from './AttachmentModal.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 PaymentModal from '../../payment/PaymentModal.async';
|
|
import ReceiptModal from '../../payment/ReceiptModal.async';
|
|
|
|
import './Composer.scss';
|
|
|
|
type OwnProps = {
|
|
chatId: number;
|
|
threadId: number;
|
|
messageListType: MessageListType;
|
|
dropAreaState: string;
|
|
onDropHide: NoneToVoidFunction;
|
|
};
|
|
|
|
type StateProps = {
|
|
editingMessage?: ApiMessage;
|
|
chat?: ApiChat;
|
|
draft?: ApiFormattedText;
|
|
isChatWithBot?: boolean;
|
|
isChatWithSelf?: boolean;
|
|
isRightColumnShown?: boolean;
|
|
isSelectModeActive?: boolean;
|
|
isForwarding?: boolean;
|
|
canSuggestMembers?: boolean;
|
|
isPollModalOpen?: boolean;
|
|
isPaymentModalOpen?: boolean;
|
|
isReceiptModalOpen?: boolean;
|
|
botKeyboardMessageId?: number;
|
|
withScheduledButton?: boolean;
|
|
shouldSchedule?: boolean;
|
|
canScheduleUntilOnline?: boolean;
|
|
stickersForEmoji?: ApiSticker[];
|
|
groupChatMembers?: ApiChatMember[];
|
|
currentUserId?: number;
|
|
usersById?: Record<number, ApiUser>;
|
|
recentEmojis: string[];
|
|
lastSyncTime?: number;
|
|
contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled'];
|
|
shouldSuggestStickers?: boolean;
|
|
} & Pick<GlobalState, 'connectionState'>;
|
|
|
|
type DispatchProps = Pick<GlobalActions, (
|
|
'sendMessage' | 'editMessage' | 'saveDraft' | 'forwardMessages' |
|
|
'clearDraft' | 'showError' | 'setStickerSearchQuery' | 'setGifSearchQuery' |
|
|
'openPollModal' | 'closePollModal' | 'loadScheduledHistory' | 'openChat' | 'closePaymentModal' |
|
|
'clearReceipt' | 'addRecentEmoji'
|
|
)>;
|
|
|
|
enum MainButtonState {
|
|
Send = 'send',
|
|
Record = 'record',
|
|
Edit = 'edit',
|
|
}
|
|
|
|
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 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 & DispatchProps> = ({
|
|
dropAreaState,
|
|
shouldSchedule,
|
|
canScheduleUntilOnline,
|
|
onDropHide,
|
|
editingMessage,
|
|
chatId,
|
|
threadId,
|
|
messageListType,
|
|
draft,
|
|
chat,
|
|
connectionState,
|
|
isChatWithBot,
|
|
isChatWithSelf,
|
|
isRightColumnShown,
|
|
isSelectModeActive,
|
|
isForwarding,
|
|
canSuggestMembers,
|
|
isPollModalOpen,
|
|
isPaymentModalOpen,
|
|
isReceiptModalOpen,
|
|
botKeyboardMessageId,
|
|
withScheduledButton,
|
|
stickersForEmoji,
|
|
groupChatMembers,
|
|
currentUserId,
|
|
usersById,
|
|
lastSyncTime,
|
|
contentToBeScheduled,
|
|
shouldSuggestStickers,
|
|
recentEmojis,
|
|
sendMessage,
|
|
editMessage,
|
|
saveDraft,
|
|
clearDraft,
|
|
showError,
|
|
setStickerSearchQuery,
|
|
setGifSearchQuery,
|
|
forwardMessages,
|
|
openPollModal,
|
|
closePollModal,
|
|
loadScheduledHistory,
|
|
closePaymentModal,
|
|
openChat,
|
|
clearReceipt,
|
|
addRecentEmoji,
|
|
}) => {
|
|
// 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>();
|
|
|
|
// 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) {
|
|
loadScheduledHistory();
|
|
}
|
|
}, [chatId, loadScheduledHistory, lastSyncTime, threadId]);
|
|
|
|
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 [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
|
|
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = 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();
|
|
|
|
const mainButtonState = editingMessage
|
|
? MainButtonState.Edit
|
|
: !IS_VOICE_RECORDING_SUPPORTED || activeVoiceRecording || (html && !attachments.length) || isForwarding
|
|
? MainButtonState.Send
|
|
: MainButtonState.Record;
|
|
const canShowCustomSendMenu = !shouldSchedule;
|
|
|
|
const {
|
|
isMentionTooltipOpen, mentionFilter,
|
|
closeMentionTooltip, insertMention,
|
|
mentionFilteredMembers,
|
|
} = useMentionTooltip(
|
|
canSuggestMembers && !attachments.length,
|
|
html,
|
|
setHtml,
|
|
undefined,
|
|
groupChatMembers,
|
|
currentUserId,
|
|
usersById,
|
|
);
|
|
|
|
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,
|
|
);
|
|
const {
|
|
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji,
|
|
} = useEmojiTooltip(
|
|
Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length),
|
|
html,
|
|
recentEmojis,
|
|
undefined,
|
|
setHtml,
|
|
);
|
|
|
|
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)) {
|
|
if (IS_EMOJI_SUPPORTED) {
|
|
// Insertion will trigger `onChange` in MessageInput, so no need to setHtml in state
|
|
document.execCommand('insertText', false, text);
|
|
} else {
|
|
insertHtmlInSelection(newHtml);
|
|
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
setHtml(`${htmlRef.current!}${newHtml}`);
|
|
|
|
if (!IS_MOBILE_SCREEN) {
|
|
// If selection is outside of input, set cursor at the end of input
|
|
requestAnimationFrame(() => {
|
|
focusEditableElement(messageInput);
|
|
});
|
|
}
|
|
} else {
|
|
setHtml(`${htmlRef.current!}${newHtml}`);
|
|
}
|
|
}, []);
|
|
|
|
const removeSymbol = useCallback(() => {
|
|
const selection = window.getSelection()!;
|
|
|
|
if (selection.rangeCount) {
|
|
const selectionRange = selection.getRangeAt(0);
|
|
if (isSelectionInsideInput(selectionRange)) {
|
|
document.execCommand('delete', false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!));
|
|
}, []);
|
|
|
|
const resetComposer = useCallback(() => {
|
|
setHtml('');
|
|
setAttachments([]);
|
|
closeStickerTooltip();
|
|
closeCalendar();
|
|
setScheduledMessageArgs(undefined);
|
|
closeMentionTooltip();
|
|
closeEmojiTooltip();
|
|
|
|
if (IS_MOBILE_SCREEN) {
|
|
// @perf
|
|
setTimeout(() => closeSymbolMenu(), SENDING_ANIMATION_DURATION);
|
|
} else {
|
|
closeSymbolMenu();
|
|
}
|
|
}, [closeStickerTooltip, closeCalendar, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]);
|
|
|
|
// Handle chat change
|
|
const prevChatId = usePrevious(chatId);
|
|
useEffect(() => {
|
|
if (!prevChatId || chatId === prevChatId) {
|
|
return;
|
|
}
|
|
|
|
stopRecordingVoice();
|
|
resetComposer();
|
|
}, [chatId, prevChatId, resetComposer, stopRecordingVoice]);
|
|
|
|
const handleEditComplete = useEditing(htmlRef, setHtml, editingMessage, resetComposer, openDeleteModal, editMessage);
|
|
useDraft(draft, chatId, threadId, html, htmlRef, setHtml, editingMessage, saveDraft, clearDraft);
|
|
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;
|
|
}
|
|
|
|
if (currentAttachments.length && text && text.length > CAPTION_MAX_LENGTH) {
|
|
const extraLength = text.length - CAPTION_MAX_LENGTH;
|
|
showError({
|
|
error: {
|
|
message: 'CAPTION_TOO_LONG_PLEASE_REMOVE_CHARACTERS',
|
|
textParams: {
|
|
'{EXTRA_CHARS_COUNT}': extraLength,
|
|
'{PLURAL_S}': extraLength > 1 ? 's' : '',
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (currentAttachments.length || text) {
|
|
if (slowMode && !isAdmin) {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
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!;
|
|
showError({
|
|
error: {
|
|
message: `A wait of ${secondsRemaining} seconds is required before sending another message in this chat`,
|
|
isSlowMode: true,
|
|
},
|
|
});
|
|
|
|
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
|
|
messageInput.blur();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
sendMessage({
|
|
text,
|
|
entities,
|
|
attachments: currentAttachments,
|
|
scheduledAt,
|
|
isSilent,
|
|
});
|
|
}
|
|
if (isForwarding) {
|
|
forwardMessages();
|
|
}
|
|
|
|
lastMessageSendTimeSeconds.current = Math.floor(Date.now() / 1000);
|
|
|
|
clearDraft({ chatId, localOnly: true });
|
|
|
|
// Wait until message animation starts
|
|
requestAnimationFrame(resetComposer);
|
|
}, [
|
|
activeVoiceRecording, attachments, connectionState, chatId, slowMode, isForwarding, isAdmin,
|
|
sendMessage, stopRecordingVoice, resetComposer, clearDraft, showError, forwardMessages,
|
|
]);
|
|
|
|
const handleStickerSelect = useCallback((sticker: ApiSticker) => {
|
|
sticker = {
|
|
...sticker,
|
|
isPreloadedGlobally: true,
|
|
};
|
|
|
|
if (shouldSchedule) {
|
|
setScheduledMessageArgs({ sticker });
|
|
openCalendar();
|
|
} else {
|
|
sendMessage({ sticker });
|
|
requestAnimationFrame(resetComposer);
|
|
}
|
|
}, [shouldSchedule, openCalendar, sendMessage, resetComposer]);
|
|
|
|
const handleGifSelect = useCallback((gif: ApiVideo) => {
|
|
if (shouldSchedule) {
|
|
setScheduledMessageArgs({ gif });
|
|
openCalendar();
|
|
} else {
|
|
sendMessage({ gif });
|
|
requestAnimationFrame(resetComposer);
|
|
}
|
|
}, [shouldSchedule, openCalendar, sendMessage, 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 {
|
|
handleSend(true);
|
|
}
|
|
}, [handleSend, openCalendar, shouldSchedule]);
|
|
|
|
const handleMessageSchedule = useCallback((date: Date) => {
|
|
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);
|
|
|
|
if (!scheduledMessageArgs || Object.keys(restArgs).length === 0) {
|
|
handleSend(!!isSilent, scheduledAt);
|
|
} else {
|
|
sendMessage({
|
|
...scheduledMessageArgs,
|
|
scheduledAt,
|
|
});
|
|
requestAnimationFrame(resetComposer);
|
|
}
|
|
closeCalendar();
|
|
}, [closeCalendar, handleSend, resetComposer, scheduledMessageArgs, sendMessage]);
|
|
|
|
const handleMessageScheduleUntilOnline = useCallback(() => {
|
|
handleMessageSchedule(new Date(SCHEDULED_WHEN_ONLINE * 1000));
|
|
}, [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_MOBILE_SCREEN || messageInput !== document.activeElement) {
|
|
openSymbolMenu();
|
|
return;
|
|
}
|
|
|
|
messageInput.blur();
|
|
setTimeout(() => {
|
|
openSymbolMenu();
|
|
}, MOBILE_KEYBOARD_HIDE_DELAY_MS);
|
|
}, [openSymbolMenu]);
|
|
|
|
const handleAllScheduledClick = useCallback(() => {
|
|
openChat({ id: chatId, threadId, type: 'scheduled' });
|
|
}, [openChat, chatId, threadId]);
|
|
|
|
useEffect(() => {
|
|
if (isRightColumnShown && IS_MOBILE_SCREEN) {
|
|
closeSymbolMenu();
|
|
}
|
|
}, [isRightColumnShown, closeSymbolMenu]);
|
|
|
|
useEffect(() => {
|
|
if (isSelectModeActive) {
|
|
disableHover();
|
|
} else {
|
|
setTimeout(() => {
|
|
enableHover();
|
|
}, SELECT_MODE_TRANSITION_MS);
|
|
}
|
|
}, [isSelectModeActive, enableHover, disableHover]);
|
|
|
|
const mainButtonHandler = useCallback(() => {
|
|
switch (mainButtonState) {
|
|
case MainButtonState.Send:
|
|
if (shouldSchedule) {
|
|
if (activeVoiceRecording) {
|
|
pauseRecordingVoice();
|
|
}
|
|
openCalendar();
|
|
} else {
|
|
handleSend();
|
|
requestAnimationFrame(resetComposer);
|
|
}
|
|
break;
|
|
case MainButtonState.Record:
|
|
startRecordingVoice();
|
|
break;
|
|
case MainButtonState.Edit:
|
|
handleEditComplete();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}, [
|
|
mainButtonState, resetComposer, shouldSchedule, startRecordingVoice, handleEditComplete,
|
|
activeVoiceRecording, openCalendar, pauseRecordingVoice, handleSend,
|
|
]);
|
|
|
|
const lang = useLang();
|
|
|
|
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',
|
|
isSymbolMenuLoaded
|
|
? (isSymbolMenuOpen && 'menu-opened')
|
|
: (isSymbolMenuOpen && 'is-loading'),
|
|
);
|
|
|
|
return (
|
|
<div className={className}>
|
|
{allowedAttachmentOptions.canAttachMedia && (
|
|
<Portal containerId="#middle-column-portals">
|
|
<DropArea
|
|
isOpen={dropAreaState !== DropAreaState.None}
|
|
withQuick={[dropAreaState, prevDropAreaState].includes(DropAreaState.QuickFile)}
|
|
onHide={onDropHide}
|
|
onFileSelect={handleFileSelect}
|
|
/>
|
|
</Portal>
|
|
)}
|
|
<AttachmentModal
|
|
attachments={attachments}
|
|
caption={attachments.length ? html : ''}
|
|
canSuggestMembers={canSuggestMembers}
|
|
groupChatMembers={groupChatMembers}
|
|
currentUserId={currentUserId}
|
|
usersById={usersById}
|
|
recentEmojis={recentEmojis}
|
|
onCaptionUpdate={setHtml}
|
|
addRecentEmoji={addRecentEmoji}
|
|
onSend={shouldSchedule ? openCalendar : handleSend}
|
|
onFileAppend={handleAppendFiles}
|
|
onClear={handleClearAttachment}
|
|
/>
|
|
<PollModal
|
|
isOpen={Boolean(isPollModalOpen)}
|
|
onClear={closePollModal}
|
|
onSend={handlePollSend}
|
|
/>
|
|
<PaymentModal
|
|
isOpen={Boolean(isPaymentModalOpen)}
|
|
onClose={closePaymentModal}
|
|
/>
|
|
<ReceiptModal
|
|
isOpen={Boolean(isReceiptModalOpen)}
|
|
onClose={clearReceipt}
|
|
/>
|
|
{renderedEditedMessage && (
|
|
<DeleteMessageModal
|
|
isOpen={isDeleteModalOpen}
|
|
isSchedule={messageListType === 'scheduled'}
|
|
onClose={closeDeleteModal}
|
|
message={renderedEditedMessage}
|
|
/>
|
|
)}
|
|
<MentionTooltip
|
|
isOpen={isMentionTooltipOpen}
|
|
filter={mentionFilter}
|
|
onClose={closeMentionTooltip}
|
|
onInsertUserName={insertMention}
|
|
filteredChatMembers={mentionFilteredMembers}
|
|
usersById={usersById}
|
|
/>
|
|
<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">
|
|
{IS_MOBILE_SCREEN ? (
|
|
<Button
|
|
className={symbolMenuButtonClassName}
|
|
round
|
|
color="translucent"
|
|
onClick={isSymbolMenuOpen ? closeSymbolMenu : handleSymbolMenuOpen}
|
|
ariaLabel="Choose emoji, sticker or GIF"
|
|
>
|
|
<i className="icon-smile" />
|
|
<i className="icon-keyboard" />
|
|
<Spinner color="gray" />
|
|
</Button>
|
|
) : (
|
|
<ResponsiveHoverButton
|
|
className={`${isSymbolMenuOpen ? 'activated' : ''}`}
|
|
round
|
|
faded
|
|
color="translucent"
|
|
onActivate={openSymbolMenu}
|
|
ariaLabel="Choose emoji, sticker or GIF"
|
|
>
|
|
<i className="icon-smile" />
|
|
</ResponsiveHoverButton>
|
|
)}
|
|
<MessageInput
|
|
id="message-input-text"
|
|
html={!attachments.length ? html : ''}
|
|
placeholder={
|
|
activeVoiceRecording && window.innerWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER ? '' : lang('Message')
|
|
}
|
|
shouldSetFocus={isSymbolMenuOpen}
|
|
shouldSupressFocus={IS_MOBILE_SCREEN && isSymbolMenuOpen}
|
|
shouldSupressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen}
|
|
onUpdate={setHtml}
|
|
onSend={mainButtonState === MainButtonState.Edit
|
|
? handleEditComplete
|
|
: (shouldSchedule ? openCalendar : handleSend)}
|
|
onSupressedFocus={closeSymbolMenu}
|
|
/>
|
|
{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
|
|
faded
|
|
color="translucent"
|
|
onActivate={openBotKeyboard}
|
|
ariaLabel="Open bot command keyboard"
|
|
>
|
|
<i className="icon-bot-command" />
|
|
</ResponsiveHoverButton>
|
|
)}
|
|
{!activeVoiceRecording && !editingMessage && (
|
|
<ResponsiveHoverButton
|
|
className={`${isAttachMenuOpen ? 'activated' : ''}`}
|
|
round
|
|
faded
|
|
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
|
|
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}
|
|
/>
|
|
)}
|
|
<SymbolMenu
|
|
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={`${mainButtonState} ${activeVoiceRecording ? 'recording' : ''}`}
|
|
disabled={areVoiceMessagesNotAllowed}
|
|
ariaLabel={lang(sendButtonAriaLabel)}
|
|
onClick={mainButtonHandler}
|
|
onContextMenu={
|
|
mainButtonState === MainButtonState.Send && canShowCustomSendMenu ? handleContextMenu : undefined
|
|
}
|
|
>
|
|
<i className="icon-send" />
|
|
<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 ? 'Send When Online' : 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 isChatWithBot = chat ? selectIsChatWithBot(global, chat) : undefined;
|
|
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
|
|
const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId);
|
|
const scheduledIds = selectScheduledIds(global, chatId);
|
|
|
|
return {
|
|
editingMessage: selectEditingMessage(global, chatId, threadId, messageListType),
|
|
connectionState: global.connectionState,
|
|
draft: selectDraft(global, chatId, threadId),
|
|
chat,
|
|
isChatWithBot,
|
|
isChatWithSelf,
|
|
canScheduleUntilOnline: (
|
|
!isChatWithSelf && !isChatWithBot
|
|
&& (chat && chatUser && isChatPrivate(chatId) && chatUser.status && Boolean(chatUser.status.wasOnline))
|
|
),
|
|
isRightColumnShown: selectIsRightColumnShown(global),
|
|
isSelectModeActive: selectIsInSelectMode(global),
|
|
withScheduledButton: (
|
|
threadId === MAIN_THREAD_ID
|
|
&& messageListType === 'thread'
|
|
&& Boolean(scheduledIds && scheduledIds.length)
|
|
),
|
|
shouldSchedule: messageListType === 'scheduled',
|
|
botKeyboardMessageId: messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined,
|
|
isForwarding: chatId === global.forwardMessages.toChatId,
|
|
canSuggestMembers: chat && isChatGroup(chat),
|
|
isPollModalOpen: global.isPollModalOpen,
|
|
stickersForEmoji: global.stickers.forEmoji.stickers,
|
|
groupChatMembers: chat && chat.fullInfo && chat.fullInfo.members,
|
|
currentUserId: global.currentUserId,
|
|
usersById: global.users.byId,
|
|
lastSyncTime: global.lastSyncTime,
|
|
contentToBeScheduled: global.messages.contentToBeScheduled,
|
|
isPaymentModalOpen: global.payment.isPaymentModalOpen,
|
|
isReceiptModalOpen: Boolean(global.payment.receipt),
|
|
shouldSuggestStickers: global.settings.byKey.shouldSuggestStickers,
|
|
recentEmojis: global.recentEmojis,
|
|
};
|
|
},
|
|
(setGlobal, actions): DispatchProps => pick(actions, [
|
|
'sendMessage',
|
|
'editMessage',
|
|
'saveDraft',
|
|
'clearDraft',
|
|
'showError',
|
|
'setStickerSearchQuery',
|
|
'setGifSearchQuery',
|
|
'forwardMessages',
|
|
'openPollModal',
|
|
'closePollModal',
|
|
'closePaymentModal',
|
|
'clearReceipt',
|
|
'loadScheduledHistory',
|
|
'openChat',
|
|
'addRecentEmoji',
|
|
]),
|
|
)(Composer));
|