import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiAttachment, ApiChatMember, ApiSticker, } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; import type { ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; import { BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_MODAL_ID, GIF_MIME_TYPE, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { isUserId } from '../../../global/helpers'; import { selectChatFullInfo, selectIsChatWithSelf } from '../../../global/selectors'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { validateFiles } from '../../../util/files'; import { removeAllSelections } from '../../../util/selection'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import { getHtmlTextLength } from './helpers/getHtmlTextLength'; import useAppLayout from '../../../hooks/useAppLayout'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useDerivedState from '../../../hooks/useDerivedState'; import useFlag from '../../../hooks/useFlag'; import useGetSelectionRange from '../../../hooks/useGetSelectionRange'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import usePrevious from '../../../hooks/usePrevious'; import useScrolledState from '../../../hooks/useScrolledState'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip'; import useMentionTooltip from './hooks/useMentionTooltip'; import Button from '../../ui/Button'; import DropdownMenu from '../../ui/DropdownMenu'; import MenuItem from '../../ui/MenuItem'; import Modal from '../../ui/Modal'; import AttachmentModalItem from './AttachmentModalItem'; import CustomEmojiTooltip from './CustomEmojiTooltip.async'; import CustomSendMenu from './CustomSendMenu.async'; import EmojiTooltip from './EmojiTooltip.async'; import MentionTooltip from './MentionTooltip'; import MessageInput from './MessageInput'; import SymbolMenuButton from './SymbolMenuButton'; import styles from './AttachmentModal.module.scss'; export type OwnProps = { chatId: string; threadId: ThreadId; attachments: ApiAttachment[]; getHtml: Signal; canShowCustomSendMenu?: boolean; isReady: boolean; isForMessage?: boolean; shouldSchedule?: boolean; shouldSuggestCompression?: boolean; shouldForceCompression?: boolean; shouldForceAsFile?: boolean; isForCurrentMessageList?: boolean; forceDarkTheme?: boolean; onCaptionUpdate: (html: string) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; onFileAppend: (files: File[], isSpoiler?: boolean) => void; onAttachmentsUpdate: (attachments: ApiAttachment[]) => void; onClear: NoneToVoidFunction; onSendSilent: (sendCompressed: boolean, sendGrouped: boolean) => void; onSendScheduled: (sendCompressed: boolean, sendGrouped: boolean) => void; onCustomEmojiSelect: (emoji: ApiSticker) => void; onRemoveSymbol: VoidFunction; onEmojiSelect: (emoji: string) => void; }; type StateProps = { isChatWithSelf?: boolean; currentUserId?: string; groupChatMembers?: ApiChatMember[]; recentEmojis: string[]; baseEmojiKeywords?: Record; emojiKeywords?: Record; shouldSuggestCustomEmoji?: boolean; customEmojiForEmoji?: ApiSticker[]; captionLimit: number; attachmentSettings: GlobalState['attachmentSettings']; }; const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text'; const DROP_LEAVE_TIMEOUT_MS = 150; const MAX_LEFT_CHARS_TO_SHOW = 100; const AttachmentModal: FC = ({ chatId, threadId, attachments, getHtml, canShowCustomSendMenu, captionLimit, isReady, isChatWithSelf, currentUserId, groupChatMembers, recentEmojis, baseEmojiKeywords, emojiKeywords, isForMessage, shouldSchedule, shouldSuggestCustomEmoji, customEmojiForEmoji, attachmentSettings, shouldSuggestCompression, shouldForceCompression, shouldForceAsFile, isForCurrentMessageList, forceDarkTheme, onAttachmentsUpdate, onCaptionUpdate, onSend, onFileAppend, onClear, onSendSilent, onSendScheduled, onCustomEmojiSelect, onRemoveSymbol, onEmojiSelect, }) => { const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions(); const lang = useLang(); // eslint-disable-next-line no-null/no-null const mainButtonRef = useRef(null); // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); const hideTimeoutRef = useRef(); const prevAttachments = usePrevious(attachments); const renderingAttachments = attachments.length ? attachments : prevAttachments; const { isMobile } = useAppLayout(); const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag(); const [shouldSendCompressed, setShouldSendCompressed] = useState( shouldSuggestCompression ?? attachmentSettings.shouldCompress, ); const isSendingCompressed = Boolean((shouldSendCompressed || shouldForceCompression) && !shouldForceAsFile); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const { handleScroll: handleAttachmentsScroll, isAtBeginning: areAttachmentsNotScrolled, isAtEnd: areAttachmentsScrolledToBottom, } = useScrolledState(); const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState(); const isOpen = Boolean(attachments.length); const renderingIsOpen = Boolean(renderingAttachments?.length); const [isHovered, markHovered, unmarkHovered] = useFlag(); useEffect(() => { if (!isOpen) { closeSymbolMenu(); } }, [closeSymbolMenu, isOpen]); const [hasMedia, hasOnlyMedia] = useMemo(() => { const onlyMedia = Boolean(renderingAttachments?.every((a) => a.quick || a.audio)); if (onlyMedia) return [true, true]; const oneMedia = Boolean(renderingAttachments?.some((a) => a.quick || a.audio)); return [oneMedia, false]; }, [renderingAttachments]); const [hasSpoiler, isEverySpoiler] = useMemo(() => { const areAllSpoilers = Boolean(renderingAttachments?.every((a) => a.shouldSendAsSpoiler)); if (areAllSpoilers) return [true, true]; const hasOneSpoiler = Boolean(renderingAttachments?.some((a) => a.shouldSendAsSpoiler)); return [hasOneSpoiler, false]; }, [renderingAttachments]); const getSelectionRange = useGetSelectionRange(`#${EDITABLE_INPUT_MODAL_ID}`); const { isEmojiTooltipOpen, filteredEmojis, filteredCustomEmojis, insertEmoji, closeEmojiTooltip, } = useEmojiTooltip( Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen), getHtml, onCaptionUpdate, EDITABLE_INPUT_MODAL_ID, recentEmojis, baseEmojiKeywords, emojiKeywords, ); const { isCustomEmojiTooltipOpen, insertCustomEmoji, closeCustomEmojiTooltip, } = useCustomEmojiTooltip( Boolean(isReady && (isForCurrentMessageList || !isForMessage) && renderingIsOpen && shouldSuggestCustomEmoji), getHtml, onCaptionUpdate, getSelectionRange, inputRef, customEmojiForEmoji, ); const { isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, } = useMentionTooltip( Boolean(isReady && isForCurrentMessageList && renderingIsOpen), getHtml, onCaptionUpdate, getSelectionRange, inputRef, groupChatMembers, undefined, currentUserId, ); useEffect(() => (isOpen ? captureEscKeyListener(onClear) : undefined), [isOpen, onClear]); useEffect(() => { if (isOpen) { setShouldSendCompressed(shouldSuggestCompression ?? attachmentSettings.shouldCompress); setShouldSendGrouped(attachmentSettings.shouldSendGrouped); } }, [attachmentSettings, isOpen, shouldSuggestCompression]); useEffect(() => { if (isOpen && isMobile) { removeAllSelections(); } }, [isMobile, isOpen]); const { isContextMenuOpen: isCustomSendMenuOpen, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(mainButtonRef, !canShowCustomSendMenu || !isOpen); const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { if (isOpen) { const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled : isSilent ? onSendSilent : onSend; send(isSendingCompressed, shouldSendGrouped); updateAttachmentSettings({ shouldCompress: shouldSuggestCompression === undefined ? isSendingCompressed : undefined, shouldSendGrouped, }); } }); const handleSendSilent = useLastCallback(() => { sendAttachments(true); }); const handleSendClick = useLastCallback(() => { sendAttachments(); }); const handleScheduleClick = useLastCallback(() => { sendAttachments(false, true); }); const handleDragLeave = (e: React.DragEvent) => { const { relatedTarget: toTarget, target: fromTarget } = e; // Esc button pressed during drag event if ((fromTarget as HTMLDivElement).matches(`.${styles.dropTarget}`) && !toTarget) { hideTimeoutRef.current = window.setTimeout(unmarkHovered, DROP_LEAVE_TIMEOUT_MS); } // Prevent DragLeave event from firing when the pointer moves inside the AttachmentModal drop target if (fromTarget && (fromTarget as HTMLElement).closest(`.${styles.hovered}`)) { return; } if (toTarget) { e.stopPropagation(); } unmarkHovered(); }; const handleFilesDrop = useLastCallback(async (e: React.DragEvent) => { e.preventDefault(); unmarkHovered(); const { dataTransfer } = e; const files = await getFilesFromDataTransferItems(dataTransfer.items); if (files?.length) { onFileAppend(files, isEverySpoiler); } }); function handleDragOver(e: React.MouseEvent) { e.preventDefault(); if (hideTimeoutRef.current) { window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = undefined; } } const handleFileSelect = useLastCallback((e: Event) => { const { files } = e.target as HTMLInputElement; const validatedFiles = validateFiles(files); if (validatedFiles?.length) { onFileAppend(validatedFiles, isEverySpoiler); } }); const handleDocumentSelect = useLastCallback(() => { openSystemFilesDialog('*', (e) => handleFileSelect(e)); }); const handleDelete = useLastCallback((index: number) => { onAttachmentsUpdate(attachments.filter((a, i) => i !== index)); }); const handleEnableSpoilers = useLastCallback(() => { onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: a.mimeType !== GIF_MIME_TYPE ? true : undefined, }))); }); const handleDisableSpoilers = useLastCallback(() => { onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: undefined }))); }); const handleToggleSpoiler = useLastCallback((index: number) => { onAttachmentsUpdate(attachments.map((attachment, i) => { if (i === index) { return { ...attachment, shouldSendAsSpoiler: !attachment.shouldSendAsSpoiler || undefined, }; } return attachment; })); }); useEffect(() => { const mainButton = mainButtonRef.current; const input = document.getElementById(ATTACHMENT_MODAL_INPUT_ID); if (!mainButton || !input) return; const { width } = mainButton.getBoundingClientRect(); requestMutation(() => { input.style.setProperty('--margin-for-scrollbar', `${width}px`); }); }, [lang, isOpen]); const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { return ({ onTrigger, isOpen: isMenuOpen }) => ( ); }, [isMobile]); const leftChars = useDerivedState(() => { if (!renderingIsOpen) return undefined; const leftCharsBeforeLimit = captionLimit - getHtmlTextLength(getHtml()); return leftCharsBeforeLimit <= MAX_LEFT_CHARS_TO_SHOW ? leftCharsBeforeLimit : undefined; }, [captionLimit, getHtml, renderingIsOpen]); const isQuickGallery = isSendingCompressed && hasOnlyMedia; const [areAllPhotos, areAllVideos, areAllAudios] = useMemo(() => { if (!isQuickGallery || !renderingAttachments) return [false, false, false]; const everyPhoto = renderingAttachments.every((a) => SUPPORTED_IMAGE_CONTENT_TYPES.has(a.mimeType)); const everyVideo = renderingAttachments.every((a) => SUPPORTED_VIDEO_CONTENT_TYPES.has(a.mimeType)); const everyAudio = renderingAttachments.every((a) => SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)); return [everyPhoto, everyVideo, everyAudio]; }, [renderingAttachments, isQuickGallery]); const hasAnySpoilerable = useMemo(() => { if (!renderingAttachments) return false; return renderingAttachments.some((a) => a.mimeType !== GIF_MIME_TYPE && !SUPPORTED_AUDIO_CONTENT_TYPES.has(a.mimeType)); }, [renderingAttachments]); if (!renderingAttachments) { return undefined; } const isMultiple = renderingAttachments.length > 1; let title = ''; if (areAllPhotos) { title = lang('PreviewSender.SendPhoto', renderingAttachments.length, 'i'); } else if (areAllVideos) { title = lang('PreviewSender.SendVideo', renderingAttachments.length, 'i'); } else if (areAllAudios) { title = lang('PreviewSender.SendAudio', renderingAttachments.length, 'i'); } else { title = lang('PreviewSender.SendFile', renderingAttachments.length, 'i'); } function renderHeader() { if (!renderingAttachments) { return undefined; } return (
{title}
{lang('Add')} {hasMedia && ( <> { !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( // eslint-disable-next-line react/jsx-no-bind setShouldSendCompressed(false)}> {lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} ) : ( // eslint-disable-next-line react/jsx-no-bind setShouldSendCompressed(true)}> {isMultiple ? 'Send All as Media' : 'Send as Media'} )) } {isSendingCompressed && hasAnySpoilerable && ( hasSpoiler ? ( {lang('Attachment.DisableSpoiler')} ) : ( {lang('Attachment.EnableSpoiler')} ) )} )} {isMultiple && ( shouldSendGrouped ? ( setShouldSendGrouped(false)} > Ungroup All Media ) : ( // eslint-disable-next-line react/jsx-no-bind setShouldSendGrouped(true)}> Group All Media ) )}
); } const isBottomDividerShown = !areAttachmentsScrolledToBottom || !isCaptionNotScrolled; return (
{renderingAttachments.map((attachment, i) => ( ))}
{canShowCustomSendMenu && ( )}
); }; export default memo(withGlobal( (global, { chatId }): StateProps => { const { currentUserId, recentEmojis, customEmojis, attachmentSettings, } = global; const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; const isChatWithSelf = selectIsChatWithSelf(global, chatId); const { language, shouldSuggestCustomEmoji } = global.settings.byKey; const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG]; const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; return { isChatWithSelf, currentUserId, groupChatMembers: chatFullInfo?.members, recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, emojiKeywords: emojiKeywords?.keywords, shouldSuggestCustomEmoji, customEmojiForEmoji: customEmojis.forEmoji.stickers, captionLimit: selectCurrentLimit(global, 'captionLength'), attachmentSettings, }; }, )(AttachmentModal));