diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index fed83c3f6..474db0ace 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -917,7 +917,7 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep }; } -function buildUploadingMedia( +export function buildUploadingMedia( attachment: ApiAttachment, ): MediaContent { const { diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index 692b0f9a3..420f53179 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -4,13 +4,11 @@ import type { ApiEmojiInteraction, ApiSticker, ApiStickerSet, ApiStickerSetInfo, GramJsEmojiInteraction, } from '../../types'; +import { LOTTIE_STICKER_MIME_TYPE, VIDEO_STICKER_MIME_TYPE } from '../../../config'; import { compact } from '../../../util/iteratees'; import localDb from '../localDb'; import { buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common'; -const LOTTIE_STICKER_MIME_TYPE = 'application/x-tgsticker'; -const VIDEO_STICKER_MIME_TYPE = 'video/webm'; - export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPremium?: boolean): ApiSticker | undefined { if (document instanceof GramJs.DocumentEmpty) { return undefined; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index d9d055efd..a55eded23 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -24,35 +24,36 @@ import type { MediaContent, OnApiUpdate, } from '../../types'; -import { - MAIN_THREAD_ID, - MESSAGE_DELETED, -} from '../../types'; +import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../types'; import { ALL_FOLDER_ID, API_GENERAL_ID_LIMIT, - DEBUG, GIF_MIME_TYPE, MAX_INT_32, MENTION_UNREAD_SLICE, - PINNED_MESSAGES_LIMIT, REACTION_UNREAD_SLICE, + DEBUG, + GIF_MIME_TYPE, + MAX_INT_32, + MENTION_UNREAD_SLICE, + PINNED_MESSAGES_LIMIT, + REACTION_UNREAD_SLICE, SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { fetchFile } from '../../../util/files'; import { compact, split } from '../../../util/iteratees'; +import { getMessageKey } from '../../../util/messageKey'; import { getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; import { buildApiFormattedText } from '../apiBuilders/common'; -import { - buildMessageMediaContent, buildMessageTextContent, buildWebPage, -} from '../apiBuilders/messageContent'; +import { buildMessageMediaContent, buildMessageTextContent, buildWebPage } from '../apiBuilders/messageContent'; import { buildApiMessage, buildApiSponsoredMessage, buildApiThreadInfo, buildLocalForwardedMessage, buildLocalMessage, + buildUploadingMedia, } from '../apiBuilders/messages'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiUser } from '../apiBuilders/users'; @@ -560,31 +561,34 @@ export async function editMessage({ message, text, entities, + attachment, noWebPage, }: { chat: ApiChat; message: ApiMessage; text: string; entities?: ApiMessageEntity[]; + attachment?: ApiAttachment; noWebPage?: boolean; -}) { +}, onProgress?: ApiOnProgress) { const isScheduled = message.date * 1000 > Date.now() + getServerTimeOffset() * 1000; - let messageUpdate: Partial = { - content: { - ...message.content, - ...(text && { - text: { - text, - entities, - }, - }), - }, + + const media = attachment && buildUploadingMedia(attachment); + + const newContent = { + ...(media || message.content), + ...(text && { + text: { + text, + entities, + }, + }), }; - const emojiOnlyCount = getEmojiOnlyCountForMessage(messageUpdate.content!, messageUpdate.groupedId); - messageUpdate = { - ...messageUpdate, - emojiOnlyCount, + const messageUpdate: Partial = { + ...message, + content: newContent, + emojiOnlyCount: getEmojiOnlyCountForMessage(newContent, message.groupedId), }; onUpdate({ @@ -594,16 +598,47 @@ export async function editMessage({ message: messageUpdate, }); - const mtpEntities = entities && entities.map(buildMtpMessageEntity); + try { + let mediaUpdate: GramJs.TypeInputMedia | undefined; + if (attachment) { + mediaUpdate = await uploadMedia(message, attachment, onProgress!); + } - await invokeRequest(new GramJs.messages.EditMessage({ - message: text || '', - entities: mtpEntities, - peer: buildInputPeer(chat.id, chat.accessHash), - id: message.id, - ...(isScheduled && { scheduleDate: message.date }), - ...(noWebPage && { noWebpage: noWebPage }), - })); + const mtpEntities = entities && entities.map(buildMtpMessageEntity); + + await invokeRequest(new GramJs.messages.EditMessage({ + message: text || '', + entities: mtpEntities, + media: mediaUpdate, + peer: buildInputPeer(chat.id, chat.accessHash), + id: message.id, + ...(isScheduled && { scheduleDate: message.date }), + ...(noWebPage && { noWebpage: noWebPage }), + }), { shouldThrow: true }); + } catch (err) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn(err); + } + + const { message: messageErr } = err as Error; + + onUpdate({ + '@type': 'error', + error: { + message: messageErr, + hasErrorKey: true, + }, + }); + + // Rollback changes + onUpdate({ + '@type': isScheduled ? 'updateScheduledMessage' : 'updateMessage', + id: message.id, + chatId: chat.id, + message, + }); + } } export async function rescheduleMessage({ @@ -622,7 +657,7 @@ export async function rescheduleMessage({ })); } -async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment, onProgress: ApiOnProgress) { +async function uploadMedia(message: ApiMessage, attachment: ApiAttachment, onProgress: ApiOnProgress) { const { filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile, shouldSendAsSpoiler, ttlSeconds, } = attachment; @@ -631,7 +666,7 @@ async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment, if (onProgress.isCanceled) { patchedOnProgress.isCanceled = true; } else { - onProgress(progress, localMessage.id); + onProgress(progress, getMessageKey(message)); } }; diff --git a/src/components/common/Audio.tsx b/src/components/common/Audio.tsx index 7174524c4..226260581 100644 --- a/src/components/common/Audio.tsx +++ b/src/components/common/Audio.tsx @@ -205,6 +205,7 @@ const Audio: FC = ({ message, uploadProgress || downloadProgress, isLoadingForPlaying || isDownloading, + uploadProgress !== undefined, ); const { diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 9b3bbfc1f..e812c0043 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -49,6 +49,7 @@ import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterd import { getAllowedAttachmentOptions, getStoryKey, + hasReplaceableMedia, isChatAdmin, isChatChannel, isChatSuperGroup, @@ -373,6 +374,7 @@ const Composer: FC = ({ openStoryReactionPicker, closeReactionPicker, sendStoryReaction, + editMessage, } = getActions(); const lang = useLang(); @@ -401,6 +403,8 @@ const Composer: FC = ({ const [isInputHasFocus, markInputHasFocus, unmarkInputHasFocus] = useFlag(); const [isAttachMenuOpen, onAttachMenuOpen, onAttachMenuClose] = useFlag(); + const canMediaBeReplaced = editingMessage && hasReplaceableMedia(editingMessage); + const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction ? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false; @@ -532,6 +536,7 @@ const Composer: FC = ({ canSendPhotos, canSendDocuments, insertNextText, + editedMessage: editingMessage, }); const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag(); @@ -891,16 +896,25 @@ const Composer: FC = ({ if (!validateTextLength(text, true)) return; if (!checkSlowMode()) return; - sendMessage({ - messageList: currentMessageList, - text, - entities, - scheduledAt, - isSilent, - shouldUpdateStickerSetOrder, - attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed), - shouldGroupMessages: sendGrouped, - }); + if (editingMessage) { + editMessage({ + messageList: currentMessageList, + text, + entities, + attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed), + }); + } else { + sendMessage({ + messageList: currentMessageList, + text, + entities, + scheduledAt, + isSilent, + shouldUpdateStickerSetOrder, + attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed), + shouldGroupMessages: sendGrouped, + }); + } lastMessageSendTimeSeconds.current = getServerTime(); @@ -1495,6 +1509,7 @@ const Composer: FC = ({ withQuick={dropAreaState === DropAreaState.QuickFile || prevDropAreaState === DropAreaState.QuickFile} onHide={onDropHide!} onFileSelect={handleFileSelect} + editingMessage={editingMessage} /> )} {shouldRenderReactionSelector && ( @@ -1535,6 +1550,7 @@ const Composer: FC = ({ onCustomEmojiSelect={handleCustomEmojiSelectAttachmentModal} onRemoveSymbol={removeSymbolAttachmentModal} onEmojiSelect={insertTextAndUpdateCursorAttachmentModal} + editingMessage={editingMessage} /> = ({ = ({ const { isUploading, isTransferring, transferProgress, - } = getMediaTransferState(message, uploadProgress || downloadProgress, shouldDownload && !isLoaded); + } = getMediaTransferState( + message, + uploadProgress || downloadProgress, + shouldDownload && !isLoaded, + uploadProgress !== undefined, + ); const hasPreview = getDocumentHasPreview(document); const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined; diff --git a/src/components/common/helpers/renderMessageText.ts b/src/components/common/helpers/renderMessageText.ts index a1523f878..e7d26e3c3 100644 --- a/src/components/common/helpers/renderMessageText.ts +++ b/src/components/common/helpers/renderMessageText.ts @@ -4,13 +4,13 @@ import type { TextPart } from '../../../types'; import { ApiMessageEntityTypes } from '../../../api/types'; import { - getMessageKey, getMessageSummaryDescription, getMessageSummaryEmoji, getMessageSummaryText, getMessageText, TRUNCATED_SUMMARY_LENGTH, } from '../../../global/helpers'; +import { getMessageKey } from '../../../util/messageKey'; import trimText from '../../../util/trimText'; import renderText from './renderText'; import { renderTextWithEntities } from './renderTextWithEntities'; diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 4f8fcd62d..f061ee06e 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -25,7 +25,6 @@ import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getChatTitle, getIsSavedDialog, - getMessageKey, getSenderTitle, isChatChannel, isChatSuperGroup, @@ -51,6 +50,7 @@ import { } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import cycleRestrict from '../../util/cycleRestrict'; +import { getMessageKey } from '../../util/messageKey'; import useAppLayout from '../../hooks/useAppLayout'; import useConnectionStatus from '../../hooks/useConnectionStatus'; diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index e77a06bc2..895932364 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -4,7 +4,7 @@ import React, { useMemo, } from '../../../lib/teact/teact'; -import type { ApiAttachMenuPeerType } from '../../../api/types'; +import type { ApiAttachMenuPeerType, ApiMessage } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; import type { ISettings, ThreadId } from '../../../types'; @@ -13,6 +13,13 @@ import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; +import { + getMessageAudio, getMessageDocument, + getMessagePhoto, + getMessageVideo, getMessageVoice, + getMessageWebPagePhoto, + getMessageWebPageVideo, +} from '../../../global/helpers'; import { getDebugLogs } from '../../../util/debugConsole'; import { validateFiles } from '../../../util/files'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; @@ -23,6 +30,7 @@ import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useMouseInside from '../../../hooks/useMouseInside'; +import Icon from '../../common/Icon'; import Menu from '../../ui/Menu'; import MenuItem from '../../ui/MenuItem'; import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; @@ -49,6 +57,8 @@ export type OwnProps = { onPollCreate: NoneToVoidFunction; onMenuOpen: NoneToVoidFunction; onMenuClose: NoneToVoidFunction; + hasReplaceableMedia?: boolean; + editingMessage?: ApiMessage; }; const AttachMenu: FC = ({ @@ -70,6 +80,8 @@ const AttachMenu: FC = ({ onMenuOpen, onMenuClose, onPollCreate, + hasReplaceableMedia, + editingMessage, }) => { const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag(); const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu); @@ -80,6 +92,12 @@ const AttachMenu: FC = ({ const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag(); const isMenuOpen = isAttachMenuOpen || isAttachmentBotMenuOpen; + const isPhotoOrVideo = editingMessage && editingMessage?.groupedId + && Boolean(getMessagePhoto(editingMessage) || getMessageWebPagePhoto(editingMessage) + || Boolean(getMessageVideo(editingMessage) || getMessageWebPageVideo(editingMessage))); + const isFile = editingMessage && editingMessage?.groupedId && Boolean(getMessageAudio(editingMessage) + || getMessageVoice(editingMessage) || getMessageDocument(editingMessage)); + useEffect(() => { if (isAttachMenuOpen) { markMouseInside(); @@ -152,18 +170,36 @@ const AttachMenu: FC = ({ return (
- - - + { + editingMessage && hasReplaceableMedia ? ( + + + + ) : ( + + + + ) + } = ({ )} {canAttachMedia && ( <> - {canSendVideoOrPhoto && ( + {canSendVideoOrPhoto && !isFile && ( {lang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo' : (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))} )} - {(canSendDocuments || canSendAudios) + {((canSendDocuments || canSendAudios) && !isPhotoOrVideo) && ( {lang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')} @@ -206,11 +242,11 @@ const AttachMenu: FC = ({ )} )} - {canAttachPolls && ( + {canAttachPolls && !editingMessage && ( {lang('Poll')} )} - {canAttachMedia && !isScheduled && bots?.map((bot) => ( + {!editingMessage && !hasReplaceableMedia && !isScheduled && bots?.map((bot) => ( ; canShowCustomSendMenu?: boolean; isReady: boolean; @@ -89,6 +91,7 @@ type StateProps = { currentUserId?: string; groupChatMembers?: ApiChatMember[]; recentEmojis: string[]; + editingMessage?: ApiMessage; baseEmojiKeywords?: Record; emojiKeywords?: Record; shouldSuggestCustomEmoji?: boolean; @@ -106,6 +109,7 @@ const AttachmentModal: FC = ({ threadId, attachments, getHtml, + editingMessage, canShowCustomSendMenu, captionLimit, isReady, @@ -150,12 +154,19 @@ const AttachmentModal: FC = ({ const renderingAttachments = attachments.length ? attachments : prevAttachments; const { isMobile } = useAppLayout(); + const isEditing = editingMessage && Boolean(editingMessage); + const isInAlbum = editingMessage && editingMessage?.groupedId; + const isEditingMessageFile = attachments?.length && getAttachmentType(attachments[0]); + const notEditingFile = isEditingMessageFile !== 'file'; + const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag(); const [shouldSendCompressed, setShouldSendCompressed] = useState( shouldSuggestCompression ?? attachmentSettings.shouldCompress, ); - const isSendingCompressed = Boolean((shouldSendCompressed || shouldForceCompression) && !shouldForceAsFile); + const isSendingCompressed = Boolean( + (shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile, + ); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const { @@ -261,7 +272,7 @@ const AttachmentModal: FC = ({ const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { if (isOpen) { - const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled + const send = ((shouldSchedule || shouldSendScheduled) && isForMessage && !editingMessage) ? onSendScheduled : isSilent ? onSendSilent : onSend; send(isSendingCompressed, shouldSendGrouped); updateAttachmentSettings({ @@ -425,13 +436,13 @@ const AttachmentModal: FC = ({ let title = ''; if (areAllPhotos) { - title = lang('PreviewSender.SendPhoto', renderingAttachments.length, 'i'); + title = lang(isEditing ? 'EditMessageReplacePhoto' : 'PreviewSender.SendPhoto', renderingAttachments.length, 'i'); } else if (areAllVideos) { - title = lang('PreviewSender.SendVideo', renderingAttachments.length, 'i'); + title = lang(isEditing ? 'EditMessageReplaceVideo' : 'PreviewSender.SendVideo', renderingAttachments.length, 'i'); } else if (areAllAudios) { - title = lang('PreviewSender.SendAudio', renderingAttachments.length, 'i'); + title = lang(isEditing ? 'EditMessageReplaceAudio' : 'PreviewSender.SendAudio', renderingAttachments.length, 'i'); } else { - title = lang('PreviewSender.SendFile', renderingAttachments.length, 'i'); + title = lang(isEditing ? 'EditMessageReplaceFile' : 'PreviewSender.SendFile', renderingAttachments.length, 'i'); } function renderHeader() { @@ -445,57 +456,62 @@ const AttachmentModal: FC = ({
{title}
- - {lang('Add')} - {hasMedia && ( - <> - { - !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( - // eslint-disable-next-line react/jsx-no-bind - setShouldSendCompressed(false)}> - {lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} + {notEditingFile && !isInAlbum + && ( + + {Boolean(!editingMessage) && ( + {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 && Boolean(!editingMessage) && ( + hasSpoiler ? ( + + {lang('Attachment.DisableSpoiler')} + + ) : ( + + {lang('Attachment.EnableSpoiler')} + + ) + )} + + )} + {isMultiple && ( + shouldSendGrouped ? ( + setShouldSendGrouped(false)} + > + Ungroup All Media ) : ( - // 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')} + // eslint-disable-next-line react/jsx-no-bind + setShouldSendGrouped(true)}> + Group All Media ) )} - + )} - {isMultiple && ( - shouldSendGrouped ? ( - setShouldSendGrouped(false)} - > - Ungroup All Media - - ) : ( - // eslint-disable-next-line react/jsx-no-bind - setShouldSendGrouped(true)}> - Group All Media - - ) - )} -
); } @@ -622,7 +638,7 @@ const AttachmentModal: FC = ({ onClick={handleSendClick} onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined} > - {shouldSchedule ? lang('Next') : lang('Send')} + {shouldSchedule && !editingMessage ? lang('Next') : editingMessage ? lang('Save') : lang('Send')} {canShowCustomSendMenu && ( = ({ ); }; -function getDisplayType(attachment: ApiAttachment, shouldDisplayCompressed?: boolean) { +export function getDisplayType(attachment: ApiAttachment, shouldDisplayCompressed?: boolean) { if (shouldDisplayCompressed && attachment.quick) { if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) { return 'image'; diff --git a/src/components/middle/composer/DropArea.tsx b/src/components/middle/composer/DropArea.tsx index b9f7e55f1..84439b8ad 100644 --- a/src/components/middle/composer/DropArea.tsx +++ b/src/components/middle/composer/DropArea.tsx @@ -1,10 +1,16 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; +import type { ApiMessage } from '../../../api/types'; + +import { canReplaceMessageMedia, isUploadingFileSticker } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; +import buildAttachment from './helpers/buildAttachment'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; +import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import usePrevious from '../../../hooks/usePrevious'; import useShowTransition from '../../../hooks/useShowTransition'; @@ -19,6 +25,7 @@ export type OwnProps = { withQuick?: boolean; onHide: NoneToVoidFunction; onFileSelect: (files: File[], suggestCompression?: boolean) => void; + editingMessage?: ApiMessage | undefined; }; export enum DropAreaState { @@ -30,12 +37,15 @@ export enum DropAreaState { const DROP_LEAVE_TIMEOUT_MS = 150; const DropArea: FC = ({ - isOpen, withQuick, onHide, onFileSelect, + isOpen, withQuick, onHide, onFileSelect, editingMessage, }) => { + const lang = useLang(); + const { showNotification } = getActions(); // eslint-disable-next-line no-null/no-null const hideTimeoutRef = useRef(null); const prevWithQuick = usePrevious(withQuick); const { shouldRender, transitionClassNames } = useShowTransition(isOpen); + const isInAlbum = editingMessage && editingMessage?.groupedId; useEffect(() => (isOpen ? captureEscKeyListener(onHide) : undefined), [isOpen, onHide]); @@ -47,6 +57,14 @@ const DropArea: FC = ({ files = files.concat(Array.from(dt.files)); } else if (dt.items && dt.items.length > 0) { const folderFiles = await getFilesFromDataTransferItems(dt.items); + const newAttachment = folderFiles && await buildAttachment(folderFiles[0].name, folderFiles[0]); + const canReplace = editingMessage && newAttachment && canReplaceMessageMedia(editingMessage, newAttachment); + const isFileSticker = newAttachment && isUploadingFileSticker(newAttachment); + + if (canReplace || isFileSticker) { + showNotification({ message: lang(isInAlbum ? 'lng_edit_media_album_error' : 'lng_edit_media_invalid_file') }); + return; + } if (folderFiles?.length) { files = files.concat(folderFiles); } diff --git a/src/components/middle/composer/hooks/useAttachmentModal.ts b/src/components/middle/composer/hooks/useAttachmentModal.ts index eb07ab496..44c3a8974 100644 --- a/src/components/middle/composer/hooks/useAttachmentModal.ts +++ b/src/components/middle/composer/hooks/useAttachmentModal.ts @@ -1,16 +1,13 @@ import { useState } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; -import type { ApiAttachment } from '../../../../api/types'; +import type { ApiAttachment, ApiMessage } from '../../../../api/types'; -import { - SUPPORTED_AUDIO_CONTENT_TYPES, - SUPPORTED_IMAGE_CONTENT_TYPES, - SUPPORTED_VIDEO_CONTENT_TYPES, -} from '../../../../config'; +import { canReplaceMessageMedia, getAttachmentType } from '../../../../global/helpers'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import buildAttachment from '../helpers/buildAttachment'; +import useLang from '../../../../hooks/useLang'; import useLastCallback from '../../../../hooks/useLastCallback'; export default function useAttachmentModal({ @@ -24,6 +21,7 @@ export default function useAttachmentModal({ canSendPhotos, canSendDocuments, insertNextText, + editedMessage, }: { attachments: ApiAttachment[]; fileSizeLimit: number; @@ -35,8 +33,10 @@ export default function useAttachmentModal({ canSendPhotos?: boolean; canSendDocuments?: boolean; insertNextText: VoidFunction; + editedMessage: ApiMessage | undefined; }) { - const { openLimitReachedModal, showAllowedMessageTypesNotification } = getActions(); + const lang = useLang(); + const { openLimitReachedModal, showAllowedMessageTypesNotification, showNotification } = getActions(); const [shouldForceAsFile, setShouldForceAsFile] = useState(false); const [shouldForceCompression, setShouldForceCompression] = useState(false); const [shouldSuggestCompression, setShouldSuggestCompression] = useState(undefined); @@ -85,16 +85,45 @@ export default function useAttachmentModal({ ); const handleAppendFiles = useLastCallback(async (files: File[], isSpoiler?: boolean) => { - handleSetAttachments([ - ...attachments, - ...await Promise.all(files.map((file) => ( + if (editedMessage) { + const newAttachment = await buildAttachment(files[0].name, files[0]); + const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment); + + if (editedMessage?.groupedId) { + if (canReplace) { + handleSetAttachments([newAttachment]); + } else { + showNotification({ message: lang('lng_edit_media_album_error') }); + } + } else { + handleSetAttachments([newAttachment]); + } + } else { + const newAttachments = await Promise.all(files.map((file) => ( buildAttachment(file.name, file, { shouldSendAsSpoiler: isSpoiler || undefined }) - ))), - ]); + ))); + handleSetAttachments([...attachments, ...newAttachments]); + } }); const handleFileSelect = useLastCallback(async (files: File[], suggestCompression?: boolean) => { - handleSetAttachments(await Promise.all(files.map((file) => buildAttachment(file.name, file)))); + if (editedMessage) { + const newAttachment = await buildAttachment(files[0].name, files[0]); + const canReplace = editedMessage && canReplaceMessageMedia(editedMessage, newAttachment); + + if (editedMessage?.groupedId) { + if (canReplace) { + handleSetAttachments([newAttachment]); + } else { + showNotification({ message: lang('lng_edit_media_album_error') }); + } + } else { + handleSetAttachments([newAttachment]); + } + } else { + const newAttachments = await Promise.all(files.map((file) => buildAttachment(file.name, file))); + handleSetAttachments(newAttachments); + } setShouldSuggestCompression(suggestCompression); }); @@ -109,21 +138,3 @@ export default function useAttachmentModal({ shouldForceAsFile, }; } - -function getAttachmentType(attachment: ApiAttachment) { - if (attachment.shouldSendAsFile) return 'file'; - - if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) { - return 'image'; - } - - if (SUPPORTED_VIDEO_CONTENT_TYPES.has(attachment.mimeType)) { - return 'video'; - } - - if (SUPPORTED_AUDIO_CONTENT_TYPES.has(attachment.mimeType)) { - return 'audio'; - } - - return 'file'; -} diff --git a/src/components/middle/composer/hooks/useClipboardPaste.ts b/src/components/middle/composer/hooks/useClipboardPaste.ts index fc22d1479..a2667505e 100644 --- a/src/components/middle/composer/hooks/useClipboardPaste.ts +++ b/src/components/middle/composer/hooks/useClipboardPaste.ts @@ -1,17 +1,21 @@ import type { StateHookSetter } from '../../../../lib/teact/teact'; import { useEffect } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; import type { ApiAttachment, ApiFormattedText, ApiMessage } from '../../../../api/types'; import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID, } from '../../../../config'; +import { canReplaceMessageMedia, isUploadingFileSticker } from '../../../../global/helpers'; import { containsCustomEmoji, stripCustomEmoji } from '../../../../global/helpers/symbols'; import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText'; import buildAttachment from '../helpers/buildAttachment'; import { preparePastedHtml } from '../helpers/cleanHtml'; import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems'; +import useLang from '../../../../hooks/useLang'; + const MAX_MESSAGE_LENGTH = 4096; const TYPE_HTML = 'text/html'; @@ -27,6 +31,9 @@ const useClipboardPaste = ( shouldStripCustomEmoji?: boolean, onCustomEmojiStripped?: VoidFunction, ) => { + const { showNotification } = getActions(); + const lang = useLang(); + useEffect(() => { if (!isActive) { return undefined; @@ -60,6 +67,9 @@ const useClipboardPaste = ( e.preventDefault(); if (items.length > 0) { files = await getFilesFromDataTransferItems(items); + if (editedMessage) { + files = files?.slice(0, 1); + } } if (!files?.length && !pastedText) { @@ -79,13 +89,29 @@ const useClipboardPaste = ( } const hasText = textToPaste && textToPaste.text; - const shouldSetAttachments = files?.length && !editedMessage && !isWordDocument; + let shouldSetAttachments = files?.length && !isWordDocument; + + const newAttachments = files ? await Promise.all(files.map((file) => buildAttachment(file.name, file))) : []; + const canReplace = (editedMessage && newAttachments?.length + && canReplaceMessageMedia(editedMessage, newAttachments[0])) || Boolean(hasText); + const isUploadingDocumentSticker = isUploadingFileSticker(newAttachments[0]); + const isInAlbum = editedMessage && editedMessage?.groupedId; + + if (editedMessage && isUploadingDocumentSticker) { + showNotification({ message: lang(isInAlbum ? 'lng_edit_media_album_error' : 'lng_edit_media_invalid_file') }); + return; + } + + if (isInAlbum) { + shouldSetAttachments = canReplace; + if (!shouldSetAttachments) { + showNotification({ message: lang('lng_edit_media_album_error') }); + return; + } + } if (shouldSetAttachments) { - const newAttachments = await Promise.all(files!.map((file) => { - return buildAttachment(file.name, file); - })); - setAttachments((attachments) => attachments.concat(newAttachments)); + setAttachments(editedMessage ? newAttachments : (attachments) => attachments.concat(newAttachments)); } if (hasText) { @@ -103,8 +129,8 @@ const useClipboardPaste = ( document.removeEventListener('paste', handlePaste, false); }; }, [ - insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, onCustomEmojiStripped, - setNextText, + insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, + onCustomEmojiStripped, setNextText, lang, ]); }; diff --git a/src/components/middle/message/Album.tsx b/src/components/middle/message/Album.tsx index 37d424efe..41652f440 100644 --- a/src/components/middle/message/Album.tsx +++ b/src/components/middle/message/Album.tsx @@ -8,13 +8,14 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { IAlbum, ISettings } from '../../../types'; import type { IAlbumLayout } from './helpers/calculateAlbumLayout'; -import { getMessageContent, getMessageHtmlId, getMessageOriginalId } from '../../../global/helpers'; +import { getMessageContent, getMessageHtmlId } from '../../../global/helpers'; import { selectActiveDownloads, selectCanAutoLoadMedia, selectCanAutoPlayMedia, selectTheme, } from '../../../global/selectors'; +import { getMessageKey } from '../../../util/messageKey'; import { AlbumRectPart } from './helpers/calculateAlbumLayout'; import withSelectControl from './hocs/withSelectControl'; @@ -40,7 +41,7 @@ type OwnProps = { type StateProps = { theme: ISettings['theme']; - uploadsById: GlobalState['fileUploads']['byMessageLocalId']; + uploadsByKey: GlobalState['fileUploads']['byMessageKey']; activeDownloadIds?: number[]; }; @@ -52,21 +53,21 @@ const Album: FC = ({ isProtected, albumLayout, onMediaClick, - uploadsById, + uploadsByKey, activeDownloadIds, theme, }) => { - const { cancelSendingMessage } = getActions(); + const { cancelUploadMedia } = getActions(); const mediaCount = album.messages.length; const handleCancelUpload = useLastCallback((message: ApiMessage) => { - cancelSendingMessage({ chatId: message.chatId, messageId: message.id }); + cancelUploadMedia({ chatId: message.chatId, messageId: message.id }); }); function renderAlbumMessage(message: ApiMessage, index: number) { const { photo, video } = getMessageContent(message); - const fileUpload = uploadsById[getMessageOriginalId(message)]; + const fileUpload = uploadsByKey[getMessageKey(message)]; const uploadProgress = fileUpload?.progress; const { dimensions, sides } = albumLayout.layout[index]; @@ -139,7 +140,7 @@ export default withGlobal( return { theme, - uploadsById: global.fileUploads.byMessageLocalId, + uploadsByKey: global.fileUploads.byMessageKey, activeDownloadIds: isScheduled ? activeDownloads?.scheduledIds : activeDownloads?.ids, }; }, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a6c32ef23..af49c4457 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -37,7 +37,6 @@ import { getMessageContent, getMessageCustomShape, getMessageHtmlId, - getMessageKey, getMessageSingleCustomEmoji, getMessageSingleRegularEmoji, getSenderTitle, @@ -99,6 +98,7 @@ import { import { isAnimatingScroll } from '../../../util/animateScroll'; import buildClassName from '../../../util/buildClassName'; import { isElementInViewport } from '../../../util/isElementInViewport'; +import { getMessageKey } from '../../../util/messageKey'; import stopEvent from '../../../util/stopEvent'; import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import { diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 1830acfad..90d9ae315 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { useRef, useState } from '../../../lib/teact/teact'; +import React, { useEffect, useRef, useState } from '../../../lib/teact/teact'; import type { ApiMessage } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; @@ -102,7 +102,15 @@ const Photo: FC = ({ const thumbClassNames = useMediaTransition(!noThumb); const thumbDataUri = getMessageMediaThumbDataUri(message); - const [isSpoilerShown, , hideSpoiler] = useFlag(photo.isSpoiler); + const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(photo.isSpoiler); + + useEffect(() => { + if (photo.isSpoiler) { + showSpoiler(); + } else { + hideSpoiler(); + } + }, [photo.isSpoiler]); const { loadProgress: downloadProgress, @@ -116,6 +124,7 @@ const Photo: FC = ({ message, uploadProgress || (isDownloading ? downloadProgress : loadProgress), shouldLoad && !fullMediaData, + uploadProgress !== undefined, ); const wasLoadDisabled = usePrevious(isLoadAllowed) === false; diff --git a/src/components/middle/message/ReactionButton.tsx b/src/components/middle/message/ReactionButton.tsx index 66c145d65..0faa758ac 100644 --- a/src/components/middle/message/ReactionButton.tsx +++ b/src/components/middle/message/ReactionButton.tsx @@ -7,8 +7,9 @@ import type { } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import { getMessageKey, isReactionChosen, isSameReaction } from '../../../global/helpers'; +import { isReactionChosen, isSameReaction } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; +import { getMessageKey } from '../../../util/messageKey'; import { formatIntegerCompact } from '../../../util/textFormat'; import { REM } from '../../common/helpers/mediaDimensions'; diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index 61448a5f9..e7c865f82 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { useRef, useState } from '../../../lib/teact/teact'; +import React, { useEffect, useRef, useState } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiMessage } from '../../../api/types'; @@ -80,7 +80,15 @@ const Video: FC = ({ const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!; const localBlobUrl = video.blobUrl; - const [isSpoilerShown, , hideSpoiler] = useFlag(video.isSpoiler); + const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(video.isSpoiler); + + useEffect(() => { + if (video.isSpoiler) { + showSpoiler(); + } else { + hideSpoiler(); + } + }, [video.isSpoiler]); const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading); const isIntersectingForPlaying = ( @@ -133,6 +141,7 @@ const Video: FC = ({ message, uploadProgress || (isDownloading ? downloadProgress : loadProgress), (shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading, + uploadProgress !== undefined, ); const wasLoadDisabled = usePrevious(isLoadAllowed) === false; diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index 3440c25ca..c3448992e 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -34,7 +34,7 @@ export default function useInnerHandlers( ) { const { openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, - markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, + markMessagesRead, cancelUploadMedia, sendPollVote, openForwardMenu, openChatLanguageModal, openThread, openStoryViewer, } = getActions(); @@ -121,7 +121,7 @@ export default function useInnerHandlers( }); const handleCancelUpload = useLastCallback(() => { - cancelSendingMessage({ chatId, messageId }); + cancelUploadMedia({ chatId, messageId }); }); const handleVoteSend = useLastCallback((options: string[]) => { diff --git a/src/config.ts b/src/config.ts index b936874d7..4ec977dc9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -219,6 +219,9 @@ export const SLIDE_TRANSITION_DURATION = 450; export const VIDEO_WEBM_TYPE = 'video/webm'; export const GIF_MIME_TYPE = 'image/gif'; +export const LOTTIE_STICKER_MIME_TYPE = 'application/x-tgsticker'; +export const VIDEO_STICKER_MIME_TYPE = 'video/webm'; + export const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([ 'image/png', 'image/jpeg', GIF_MIME_TYPE, ]); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 6f25531d7..8609f3ccc 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -15,6 +15,7 @@ import type { ApiStorySkipped, ApiVideo, } from '../../../api/types'; +import type { MessageKey } from '../../../util/messageKey'; import type { RequiredGlobalActions } from '../../index'; import type { ActionReturnType, ApiDraft, GlobalState, TabArgs, @@ -36,17 +37,20 @@ import { isDeepLink } from '../../../util/deepLinkParser'; import { ensureProtocol } from '../../../util/ensureProtocol'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { - areSortedArraysIntersecting, buildCollectionByKey, omit, partition, split, unique, + areSortedArraysIntersecting, + buildCollectionByKey, + omit, + partition, + split, + unique, } from '../../../util/iteratees'; import { translate } from '../../../util/langProvider'; -import { - debounce, onTickEnd, rafPromise, -} from '../../../util/schedulers'; +import { getMessageKey } from '../../../util/messageKey'; +import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers'; import { IS_IOS } from '../../../util/windowEnvironment'; import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { getIsSavedDialog, - getMessageOriginalId, getUserFullName, isChatChannel, isDeletedUser, @@ -81,6 +85,7 @@ import { updateThreadInfo, updateThreadUnreadFromForwardedMessage, updateTopic, + updateUploadByMessageKey, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; import { @@ -125,7 +130,7 @@ import { deleteMessages } from '../apiUpdaters/messages'; const AUTOLOGIN_TOKEN_KEY = 'autologin_token'; -const uploadProgressCallbacks = new Map(); +const uploadProgressCallbacks = new Map(); const runDebouncedForMarkRead = debounce((cb) => cb(), 500, false); @@ -426,13 +431,25 @@ addActionHandler('sendInviteMessages', async (global, actions, payload): Promise addActionHandler('editMessage', (global, actions, payload): ActionReturnType => { const { - messageList, text, entities, tabId = getCurrentTabId(), + messageList, text, entities, attachments, tabId = getCurrentTabId(), } = payload; if (!messageList) { return; } + let currentMessageKey: MessageKey | undefined; + const progressCallback = attachments ? (progress: number, messageKey: MessageKey) => { + if (!uploadProgressCallbacks.has(messageKey)) { + currentMessageKey = messageKey; + uploadProgressCallbacks.set(messageKey, progressCallback!); + } + + global = getGlobal(); + global = updateUploadByMessageKey(global, messageKey, progress); + setGlobal(global); + } : undefined; + const { chatId, threadId, type: messageListType } = messageList; const chat = selectChat(global, chatId); const message = selectEditingMessage(global, chatId, threadId, messageListType); @@ -440,26 +457,46 @@ addActionHandler('editMessage', (global, actions, payload): ActionReturnType => return; } - void callApi('editMessage', { - chat, message, text, entities, noWebPage: selectNoWebPage(global, chatId, threadId), - }); - actions.setEditingId({ messageId: undefined, tabId }); + + (async () => { + await callApi('editMessage', { + chat, + message, + attachment: attachments ? attachments[0] : undefined, + text, + entities, + noWebPage: selectNoWebPage(global, chatId, threadId), + }, progressCallback); + + if (progressCallback && currentMessageKey) { + global = getGlobal(); + global = updateUploadByMessageKey(global, currentMessageKey, undefined); + setGlobal(global); + + uploadProgressCallbacks.delete(currentMessageKey); + } + })(); }); -addActionHandler('cancelSendingMessage', (global, actions, payload): ActionReturnType => { +addActionHandler('cancelUploadMedia', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload!; + const message = selectChatMessage(global, chatId, messageId); - const progressCallback = message && uploadProgressCallbacks.get(getMessageOriginalId(message)); + if (!message) return; + + const progressCallback = message && uploadProgressCallbacks.get(getMessageKey(message)); if (progressCallback) { cancelApiProgress(progressCallback); } - actions.apiUpdate({ - '@type': 'deleteMessages', - ids: [messageId], - chatId, - }); + if (isMessageLocal(message)) { + actions.apiUpdate({ + '@type': 'deleteMessages', + ids: [messageId], + chatId, + }); + } }); addActionHandler('saveDraft', (global, actions, payload): ActionReturnType => { @@ -1349,24 +1386,15 @@ async function sendMessage(global: T, params: { wasDrafted?: boolean; lastMessageId?: number; }) { - let localId: number | undefined; - const progressCallback = params.attachment ? (progress: number, messageLocalId: number) => { - if (!uploadProgressCallbacks.has(messageLocalId)) { - localId = messageLocalId; - uploadProgressCallbacks.set(messageLocalId, progressCallback!); + let currentMessageKey: MessageKey | undefined; + const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => { + if (!uploadProgressCallbacks.has(messageKey)) { + currentMessageKey = messageKey; + uploadProgressCallbacks.set(messageKey, progressCallback!); } global = getGlobal(); - - global = { - ...global, - fileUploads: { - byMessageLocalId: { - ...global.fileUploads.byMessageLocalId, - [messageLocalId]: { progress }, - }, - }, - }; + global = updateUploadByMessageKey(global, messageKey, progress); setGlobal(global); } : undefined; @@ -1377,8 +1405,12 @@ async function sendMessage(global: T, params: { await callApi('sendMessage', params, progressCallback); - if (progressCallback && localId) { - uploadProgressCallbacks.delete(localId); + if (progressCallback && currentMessageKey) { + global = getGlobal(); + global = updateUploadByMessageKey(global, currentMessageKey, undefined); + setGlobal(global); + + uploadProgressCallbacks.delete(currentMessageKey); } } diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 9fefbc509..05be89a8e 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -5,11 +5,11 @@ import { GENERAL_REFETCH_INTERVAL } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey, omit } from '../../../util/iteratees'; import * as mediaLoader from '../../../util/mediaLoader'; +import { getMessageKey } from '../../../util/messageKey'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { callApi } from '../../../api/gramjs'; import { getDocumentMediaHash, - getMessageKey, getUserReactions, isMessageLocal, isSameReaction, diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 74dad1550..2ecaf7dd0 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -12,10 +12,11 @@ import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; import { areDeepEqual } from '../../../util/areDeepEqual'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { omit, pickTruthy, unique } from '../../../util/iteratees'; +import { getMessageKey } from '../../../util/messageKey'; import { notifyAboutMessage } from '../../../util/notifications'; import { onTickEnd } from '../../../util/schedulers'; import { - checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage, + checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage, isLocalMessageId, isMessageLocal, isUserId, } from '../../helpers'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies'; @@ -278,7 +279,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = { ...global, fileUploads: { - byMessageLocalId: omit(global.fileUploads.byMessageLocalId, [localId.toString()]), + byMessageKey: omit(global.fileUploads.byMessageKey, [getMessageKey(message)]), }, }; @@ -763,19 +764,20 @@ function updateWithLocalMedia( : selectChatMessage(global, chatId, id); // Preserve locally uploaded media. - if (currentMessage && messageUpdate.content) { + if (currentMessage && messageUpdate.content && !isLocalMessageId(id)) { const { photo, video, sticker, document, } = getMessageContent(currentMessage); + if (photo && messageUpdate.content.photo) { - messageUpdate.content.photo.blobUrl = photo.blobUrl; - messageUpdate.content.photo.thumbnail = photo.thumbnail; + messageUpdate.content.photo.blobUrl ??= photo.blobUrl; + messageUpdate.content.photo.thumbnail ??= photo.thumbnail; } else if (video && messageUpdate.content.video) { - messageUpdate.content.video.blobUrl = video.blobUrl; + messageUpdate.content.video.blobUrl ??= video.blobUrl; } else if (sticker && messageUpdate.content.sticker) { - messageUpdate.content.sticker.isPreloadedGlobally = sticker.isPreloadedGlobally; + messageUpdate.content.sticker.isPreloadedGlobally ??= sticker.isPreloadedGlobally; } else if (document && messageUpdate.content.document) { - messageUpdate.content.document.previewBlobUrl = document.previewBlobUrl; + messageUpdate.content.document.previewBlobUrl ??= document.previewBlobUrl; } } diff --git a/src/global/cache.ts b/src/global/cache.ts index d90eded2b..eb4ee2f0a 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -1,6 +1,7 @@ /* eslint-disable eslint-multitab-tt/no-immediate-global */ import { addCallback, removeCallback } from '../lib/teact/teactn'; +import type { ApiMessage } from '../api/types'; import type { ActionReturnType, GlobalState, MessageList } from './types'; import { MAIN_THREAD_ID } from '../api/types'; @@ -219,6 +220,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { untypedCached.appConfig.peerColors = undefined; untypedCached.appConfig.darkPeerColors = undefined; } + + if (!cached.fileUploads.byMessageKey) { + cached.fileUploads.byMessageKey = {}; + } } function updateCache() { @@ -458,8 +463,16 @@ function reduceMessages(global: T): GlobalState['messages return acc; }, {} as GlobalState['messages']['byChatId'][string]['threadsById']); + const cleanedById = Object.values(byId).reduce((acc, message) => { + if (!message) return acc; + + const cleanedMessage = omitLocalMedia(message); + acc[message.id] = cleanedMessage; + return acc; + }, {} as Record); + byChatId[chatId] = { - byId, + byId: cleanedById, threadsById, }; }); @@ -470,6 +483,33 @@ function reduceMessages(global: T): GlobalState['messages }; } +function omitLocalMedia(message: ApiMessage): ApiMessage { + const { + photo, video, document, sticker, + } = message.content; + + if (photo) { + photo.blobUrl = undefined; + } + + if (video) { + video.blobUrl = undefined; + video.previewBlobUrl = undefined; + } + + if (document) { + document.previewBlobUrl = undefined; + } + + if (sticker) { + sticker.isPreloadedGlobally = undefined; + } + + message.previousLocalId = undefined; + + return message; +} + function reduceSettings(global: T): GlobalState['settings'] { const { byKey, themes, performance } = global.settings; diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index 64acdc193..b780eb31c 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -1,4 +1,5 @@ import type { + ApiAttachment, ApiAudio, ApiDimensions, ApiDocument, @@ -13,6 +14,7 @@ import type { } from '../../api/types'; import { ApiMediaFormat } from '../../api/types'; +import { getMessageKey } from '../../util/messageKey'; import { IS_OPFS_SUPPORTED, IS_OPUS_SUPPORTED, @@ -21,7 +23,7 @@ import { MAX_BUFFER_SIZE, } from '../../util/windowEnvironment'; import { getDocumentHasPreview } from '../../components/common/helpers/documentInfo'; -import { getMessageKey, isMessageLocal, matchLinkInMessageText } from './messages'; +import { getAttachmentType, matchLinkInMessageText } from './messages'; type MediaContainer = { content: MediaContent; @@ -53,6 +55,17 @@ export function hasMessageMedia(message: MediaContainer) { )); } +export function hasReplaceableMedia(message: MediaContainer) { + const video = getMessageVideo(message); + return Boolean(( + getMessagePhoto(message) + || (video && !video?.isRound) + || getMessageDocument(message) + || getMessageSticker(message) + || getMessageAudio(message) + )); +} + export function getMessagePhoto(message: MediaContainer) { return message.content.photo; } @@ -101,6 +114,11 @@ export function isMessageDocumentVideo(message: MediaContainer) { return document ? document.mediaType === 'video' : undefined; } +export function isMessageDocumentSticker(message: MediaContainer) { + const document = getMessageDocument(message); + return document ? document.mimeType === 'image/webp' : undefined; +} + export function getMessageContact(message: MediaContainer) { return message.content.contact; } @@ -423,8 +441,9 @@ export function getVideoDimensions(video: ApiVideo): ApiDimensions | undefined { return undefined; } -export function getMediaTransferState(message: ApiMessage, progress?: number, isLoadNeeded = false) { - const isUploading = isMessageLocal(message); +export function getMediaTransferState( + message: ApiMessage, progress?: number, isLoadNeeded = false, isUploading = false, +) { const isTransferring = isUploading || isLoadNeeded; const transferProgress = Number(progress); @@ -499,3 +518,18 @@ export function getMediaDuration(message: ApiMessage) { return media.duration; } + +export function canReplaceMessageMedia(message: ApiMessage, attachment: ApiAttachment) { + const isPhotoOrVideo = Boolean(getMessagePhoto(message) + || getMessageWebPagePhoto(message) || Boolean(getMessageVideo(message) + || getMessageWebPageVideo(message))); + const isFile = Boolean(getMessageAudio(message) + || getMessageVoice(message) || getMessageDocument(message)); + + const fileType = getAttachmentType(attachment); + + return ( + (isPhotoOrVideo && (fileType === 'image' || fileType === 'video')) + || (isFile && (fileType === 'audio' || fileType === 'file')) + ); +} diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 013a57907..57448d522 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -1,15 +1,17 @@ import type { - ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiPeer, ApiStory, ApiUser, + ApiAttachment, ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiPeer, ApiStory, ApiUser, } from '../../api/types'; import type { LangFn } from '../../hooks/useLang'; import { ApiMessageEntityTypes } from '../../api/types'; import { - CONTENT_NOT_SUPPORTED, + CONTENT_NOT_SUPPORTED, LOTTIE_STICKER_MIME_TYPE, RE_LINK_TEMPLATE, - SERVICE_NOTIFICATIONS_USER_ID, + SERVICE_NOTIFICATIONS_USER_ID, SUPPORTED_AUDIO_CONTENT_TYPES, + SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_STICKER_MIME_TYPE, } from '../../config'; import { areSortedArraysIntersecting, unique } from '../../util/iteratees'; +import { getMessageKey } from '../../util/messageKey'; import { getServerTime } from '../../util/serverTime'; import { IS_OPUS_SUPPORTED } from '../../util/windowEnvironment'; import { getGlobal } from '../index'; @@ -18,28 +20,10 @@ import { getUserFullName } from './users'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); -export type MessageKey = `msg${string}-${number}`; - export function getMessageHtmlId(messageId: number) { return `message${messageId.toString().replace('.', '-')}`; } -export function getMessageKey(message: ApiMessage): MessageKey { - const { chatId, id, previousLocalId } = message; - - return buildMessageKey(chatId, isServiceNotificationMessage(message) ? previousLocalId || id : id); -} - -export function buildMessageKey(chatId: string, msgId: number): MessageKey { - return `msg${chatId}-${msgId}`; -} - -export function parseMessageKey(key: MessageKey) { - const match = key.match(/^msg(-?\d+)-(\d+)/)!; - - return { chatId: match[1], messageId: Number(match[2]) }; -} - export function getMessageOriginalId(message: ApiMessage) { return message.previousLocalId || message.id; } @@ -358,3 +342,26 @@ export function hasMessageTtl(message: ApiMessage) { export function isJoinedChannelMessage(message: ApiMessage) { return message.content.action && message.content.action.type === 'joinedChannel'; } + +export function getAttachmentType(attachment: ApiAttachment) { + if (attachment.shouldSendAsFile) return 'file'; + + if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) { + return 'image'; + } + + if (SUPPORTED_VIDEO_CONTENT_TYPES.has(attachment.mimeType)) { + return 'video'; + } + + if (SUPPORTED_AUDIO_CONTENT_TYPES.has(attachment.mimeType)) { + return 'audio'; + } + + return 'file'; +} + +export function isUploadingFileSticker(attachment: ApiAttachment) { + return attachment ? (attachment.mimeType === 'image/webp' || attachment.mimeType === LOTTIE_STICKER_MIME_TYPE + || attachment.mimeType === VIDEO_STICKER_MIME_TYPE) : undefined; +} diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 9e878aac1..50b26b39f 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -144,7 +144,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { }, fileUploads: { - byMessageLocalId: {}, + byMessageKey: {}, }, recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'], diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 2f184f40f..96bed5bfe 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -1,16 +1,13 @@ -import type { - ApiMessage, ApiSponsoredMessage, ApiThreadInfo, -} from '../../api/types'; +import type { ApiMessage, ApiSponsoredMessage, ApiThreadInfo } from '../../api/types'; import type { FocusDirection, ThreadId } from '../../types'; +import type { MessageKey } from '../../util/messageKey'; import type { - GlobalState, MessageList, MessageListType, TabArgs, TabThread, - Thread, + GlobalState, MessageList, MessageListType, TabArgs, TabThread, Thread, } from '../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { - IS_MOCKED_CLIENT, - IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID, + IS_MOCKED_CLIENT, IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID, } from '../../config'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { @@ -32,7 +29,9 @@ import { selectOutlyingLists, selectPinnedIds, selectScheduledIds, - selectTabState, selectThreadIdFromMessage, selectThreadInfo, + selectTabState, + selectThreadIdFromMessage, + selectThreadInfo, selectViewportIds, } from '../selectors'; import { updateTabState } from './tabs'; @@ -788,3 +787,21 @@ export function cancelMessageMediaDownload( return global; } + +export function updateUploadByMessageKey( + global: T, + messageKey: MessageKey, + progress: number | undefined, +) { + return { + ...global, + fileUploads: { + byMessageKey: progress !== undefined + ? { + ...global.fileUploads.byMessageKey, + [messageKey]: { progress }, + } + : omit(global.fileUploads.byMessageKey, [messageKey]), + }, + }; +} diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index d7a405ed9..f6199fc8c 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -22,6 +22,7 @@ import { import { getCurrentTabId } from '../../util/establishMultitabRole'; import { findLast } from '../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; +import { getMessageKey } from '../../util/messageKey'; import { getServerTime } from '../../util/serverTime'; import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment'; import { @@ -32,9 +33,7 @@ import { getIsSavedDialog, getMainUsername, getMessageAudio, - getMessageDocument, - getMessageOriginalId, - getMessagePhoto, + getMessageDocument, getMessagePhoto, getMessageVideo, getMessageVoice, getMessageWebPagePhoto, @@ -48,7 +47,10 @@ import { isChatSuperGroup, isCommonBoxChat, isForwardedMessage, - isLocalMessageId, isMessageFailed, isMessageLocal, + isLocalMessageId, + isMessageDocumentSticker, + isMessageFailed, + isMessageLocal, isMessageTranslatable, isOwnMessage, isServiceNotificationMessage, @@ -588,6 +590,7 @@ export function selectAllowedMessageActions(global: T, me const hasTtl = hasMessageTtl(message); const { content } = message; const messageTopic = selectTopicFromMessage(global, message); + const isDocumentSticker = isMessageDocumentSticker(message); const canEditMessagesIndefinitely = isChatWithSelf || (isSuperGroup && getHasAdminRight(chat, 'pinMessages')) @@ -597,8 +600,9 @@ export function selectAllowedMessageActions(global: T, me canEditMessagesIndefinitely || getServerTime() - message.date < MESSAGE_EDIT_ALLOWED_TIME ) && !( - content.sticker || content.contact || content.poll || content.action || content.audio + content.sticker || content.contact || content.poll || content.action || (content.video?.isRound) || content.location || content.invoice || content.giveaway || content.giveawayResults + || isDocumentSticker ) && !isForwarded && !message.viaBotId @@ -801,7 +805,7 @@ export function selectActiveDownloads( } export function selectUploadProgress(global: T, message: ApiMessage) { - return global.fileUploads.byMessageLocalId[getMessageOriginalId(message)]?.progress; + return global.fileUploads.byMessageKey[getMessageKey(message)]?.progress; } export function selectRealLastReadId(global: T, chatId: string, threadId: ThreadId) { diff --git a/src/global/types.ts b/src/global/types.ts index ecfaa4988..a6944b403 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -865,7 +865,7 @@ export type GlobalState = { phoneCall?: ApiPhoneCall; fileUploads: { - byMessageLocalId: Record; }; @@ -1352,7 +1352,7 @@ export interface ActionPayloads { chatId: string; userIds: string[]; } & WithTabId; - cancelSendingMessage: { + cancelUploadMedia: { chatId: string; messageId: number; }; @@ -1382,8 +1382,9 @@ export interface ActionPayloads { }; }; editMessage: { - messageList: MessageList; + messageList?: MessageList; text: string; + attachments?: ApiAttachment[]; entities?: ApiMessageEntity[]; } & WithTabId; deleteHistory: { diff --git a/src/util/audio.ts b/src/util/audio.ts index 12429f5d3..0ce63fc8f 100644 --- a/src/util/audio.ts +++ b/src/util/audio.ts @@ -11,7 +11,8 @@ export async function parseAudioMetadata(url: string): Promise { const { common: { title, artist, picture }, format: { duration } } = metadata; const cover = selectCover(picture); - const coverUrl = cover ? `data:${cover.format};base64,${cover.data.toString('base64')}` : undefined; + const coverBlob = cover ? new Blob([cover.data], { type: cover.format }) : undefined; + const coverUrl = coverBlob ? URL.createObjectURL(coverBlob) : undefined; return { title, diff --git a/src/util/audioPlayer.ts b/src/util/audioPlayer.ts index 92e124769..429bf0595 100644 --- a/src/util/audioPlayer.ts +++ b/src/util/audioPlayer.ts @@ -1,12 +1,12 @@ import { getActions, getGlobal } from '../global'; import type { ApiMessage } from '../api/types'; -import type { MessageKey } from '../global/helpers'; +import type { MessageKey } from './messageKey'; import { AudioOrigin, GlobalSearchContent } from '../types'; import { requestNextMutation } from '../lib/fasterdom/fasterdom'; -import { getMessageKey, parseMessageKey } from '../global/helpers'; import { selectCurrentMessageList, selectTabState } from '../global/selectors'; +import { getMessageKey, parseMessageKey } from './messageKey'; import { isSafariPatchInProgress, patchSafariProgressiveAudio } from './patchSafariProgressiveAudio'; import safePlay from './safePlay'; import { IS_SAFARI } from './windowEnvironment'; diff --git a/src/util/getReadableErrorText.ts b/src/util/getReadableErrorText.ts index 7ebe0626f..46123aff1 100644 --- a/src/util/getReadableErrorText.ts +++ b/src/util/getReadableErrorText.ts @@ -16,6 +16,9 @@ const READABLE_ERROR_MESSAGES: Record = { YOU_BLOCKED_USER: 'You blocked this user', IMAGE_PROCESS_FAILED: 'Failure while processing image', MEDIA_EMPTY: 'The provided media object is invalid', + MEDIA_GROUPED_INVALID: 'Failed to replace album media', + MEDIA_NEW_INVALID: 'Failed to replace new media', + MESSAGE_NOT_MODIFIED: 'Message not modified. The new content is identical to the current one.', MEDIA_INVALID: 'Media invalid', PHOTO_EXT_INVALID: 'The extension of the photo is invalid', PHOTO_INVALID_DIMENSIONS: 'The photo dimensions are invalid', diff --git a/src/util/messageKey.ts b/src/util/messageKey.ts new file mode 100644 index 000000000..5121070d4 --- /dev/null +++ b/src/util/messageKey.ts @@ -0,0 +1,19 @@ +import type { ApiMessage } from '../api/types'; + +export type MessageKey = `msg${string}-${number}`; + +export function getMessageKey(message: ApiMessage): MessageKey { + const { chatId, id, previousLocalId } = message; + + return buildMessageKey(chatId, previousLocalId || id); +} + +function buildMessageKey(chatId: string, msgId: number): MessageKey { + return `msg${chatId}-${msgId}`; +} + +export function parseMessageKey(key: MessageKey) { + const match = key.match(/^msg(-?\d+)-(\d+)/)!; + + return { chatId: match[1], messageId: Number(match[2]) }; +}