import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; import { BASE_EMOJI_KEYWORD_LANG, EDITABLE_INPUT_MODAL_ID, SUPPORTED_AUDIO_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import { getHtmlTextLength } from './helpers/getHtmlTextLength'; import { selectChat, selectIsChatWithSelf } from '../../../global/selectors'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import buildClassName from '../../../util/buildClassName'; import { validateFiles } from '../../../util/files'; import usePrevious from '../../../hooks/usePrevious'; import useMentionTooltip from './hooks/useMentionTooltip'; import useEmojiTooltip from './hooks/useEmojiTooltip'; import useLang from '../../../hooks/useLang'; import useFlag from '../../../hooks/useFlag'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import { useStateRef } from '../../../hooks/useStateRef'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useAppLayout from '../../../hooks/useAppLayout'; import useScrolledState from '../../../hooks/useScrolledState'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; import MessageInput from './MessageInput'; import MentionTooltip from './MentionTooltip'; import EmojiTooltip from './EmojiTooltip.async'; import CustomSendMenu from './CustomSendMenu.async'; import CustomEmojiTooltip from './CustomEmojiTooltip.async'; import AttachmentModalItem from './AttachmentModalItem'; import DropdownMenu from '../../ui/DropdownMenu'; import MenuItem from '../../ui/MenuItem'; import styles from './AttachmentModal.module.scss'; export type OwnProps = { chatId: string; threadId: number; attachments: ApiAttachment[]; caption: string; canShowCustomSendMenu?: boolean; isReady?: boolean; shouldSchedule?: boolean; shouldSuggestCompression?: 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; }; type StateProps = { isChatWithSelf?: boolean; currentUserId?: string; groupChatMembers?: ApiChatMember[]; recentEmojis: string[]; baseEmojiKeywords?: Record; emojiKeywords?: Record; shouldSuggestCustomEmoji?: boolean; customEmojiForEmoji?: ApiSticker[]; captionLimit: number; attachmentSettings: GlobalState['attachmentSettings']; }; const DROP_LEAVE_TIMEOUT_MS = 150; const CAPTION_SYMBOLS_LEFT_THRESHOLD = 100; const AttachmentModal: FC = ({ chatId, threadId, attachments, caption, canShowCustomSendMenu, captionLimit, isReady, isChatWithSelf, currentUserId, groupChatMembers, recentEmojis, baseEmojiKeywords, emojiKeywords, shouldSchedule, shouldSuggestCustomEmoji, customEmojiForEmoji, attachmentSettings, shouldSuggestCompression, onAttachmentsUpdate, onCaptionUpdate, onSend, onFileAppend, onClear, onSendSilent, onSendScheduled, }) => { const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions(); const lang = useLang(); const captionRef = useStateRef(caption); // eslint-disable-next-line no-null/no-null const mainButtonRef = useStateRef(null); const hideTimeoutRef = useRef(); const prevAttachments = usePrevious(attachments); const renderingAttachments = attachments.length ? attachments : prevAttachments; const { isMobile } = useAppLayout(); const [shouldSendCompressed, setShouldSendCompressed] = useState( shouldSuggestCompression ?? attachmentSettings.shouldCompress, ); 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 [isHovered, markHovered, unmarkHovered] = useFlag(); 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 { isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, } = useMentionTooltip( isOpen, `#${EDITABLE_INPUT_MODAL_ID}`, onCaptionUpdate, groupChatMembers, undefined, currentUserId, ); const { isCustomEmojiTooltipOpen, insertCustomEmoji } = useCustomEmojiTooltip( Boolean(shouldSuggestCustomEmoji) && isOpen, `#${EDITABLE_INPUT_MODAL_ID}`, caption, onCaptionUpdate, customEmojiForEmoji, !isReady, ); const { isEmojiTooltipOpen, filteredEmojis, filteredCustomEmojis, insertEmoji, insertCustomEmoji: insertCustomEmojiFromEmojiTooltip, closeEmojiTooltip, } = useEmojiTooltip( isOpen, captionRef, recentEmojis, EDITABLE_INPUT_MODAL_ID, onCaptionUpdate, baseEmojiKeywords, emojiKeywords, !isReady, ); useEffect(() => (isOpen ? captureEscKeyListener(onClear) : undefined), [isOpen, onClear]); useEffect(() => { if (isOpen) { setShouldSendCompressed(shouldSuggestCompression ?? attachmentSettings.shouldCompress); setShouldSendGrouped(attachmentSettings.shouldSendGrouped); } }, [attachmentSettings, isOpen, shouldSuggestCompression]); const { isContextMenuOpen: isCustomSendMenuOpen, handleContextMenu, handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(mainButtonRef, !canShowCustomSendMenu || !isOpen); const sendAttachments = useCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { if (isOpen) { const send = (shouldSchedule || shouldSendScheduled) ? onSendScheduled : isSilent ? onSendSilent : onSend; send(shouldSendCompressed, shouldSendGrouped); updateAttachmentSettings({ shouldCompress: shouldSendCompressed, shouldSendGrouped, }); } }, [ isOpen, shouldSchedule, onSendScheduled, onSend, updateAttachmentSettings, shouldSendCompressed, shouldSendGrouped, onSendSilent, ]); const handleSendSilent = useCallback(() => { sendAttachments(true); }, [sendAttachments]); const handleSendClick = useCallback(() => { sendAttachments(); }, [sendAttachments]); const handleScheduleClick = useCallback(() => { sendAttachments(false, true); }, [sendAttachments]); 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 = useCallback(async (e: React.DragEvent) => { e.preventDefault(); unmarkHovered(); const { dataTransfer } = e; const files = await getFilesFromDataTransferItems(dataTransfer.items); if (files?.length) { onFileAppend(files, isEverySpoiler); } }, [isEverySpoiler, onFileAppend, unmarkHovered]); function handleDragOver(e: React.MouseEvent) { e.preventDefault(); if (hideTimeoutRef.current) { window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = undefined; } } const handleFileSelect = useCallback((e: Event) => { const { files } = e.target as HTMLInputElement; const validatedFiles = validateFiles(files); if (validatedFiles?.length) { onFileAppend(validatedFiles, isEverySpoiler); } }, [isEverySpoiler, onFileAppend]); const handleDocumentSelect = useCallback(() => { openSystemFilesDialog('*', (e) => handleFileSelect(e)); }, [handleFileSelect]); const handleDelete = useCallback((index: number) => { onAttachmentsUpdate(attachments.filter((a, i) => i !== index)); }, [attachments, onAttachmentsUpdate]); const handleEnableSpoilers = useCallback(() => { onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: true }))); }, [attachments, onAttachmentsUpdate]); const handleDisableSpoilers = useCallback(() => { onAttachmentsUpdate(attachments.map((a) => ({ ...a, shouldSendAsSpoiler: undefined }))); }, [attachments, onAttachmentsUpdate]); const handleToggleSpoiler = useCallback((index: number) => { onAttachmentsUpdate(attachments.map((attachment, i) => { if (i === index) { return { ...attachment, shouldSendAsSpoiler: !attachment.shouldSendAsSpoiler || undefined, }; } return attachment; })); }, [attachments, onAttachmentsUpdate]); const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { return ({ onTrigger, isOpen: isMenuOpen }) => ( ); }, [isMobile]); const leftChars = useMemo(() => { const captionLeftBeforeLimit = captionLimit - getHtmlTextLength(caption); return captionLeftBeforeLimit <= CAPTION_SYMBOLS_LEFT_THRESHOLD ? captionLeftBeforeLimit : undefined; }, [caption, captionLimit]); const isQuickGallery = shouldSendCompressed && 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]); 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 && ( <> { shouldSendCompressed ? ( // 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'} ) } {shouldSendCompressed && ( 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 chat = selectChat(global, chatId); 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: chat?.fullInfo?.members, recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, emojiKeywords: emojiKeywords?.keywords, shouldSuggestCustomEmoji, customEmojiForEmoji: customEmojis.forEmoji.stickers, captionLimit: selectCurrentLimit(global, 'captionLength'), attachmentSettings, }; }, )(AttachmentModal));