Message Media: Editing message media (#4202)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
This commit is contained in:
Alexander Zinchuk 2024-02-23 14:05:46 +01:00
parent 64799a8818
commit c3cc8b634f
35 changed files with 618 additions and 274 deletions

View File

@ -917,7 +917,7 @@ function buildReplyInfo(inputInfo: ApiInputReplyInfo, isForum?: boolean): ApiRep
}; };
} }
function buildUploadingMedia( export function buildUploadingMedia(
attachment: ApiAttachment, attachment: ApiAttachment,
): MediaContent { ): MediaContent {
const { const {

View File

@ -4,13 +4,11 @@ import type {
ApiEmojiInteraction, ApiSticker, ApiStickerSet, ApiStickerSetInfo, GramJsEmojiInteraction, ApiEmojiInteraction, ApiSticker, ApiStickerSet, ApiStickerSetInfo, GramJsEmojiInteraction,
} from '../../types'; } from '../../types';
import { LOTTIE_STICKER_MIME_TYPE, VIDEO_STICKER_MIME_TYPE } from '../../../config';
import { compact } from '../../../util/iteratees'; import { compact } from '../../../util/iteratees';
import localDb from '../localDb'; import localDb from '../localDb';
import { buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common'; 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 { export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPremium?: boolean): ApiSticker | undefined {
if (document instanceof GramJs.DocumentEmpty) { if (document instanceof GramJs.DocumentEmpty) {
return undefined; return undefined;

View File

@ -24,35 +24,36 @@ import type {
MediaContent, MediaContent,
OnApiUpdate, OnApiUpdate,
} from '../../types'; } from '../../types';
import { import { MAIN_THREAD_ID, MESSAGE_DELETED } from '../../types';
MAIN_THREAD_ID,
MESSAGE_DELETED,
} from '../../types';
import { import {
ALL_FOLDER_ID, ALL_FOLDER_ID,
API_GENERAL_ID_LIMIT, API_GENERAL_ID_LIMIT,
DEBUG, GIF_MIME_TYPE, MAX_INT_32, MENTION_UNREAD_SLICE, DEBUG,
PINNED_MESSAGES_LIMIT, REACTION_UNREAD_SLICE, GIF_MIME_TYPE,
MAX_INT_32,
MENTION_UNREAD_SLICE,
PINNED_MESSAGES_LIMIT,
REACTION_UNREAD_SLICE,
SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config'; } from '../../../config';
import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage';
import { fetchFile } from '../../../util/files'; import { fetchFile } from '../../../util/files';
import { compact, split } from '../../../util/iteratees'; import { compact, split } from '../../../util/iteratees';
import { getMessageKey } from '../../../util/messageKey';
import { getServerTimeOffset } from '../../../util/serverTime'; import { getServerTimeOffset } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform'; import { interpolateArray } from '../../../util/waveform';
import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats';
import { buildApiFormattedText } from '../apiBuilders/common'; import { buildApiFormattedText } from '../apiBuilders/common';
import { import { buildMessageMediaContent, buildMessageTextContent, buildWebPage } from '../apiBuilders/messageContent';
buildMessageMediaContent, buildMessageTextContent, buildWebPage,
} from '../apiBuilders/messageContent';
import { import {
buildApiMessage, buildApiMessage,
buildApiSponsoredMessage, buildApiSponsoredMessage,
buildApiThreadInfo, buildApiThreadInfo,
buildLocalForwardedMessage, buildLocalForwardedMessage,
buildLocalMessage, buildLocalMessage,
buildUploadingMedia,
} from '../apiBuilders/messages'; } from '../apiBuilders/messages';
import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
@ -560,31 +561,34 @@ export async function editMessage({
message, message,
text, text,
entities, entities,
attachment,
noWebPage, noWebPage,
}: { }: {
chat: ApiChat; chat: ApiChat;
message: ApiMessage; message: ApiMessage;
text: string; text: string;
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
attachment?: ApiAttachment;
noWebPage?: boolean; noWebPage?: boolean;
}) { }, onProgress?: ApiOnProgress) {
const isScheduled = message.date * 1000 > Date.now() + getServerTimeOffset() * 1000; const isScheduled = message.date * 1000 > Date.now() + getServerTimeOffset() * 1000;
let messageUpdate: Partial<ApiMessage> = {
content: { const media = attachment && buildUploadingMedia(attachment);
...message.content,
...(text && { const newContent = {
text: { ...(media || message.content),
text, ...(text && {
entities, text: {
}, text,
}), entities,
}, },
}),
}; };
const emojiOnlyCount = getEmojiOnlyCountForMessage(messageUpdate.content!, messageUpdate.groupedId); const messageUpdate: Partial<ApiMessage> = {
messageUpdate = { ...message,
...messageUpdate, content: newContent,
emojiOnlyCount, emojiOnlyCount: getEmojiOnlyCountForMessage(newContent, message.groupedId),
}; };
onUpdate({ onUpdate({
@ -594,16 +598,47 @@ export async function editMessage({
message: messageUpdate, 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({ const mtpEntities = entities && entities.map(buildMtpMessageEntity);
message: text || '',
entities: mtpEntities, await invokeRequest(new GramJs.messages.EditMessage({
peer: buildInputPeer(chat.id, chat.accessHash), message: text || '',
id: message.id, entities: mtpEntities,
...(isScheduled && { scheduleDate: message.date }), media: mediaUpdate,
...(noWebPage && { noWebpage: noWebPage }), 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({ 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 { const {
filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile, shouldSendAsSpoiler, ttlSeconds, filename, blobUrl, mimeType, quick, voice, audio, previewBlobUrl, shouldSendAsFile, shouldSendAsSpoiler, ttlSeconds,
} = attachment; } = attachment;
@ -631,7 +666,7 @@ async function uploadMedia(localMessage: ApiMessage, attachment: ApiAttachment,
if (onProgress.isCanceled) { if (onProgress.isCanceled) {
patchedOnProgress.isCanceled = true; patchedOnProgress.isCanceled = true;
} else { } else {
onProgress(progress, localMessage.id); onProgress(progress, getMessageKey(message));
} }
}; };

View File

@ -205,6 +205,7 @@ const Audio: FC<OwnProps> = ({
message, message,
uploadProgress || downloadProgress, uploadProgress || downloadProgress,
isLoadingForPlaying || isDownloading, isLoadingForPlaying || isDownloading,
uploadProgress !== undefined,
); );
const { const {

View File

@ -49,6 +49,7 @@ import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterd
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
getStoryKey, getStoryKey,
hasReplaceableMedia,
isChatAdmin, isChatAdmin,
isChatChannel, isChatChannel,
isChatSuperGroup, isChatSuperGroup,
@ -373,6 +374,7 @@ const Composer: FC<OwnProps & StateProps> = ({
openStoryReactionPicker, openStoryReactionPicker,
closeReactionPicker, closeReactionPicker,
sendStoryReaction, sendStoryReaction,
editMessage,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -401,6 +403,8 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isInputHasFocus, markInputHasFocus, unmarkInputHasFocus] = useFlag(); const [isInputHasFocus, markInputHasFocus, unmarkInputHasFocus] = useFlag();
const [isAttachMenuOpen, onAttachMenuOpen, onAttachMenuClose] = useFlag(); const [isAttachMenuOpen, onAttachMenuOpen, onAttachMenuClose] = useFlag();
const canMediaBeReplaced = editingMessage && hasReplaceableMedia(editingMessage);
const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction
? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false; ? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false;
@ -532,6 +536,7 @@ const Composer: FC<OwnProps & StateProps> = ({
canSendPhotos, canSendPhotos,
canSendDocuments, canSendDocuments,
insertNextText, insertNextText,
editedMessage: editingMessage,
}); });
const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag(); const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag();
@ -891,16 +896,25 @@ const Composer: FC<OwnProps & StateProps> = ({
if (!validateTextLength(text, true)) return; if (!validateTextLength(text, true)) return;
if (!checkSlowMode()) return; if (!checkSlowMode()) return;
sendMessage({ if (editingMessage) {
messageList: currentMessageList, editMessage({
text, messageList: currentMessageList,
entities, text,
scheduledAt, entities,
isSilent, attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed),
shouldUpdateStickerSetOrder, });
attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed), } else {
shouldGroupMessages: sendGrouped, sendMessage({
}); messageList: currentMessageList,
text,
entities,
scheduledAt,
isSilent,
shouldUpdateStickerSetOrder,
attachments: prepareAttachmentsToSend(attachmentsToSend, sendCompressed),
shouldGroupMessages: sendGrouped,
});
}
lastMessageSendTimeSeconds.current = getServerTime(); lastMessageSendTimeSeconds.current = getServerTime();
@ -1495,6 +1509,7 @@ const Composer: FC<OwnProps & StateProps> = ({
withQuick={dropAreaState === DropAreaState.QuickFile || prevDropAreaState === DropAreaState.QuickFile} withQuick={dropAreaState === DropAreaState.QuickFile || prevDropAreaState === DropAreaState.QuickFile}
onHide={onDropHide!} onHide={onDropHide!}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
editingMessage={editingMessage}
/> />
)} )}
{shouldRenderReactionSelector && ( {shouldRenderReactionSelector && (
@ -1535,6 +1550,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect={handleCustomEmojiSelectAttachmentModal} onCustomEmojiSelect={handleCustomEmojiSelectAttachmentModal}
onRemoveSymbol={removeSymbolAttachmentModal} onRemoveSymbol={removeSymbolAttachmentModal}
onEmojiSelect={insertTextAndUpdateCursorAttachmentModal} onEmojiSelect={insertTextAndUpdateCursorAttachmentModal}
editingMessage={editingMessage}
/> />
<PollModal <PollModal
isOpen={pollModal.isOpen} isOpen={pollModal.isOpen}
@ -1763,7 +1779,9 @@ const Composer: FC<OwnProps & StateProps> = ({
<AttachMenu <AttachMenu
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isButtonVisible={!activeVoiceRecording && !editingMessage} editingMessage={editingMessage}
hasReplaceableMedia={canMediaBeReplaced}
isButtonVisible={!activeVoiceRecording}
canAttachMedia={canAttachMedia} canAttachMedia={canAttachMedia}
canAttachPolls={canAttachPolls} canAttachPolls={canAttachPolls}
canSendPhotos={canSendPhotos} canSendPhotos={canSendPhotos}

View File

@ -7,10 +7,7 @@ import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES } from '../../config';
SUPPORTED_IMAGE_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../config';
import { import {
getMediaTransferState, getMediaTransferState,
getMessageMediaFormat, getMessageMediaFormat,
@ -109,7 +106,12 @@ const Document: FC<OwnProps> = ({
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState(message, uploadProgress || downloadProgress, shouldDownload && !isLoaded); } = getMediaTransferState(
message,
uploadProgress || downloadProgress,
shouldDownload && !isLoaded,
uploadProgress !== undefined,
);
const hasPreview = getDocumentHasPreview(document); const hasPreview = getDocumentHasPreview(document);
const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined; const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined;

View File

@ -4,13 +4,13 @@ import type { TextPart } from '../../../types';
import { ApiMessageEntityTypes } from '../../../api/types'; import { ApiMessageEntityTypes } from '../../../api/types';
import { import {
getMessageKey,
getMessageSummaryDescription, getMessageSummaryDescription,
getMessageSummaryEmoji, getMessageSummaryEmoji,
getMessageSummaryText, getMessageSummaryText,
getMessageText, getMessageText,
TRUNCATED_SUMMARY_LENGTH, TRUNCATED_SUMMARY_LENGTH,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { getMessageKey } from '../../../util/messageKey';
import trimText from '../../../util/trimText'; import trimText from '../../../util/trimText';
import renderText from './renderText'; import renderText from './renderText';
import { renderTextWithEntities } from './renderTextWithEntities'; import { renderTextWithEntities } from './renderTextWithEntities';

View File

@ -25,7 +25,6 @@ import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { import {
getChatTitle, getChatTitle,
getIsSavedDialog, getIsSavedDialog,
getMessageKey,
getSenderTitle, getSenderTitle,
isChatChannel, isChatChannel,
isChatSuperGroup, isChatSuperGroup,
@ -51,6 +50,7 @@ import {
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import cycleRestrict from '../../util/cycleRestrict'; import cycleRestrict from '../../util/cycleRestrict';
import { getMessageKey } from '../../util/messageKey';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useConnectionStatus from '../../hooks/useConnectionStatus'; import useConnectionStatus from '../../hooks/useConnectionStatus';

View File

@ -4,7 +4,7 @@ import React, {
useMemo, useMemo,
} from '../../../lib/teact/teact'; } 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 { GlobalState } from '../../../global/types';
import type { ISettings, ThreadId } from '../../../types'; import type { ISettings, ThreadId } from '../../../types';
@ -13,6 +13,13 @@ import {
SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_IMAGE_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config'; } from '../../../config';
import {
getMessageAudio, getMessageDocument,
getMessagePhoto,
getMessageVideo, getMessageVoice,
getMessageWebPagePhoto,
getMessageWebPageVideo,
} from '../../../global/helpers';
import { getDebugLogs } from '../../../util/debugConsole'; import { getDebugLogs } from '../../../util/debugConsole';
import { validateFiles } from '../../../util/files'; import { validateFiles } from '../../../util/files';
import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog';
@ -23,6 +30,7 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMouseInside from '../../../hooks/useMouseInside'; import useMouseInside from '../../../hooks/useMouseInside';
import Icon from '../../common/Icon';
import Menu from '../../ui/Menu'; import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem'; import MenuItem from '../../ui/MenuItem';
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton'; import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
@ -49,6 +57,8 @@ export type OwnProps = {
onPollCreate: NoneToVoidFunction; onPollCreate: NoneToVoidFunction;
onMenuOpen: NoneToVoidFunction; onMenuOpen: NoneToVoidFunction;
onMenuClose: NoneToVoidFunction; onMenuClose: NoneToVoidFunction;
hasReplaceableMedia?: boolean;
editingMessage?: ApiMessage;
}; };
const AttachMenu: FC<OwnProps> = ({ const AttachMenu: FC<OwnProps> = ({
@ -70,6 +80,8 @@ const AttachMenu: FC<OwnProps> = ({
onMenuOpen, onMenuOpen,
onMenuClose, onMenuClose,
onPollCreate, onPollCreate,
hasReplaceableMedia,
editingMessage,
}) => { }) => {
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag(); const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu); const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu);
@ -80,6 +92,12 @@ const AttachMenu: FC<OwnProps> = ({
const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag(); const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag();
const isMenuOpen = isAttachMenuOpen || isAttachmentBotMenuOpen; 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(() => { useEffect(() => {
if (isAttachMenuOpen) { if (isAttachMenuOpen) {
markMouseInside(); markMouseInside();
@ -152,18 +170,36 @@ const AttachMenu: FC<OwnProps> = ({
return ( return (
<div className="AttachMenu"> <div className="AttachMenu">
<ResponsiveHoverButton {
id="attach-menu-button" editingMessage && hasReplaceableMedia ? (
className={isAttachMenuOpen ? 'AttachMenu--button activated' : 'AttachMenu--button'} <ResponsiveHoverButton
round id="replace-menu-button"
color="translucent" className={isAttachMenuOpen ? 'AttachMenu--button activated' : 'AttachMenu--button'}
onActivate={handleToggleAttachMenu} round
ariaLabel="Add an attachment" color="translucent"
ariaControls="attach-menu-controls" onActivate={handleToggleAttachMenu}
hasPopup ariaLabel="Replace an attachment"
> ariaControls="replace-menu-controls"
<i className="icon icon-attach" /> hasPopup
</ResponsiveHoverButton> >
<Icon name="replace" />
</ResponsiveHoverButton>
) : (
<ResponsiveHoverButton
id="attach-menu-button"
disabled={Boolean(editingMessage)}
className={isAttachMenuOpen ? 'AttachMenu--button activated' : 'AttachMenu--button'}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Add an attachment"
ariaControls="attach-menu-controls"
hasPopup
>
<Icon name="attach" />
</ResponsiveHoverButton>
)
}
<Menu <Menu
id="attach-menu-controls" id="attach-menu-controls"
isOpen={isMenuOpen} isOpen={isMenuOpen}
@ -187,13 +223,13 @@ const AttachMenu: FC<OwnProps> = ({
)} )}
{canAttachMedia && ( {canAttachMedia && (
<> <>
{canSendVideoOrPhoto && ( {canSendVideoOrPhoto && !isFile && (
<MenuItem icon="photo" onClick={handleQuickSelect}> <MenuItem icon="photo" onClick={handleQuickSelect}>
{lang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo' {lang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo'
: (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))} : (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))}
</MenuItem> </MenuItem>
)} )}
{(canSendDocuments || canSendAudios) {((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
&& ( && (
<MenuItem icon="document" onClick={handleDocumentSelect}> <MenuItem icon="document" onClick={handleDocumentSelect}>
{lang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')} {lang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')}
@ -206,11 +242,11 @@ const AttachMenu: FC<OwnProps> = ({
)} )}
</> </>
)} )}
{canAttachPolls && ( {canAttachPolls && !editingMessage && (
<MenuItem icon="poll" onClick={onPollCreate}>{lang('Poll')}</MenuItem> <MenuItem icon="poll" onClick={onPollCreate}>{lang('Poll')}</MenuItem>
)} )}
{canAttachMedia && !isScheduled && bots?.map((bot) => ( {!editingMessage && !hasReplaceableMedia && !isScheduled && bots?.map((bot) => (
<AttachBotItem <AttachBotItem
bot={bot} bot={bot}
chatId={chatId} chatId={chatId}

View File

@ -5,9 +5,9 @@ import React, {
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { import type {
ApiAttachment, ApiChatMember, ApiSticker, ApiAttachment, ApiChatMember, ApiMessage, ApiSticker,
} from '../../../api/types'; } from '../../../api/types';
import type { GlobalState } from '../../../global/types'; import type { GlobalState, MessageListType } from '../../../global/types';
import type { ThreadId } from '../../../types'; import type { ThreadId } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
@ -20,7 +20,7 @@ import {
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config'; } from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { isUserId } from '../../../global/helpers'; import { getAttachmentType, isUserId } from '../../../global/helpers';
import { selectChatFullInfo, selectIsChatWithSelf } from '../../../global/selectors'; import { selectChatFullInfo, selectIsChatWithSelf } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -62,6 +62,8 @@ export type OwnProps = {
chatId: string; chatId: string;
threadId: ThreadId; threadId: ThreadId;
attachments: ApiAttachment[]; attachments: ApiAttachment[];
editingMessage?: ApiMessage;
messageListType?: MessageListType;
getHtml: Signal<string>; getHtml: Signal<string>;
canShowCustomSendMenu?: boolean; canShowCustomSendMenu?: boolean;
isReady: boolean; isReady: boolean;
@ -89,6 +91,7 @@ type StateProps = {
currentUserId?: string; currentUserId?: string;
groupChatMembers?: ApiChatMember[]; groupChatMembers?: ApiChatMember[];
recentEmojis: string[]; recentEmojis: string[];
editingMessage?: ApiMessage;
baseEmojiKeywords?: Record<string, string[]>; baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>; emojiKeywords?: Record<string, string[]>;
shouldSuggestCustomEmoji?: boolean; shouldSuggestCustomEmoji?: boolean;
@ -106,6 +109,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
threadId, threadId,
attachments, attachments,
getHtml, getHtml,
editingMessage,
canShowCustomSendMenu, canShowCustomSendMenu,
captionLimit, captionLimit,
isReady, isReady,
@ -150,12 +154,19 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const renderingAttachments = attachments.length ? attachments : prevAttachments; const renderingAttachments = attachments.length ? attachments : prevAttachments;
const { isMobile } = useAppLayout(); 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 [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [shouldSendCompressed, setShouldSendCompressed] = useState( const [shouldSendCompressed, setShouldSendCompressed] = useState(
shouldSuggestCompression ?? attachmentSettings.shouldCompress, shouldSuggestCompression ?? attachmentSettings.shouldCompress,
); );
const isSendingCompressed = Boolean((shouldSendCompressed || shouldForceCompression) && !shouldForceAsFile); const isSendingCompressed = Boolean(
(shouldSendCompressed || shouldForceCompression || isInAlbum) && !shouldForceAsFile,
);
const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped);
const { const {
@ -261,7 +272,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => {
if (isOpen) { if (isOpen) {
const send = ((shouldSchedule || shouldSendScheduled) && isForMessage) ? onSendScheduled const send = ((shouldSchedule || shouldSendScheduled) && isForMessage && !editingMessage) ? onSendScheduled
: isSilent ? onSendSilent : onSend; : isSilent ? onSendSilent : onSend;
send(isSendingCompressed, shouldSendGrouped); send(isSendingCompressed, shouldSendGrouped);
updateAttachmentSettings({ updateAttachmentSettings({
@ -425,13 +436,13 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
let title = ''; let title = '';
if (areAllPhotos) { if (areAllPhotos) {
title = lang('PreviewSender.SendPhoto', renderingAttachments.length, 'i'); title = lang(isEditing ? 'EditMessageReplacePhoto' : 'PreviewSender.SendPhoto', renderingAttachments.length, 'i');
} else if (areAllVideos) { } else if (areAllVideos) {
title = lang('PreviewSender.SendVideo', renderingAttachments.length, 'i'); title = lang(isEditing ? 'EditMessageReplaceVideo' : 'PreviewSender.SendVideo', renderingAttachments.length, 'i');
} else if (areAllAudios) { } else if (areAllAudios) {
title = lang('PreviewSender.SendAudio', renderingAttachments.length, 'i'); title = lang(isEditing ? 'EditMessageReplaceAudio' : 'PreviewSender.SendAudio', renderingAttachments.length, 'i');
} else { } else {
title = lang('PreviewSender.SendFile', renderingAttachments.length, 'i'); title = lang(isEditing ? 'EditMessageReplaceFile' : 'PreviewSender.SendFile', renderingAttachments.length, 'i');
} }
function renderHeader() { function renderHeader() {
@ -445,57 +456,62 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
<i className="icon icon-close" /> <i className="icon icon-close" />
</Button> </Button>
<div className="modal-title">{title}</div> <div className="modal-title">{title}</div>
<DropdownMenu {notEditingFile && !isInAlbum
className="attachment-modal-more-menu with-menu-transitions" && (
trigger={MoreMenuButton} <DropdownMenu
positionX="right" className="attachmeneditingMessaget-modal-more-menu with-menu-transitions"
> trigger={MoreMenuButton}
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem> positionX="right"
{hasMedia && ( >
<> {Boolean(!editingMessage) && (
{ <MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem>
!shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( )}
// eslint-disable-next-line react/jsx-no-bind {hasMedia && (
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}> <>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} {
!shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')}
</MenuItem>
) : (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="photo" onClick={() => setShouldSendCompressed(true)}>
{isMultiple ? 'Send All as Media' : 'Send as Media'}
</MenuItem>
))
}
{isSendingCompressed && hasAnySpoilerable && Boolean(!editingMessage) && (
hasSpoiler ? (
<MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}>
{lang('Attachment.DisableSpoiler')}
</MenuItem>
) : (
<MenuItem icon="spoiler" onClick={handleEnableSpoilers}>
{lang('Attachment.EnableSpoiler')}
</MenuItem>
)
)}
</>
)}
{isMultiple && (
shouldSendGrouped ? (
<MenuItem
icon="grouped-disable"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setShouldSendGrouped(false)}
>
Ungroup All Media
</MenuItem> </MenuItem>
) : ( ) : (
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="photo" onClick={() => setShouldSendCompressed(true)}> <MenuItem icon="grouped" onClick={() => setShouldSendGrouped(true)}>
{isMultiple ? 'Send All as Media' : 'Send as Media'} Group All Media
</MenuItem>
))
}
{isSendingCompressed && hasAnySpoilerable && (
hasSpoiler ? (
<MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}>
{lang('Attachment.DisableSpoiler')}
</MenuItem>
) : (
<MenuItem icon="spoiler" onClick={handleEnableSpoilers}>
{lang('Attachment.EnableSpoiler')}
</MenuItem> </MenuItem>
) )
)} )}
</> </DropdownMenu>
)} )}
{isMultiple && (
shouldSendGrouped ? (
<MenuItem
icon="grouped-disable"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setShouldSendGrouped(false)}
>
Ungroup All Media
</MenuItem>
) : (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="grouped" onClick={() => setShouldSendGrouped(true)}>
Group All Media
</MenuItem>
)
)}
</DropdownMenu>
</div> </div>
); );
} }
@ -622,7 +638,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onClick={handleSendClick} onClick={handleSendClick}
onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined} onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined}
> >
{shouldSchedule ? lang('Next') : lang('Send')} {shouldSchedule && !editingMessage ? lang('Next') : editingMessage ? lang('Save') : lang('Send')}
</Button> </Button>
{canShowCustomSendMenu && ( {canShowCustomSendMenu && (
<CustomSendMenu <CustomSendMenu

View File

@ -137,7 +137,7 @@ const AttachmentModalItem: FC<OwnProps> = ({
); );
}; };
function getDisplayType(attachment: ApiAttachment, shouldDisplayCompressed?: boolean) { export function getDisplayType(attachment: ApiAttachment, shouldDisplayCompressed?: boolean) {
if (shouldDisplayCompressed && attachment.quick) { if (shouldDisplayCompressed && attachment.quick) {
if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) { if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) {
return 'image'; return 'image';

View File

@ -1,10 +1,16 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo, useEffect, useRef } 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 buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import buildAttachment from './helpers/buildAttachment';
import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
@ -19,6 +25,7 @@ export type OwnProps = {
withQuick?: boolean; withQuick?: boolean;
onHide: NoneToVoidFunction; onHide: NoneToVoidFunction;
onFileSelect: (files: File[], suggestCompression?: boolean) => void; onFileSelect: (files: File[], suggestCompression?: boolean) => void;
editingMessage?: ApiMessage | undefined;
}; };
export enum DropAreaState { export enum DropAreaState {
@ -30,12 +37,15 @@ export enum DropAreaState {
const DROP_LEAVE_TIMEOUT_MS = 150; const DROP_LEAVE_TIMEOUT_MS = 150;
const DropArea: FC<OwnProps> = ({ const DropArea: FC<OwnProps> = ({
isOpen, withQuick, onHide, onFileSelect, isOpen, withQuick, onHide, onFileSelect, editingMessage,
}) => { }) => {
const lang = useLang();
const { showNotification } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const hideTimeoutRef = useRef<number>(null); const hideTimeoutRef = useRef<number>(null);
const prevWithQuick = usePrevious(withQuick); const prevWithQuick = usePrevious(withQuick);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen); const { shouldRender, transitionClassNames } = useShowTransition(isOpen);
const isInAlbum = editingMessage && editingMessage?.groupedId;
useEffect(() => (isOpen ? captureEscKeyListener(onHide) : undefined), [isOpen, onHide]); useEffect(() => (isOpen ? captureEscKeyListener(onHide) : undefined), [isOpen, onHide]);
@ -47,6 +57,14 @@ const DropArea: FC<OwnProps> = ({
files = files.concat(Array.from(dt.files)); files = files.concat(Array.from(dt.files));
} else if (dt.items && dt.items.length > 0) { } else if (dt.items && dt.items.length > 0) {
const folderFiles = await getFilesFromDataTransferItems(dt.items); 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) { if (folderFiles?.length) {
files = files.concat(folderFiles); files = files.concat(folderFiles);
} }

View File

@ -1,16 +1,13 @@
import { useState } from '../../../../lib/teact/teact'; import { useState } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { ApiAttachment } from '../../../../api/types'; import type { ApiAttachment, ApiMessage } from '../../../../api/types';
import { import { canReplaceMessageMedia, getAttachmentType } from '../../../../global/helpers';
SUPPORTED_AUDIO_CONTENT_TYPES,
SUPPORTED_IMAGE_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../../config';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import buildAttachment from '../helpers/buildAttachment'; import buildAttachment from '../helpers/buildAttachment';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
export default function useAttachmentModal({ export default function useAttachmentModal({
@ -24,6 +21,7 @@ export default function useAttachmentModal({
canSendPhotos, canSendPhotos,
canSendDocuments, canSendDocuments,
insertNextText, insertNextText,
editedMessage,
}: { }: {
attachments: ApiAttachment[]; attachments: ApiAttachment[];
fileSizeLimit: number; fileSizeLimit: number;
@ -35,8 +33,10 @@ export default function useAttachmentModal({
canSendPhotos?: boolean; canSendPhotos?: boolean;
canSendDocuments?: boolean; canSendDocuments?: boolean;
insertNextText: VoidFunction; insertNextText: VoidFunction;
editedMessage: ApiMessage | undefined;
}) { }) {
const { openLimitReachedModal, showAllowedMessageTypesNotification } = getActions(); const lang = useLang();
const { openLimitReachedModal, showAllowedMessageTypesNotification, showNotification } = getActions();
const [shouldForceAsFile, setShouldForceAsFile] = useState<boolean>(false); const [shouldForceAsFile, setShouldForceAsFile] = useState<boolean>(false);
const [shouldForceCompression, setShouldForceCompression] = useState<boolean>(false); const [shouldForceCompression, setShouldForceCompression] = useState<boolean>(false);
const [shouldSuggestCompression, setShouldSuggestCompression] = useState<boolean | undefined>(undefined); const [shouldSuggestCompression, setShouldSuggestCompression] = useState<boolean | undefined>(undefined);
@ -85,16 +85,45 @@ export default function useAttachmentModal({
); );
const handleAppendFiles = useLastCallback(async (files: File[], isSpoiler?: boolean) => { const handleAppendFiles = useLastCallback(async (files: File[], isSpoiler?: boolean) => {
handleSetAttachments([ if (editedMessage) {
...attachments, const newAttachment = await buildAttachment(files[0].name, files[0]);
...await Promise.all(files.map((file) => ( 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 }) buildAttachment(file.name, file, { shouldSendAsSpoiler: isSpoiler || undefined })
))), )));
]); handleSetAttachments([...attachments, ...newAttachments]);
}
}); });
const handleFileSelect = useLastCallback(async (files: File[], suggestCompression?: boolean) => { 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); setShouldSuggestCompression(suggestCompression);
}); });
@ -109,21 +138,3 @@ export default function useAttachmentModal({
shouldForceAsFile, 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';
}

View File

@ -1,17 +1,21 @@
import type { StateHookSetter } from '../../../../lib/teact/teact'; import type { StateHookSetter } from '../../../../lib/teact/teact';
import { useEffect } from '../../../../lib/teact/teact'; import { useEffect } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiAttachment, ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { ApiAttachment, ApiFormattedText, ApiMessage } from '../../../../api/types';
import { import {
EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID, EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID, EDITABLE_STORY_INPUT_ID,
} from '../../../../config'; } from '../../../../config';
import { canReplaceMessageMedia, isUploadingFileSticker } from '../../../../global/helpers';
import { containsCustomEmoji, stripCustomEmoji } from '../../../../global/helpers/symbols'; import { containsCustomEmoji, stripCustomEmoji } from '../../../../global/helpers/symbols';
import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText'; import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText';
import buildAttachment from '../helpers/buildAttachment'; import buildAttachment from '../helpers/buildAttachment';
import { preparePastedHtml } from '../helpers/cleanHtml'; import { preparePastedHtml } from '../helpers/cleanHtml';
import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems'; import getFilesFromDataTransferItems from '../helpers/getFilesFromDataTransferItems';
import useLang from '../../../../hooks/useLang';
const MAX_MESSAGE_LENGTH = 4096; const MAX_MESSAGE_LENGTH = 4096;
const TYPE_HTML = 'text/html'; const TYPE_HTML = 'text/html';
@ -27,6 +31,9 @@ const useClipboardPaste = (
shouldStripCustomEmoji?: boolean, shouldStripCustomEmoji?: boolean,
onCustomEmojiStripped?: VoidFunction, onCustomEmojiStripped?: VoidFunction,
) => { ) => {
const { showNotification } = getActions();
const lang = useLang();
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
return undefined; return undefined;
@ -60,6 +67,9 @@ const useClipboardPaste = (
e.preventDefault(); e.preventDefault();
if (items.length > 0) { if (items.length > 0) {
files = await getFilesFromDataTransferItems(items); files = await getFilesFromDataTransferItems(items);
if (editedMessage) {
files = files?.slice(0, 1);
}
} }
if (!files?.length && !pastedText) { if (!files?.length && !pastedText) {
@ -79,13 +89,29 @@ const useClipboardPaste = (
} }
const hasText = textToPaste && textToPaste.text; 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) { if (shouldSetAttachments) {
const newAttachments = await Promise.all(files!.map((file) => { setAttachments(editedMessage ? newAttachments : (attachments) => attachments.concat(newAttachments));
return buildAttachment(file.name, file);
}));
setAttachments((attachments) => attachments.concat(newAttachments));
} }
if (hasText) { if (hasText) {
@ -103,8 +129,8 @@ const useClipboardPaste = (
document.removeEventListener('paste', handlePaste, false); document.removeEventListener('paste', handlePaste, false);
}; };
}, [ }, [
insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji, onCustomEmojiStripped, insertTextAndUpdateCursor, editedMessage, setAttachments, isActive, shouldStripCustomEmoji,
setNextText, onCustomEmojiStripped, setNextText, lang,
]); ]);
}; };

View File

@ -8,13 +8,14 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { IAlbum, ISettings } from '../../../types'; import type { IAlbum, ISettings } from '../../../types';
import type { IAlbumLayout } from './helpers/calculateAlbumLayout'; import type { IAlbumLayout } from './helpers/calculateAlbumLayout';
import { getMessageContent, getMessageHtmlId, getMessageOriginalId } from '../../../global/helpers'; import { getMessageContent, getMessageHtmlId } from '../../../global/helpers';
import { import {
selectActiveDownloads, selectActiveDownloads,
selectCanAutoLoadMedia, selectCanAutoLoadMedia,
selectCanAutoPlayMedia, selectCanAutoPlayMedia,
selectTheme, selectTheme,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { getMessageKey } from '../../../util/messageKey';
import { AlbumRectPart } from './helpers/calculateAlbumLayout'; import { AlbumRectPart } from './helpers/calculateAlbumLayout';
import withSelectControl from './hocs/withSelectControl'; import withSelectControl from './hocs/withSelectControl';
@ -40,7 +41,7 @@ type OwnProps = {
type StateProps = { type StateProps = {
theme: ISettings['theme']; theme: ISettings['theme'];
uploadsById: GlobalState['fileUploads']['byMessageLocalId']; uploadsByKey: GlobalState['fileUploads']['byMessageKey'];
activeDownloadIds?: number[]; activeDownloadIds?: number[];
}; };
@ -52,21 +53,21 @@ const Album: FC<OwnProps & StateProps> = ({
isProtected, isProtected,
albumLayout, albumLayout,
onMediaClick, onMediaClick,
uploadsById, uploadsByKey,
activeDownloadIds, activeDownloadIds,
theme, theme,
}) => { }) => {
const { cancelSendingMessage } = getActions(); const { cancelUploadMedia } = getActions();
const mediaCount = album.messages.length; const mediaCount = album.messages.length;
const handleCancelUpload = useLastCallback((message: ApiMessage) => { 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) { function renderAlbumMessage(message: ApiMessage, index: number) {
const { photo, video } = getMessageContent(message); const { photo, video } = getMessageContent(message);
const fileUpload = uploadsById[getMessageOriginalId(message)]; const fileUpload = uploadsByKey[getMessageKey(message)];
const uploadProgress = fileUpload?.progress; const uploadProgress = fileUpload?.progress;
const { dimensions, sides } = albumLayout.layout[index]; const { dimensions, sides } = albumLayout.layout[index];
@ -139,7 +140,7 @@ export default withGlobal<OwnProps>(
return { return {
theme, theme,
uploadsById: global.fileUploads.byMessageLocalId, uploadsByKey: global.fileUploads.byMessageKey,
activeDownloadIds: isScheduled ? activeDownloads?.scheduledIds : activeDownloads?.ids, activeDownloadIds: isScheduled ? activeDownloads?.scheduledIds : activeDownloads?.ids,
}; };
}, },

View File

@ -37,7 +37,6 @@ import {
getMessageContent, getMessageContent,
getMessageCustomShape, getMessageCustomShape,
getMessageHtmlId, getMessageHtmlId,
getMessageKey,
getMessageSingleCustomEmoji, getMessageSingleCustomEmoji,
getMessageSingleRegularEmoji, getMessageSingleRegularEmoji,
getSenderTitle, getSenderTitle,
@ -99,6 +98,7 @@ import {
import { isAnimatingScroll } from '../../../util/animateScroll'; import { isAnimatingScroll } from '../../../util/animateScroll';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { isElementInViewport } from '../../../util/isElementInViewport'; import { isElementInViewport } from '../../../util/isElementInViewport';
import { getMessageKey } from '../../../util/messageKey';
import stopEvent from '../../../util/stopEvent'; import stopEvent from '../../../util/stopEvent';
import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment';
import { import {

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; 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 { ApiMessage } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
@ -102,7 +102,15 @@ const Photo: FC<OwnProps> = ({
const thumbClassNames = useMediaTransition(!noThumb); const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message); 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 { const {
loadProgress: downloadProgress, loadProgress: downloadProgress,
@ -116,6 +124,7 @@ const Photo: FC<OwnProps> = ({
message, message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress), uploadProgress || (isDownloading ? downloadProgress : loadProgress),
shouldLoad && !fullMediaData, shouldLoad && !fullMediaData,
uploadProgress !== undefined,
); );
const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const wasLoadDisabled = usePrevious(isLoadAllowed) === false;

View File

@ -7,8 +7,9 @@ import type {
} from '../../../api/types'; } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; 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 buildClassName from '../../../util/buildClassName';
import { getMessageKey } from '../../../util/messageKey';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; 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 { getActions } from '../../../global';
import type { ApiMessage } from '../../../api/types'; import type { ApiMessage } from '../../../api/types';
@ -80,7 +80,15 @@ const Video: FC<OwnProps> = ({
const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!; const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!;
const localBlobUrl = video.blobUrl; 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 isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading);
const isIntersectingForPlaying = ( const isIntersectingForPlaying = (
@ -133,6 +141,7 @@ const Video: FC<OwnProps> = ({
message, message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress), uploadProgress || (isDownloading ? downloadProgress : loadProgress),
(shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading, (shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading,
uploadProgress !== undefined,
); );
const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const wasLoadDisabled = usePrevious(isLoadAllowed) === false;

View File

@ -34,7 +34,7 @@ export default function useInnerHandlers(
) { ) {
const { const {
openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer,
markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, markMessagesRead, cancelUploadMedia, sendPollVote, openForwardMenu,
openChatLanguageModal, openThread, openStoryViewer, openChatLanguageModal, openThread, openStoryViewer,
} = getActions(); } = getActions();
@ -121,7 +121,7 @@ export default function useInnerHandlers(
}); });
const handleCancelUpload = useLastCallback(() => { const handleCancelUpload = useLastCallback(() => {
cancelSendingMessage({ chatId, messageId }); cancelUploadMedia({ chatId, messageId });
}); });
const handleVoteSend = useLastCallback((options: string[]) => { const handleVoteSend = useLastCallback((options: string[]) => {

View File

@ -219,6 +219,9 @@ export const SLIDE_TRANSITION_DURATION = 450;
export const VIDEO_WEBM_TYPE = 'video/webm'; export const VIDEO_WEBM_TYPE = 'video/webm';
export const GIF_MIME_TYPE = 'image/gif'; 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([ export const SUPPORTED_IMAGE_CONTENT_TYPES = new Set([
'image/png', 'image/jpeg', GIF_MIME_TYPE, 'image/png', 'image/jpeg', GIF_MIME_TYPE,
]); ]);

View File

@ -15,6 +15,7 @@ import type {
ApiStorySkipped, ApiStorySkipped,
ApiVideo, ApiVideo,
} from '../../../api/types'; } from '../../../api/types';
import type { MessageKey } from '../../../util/messageKey';
import type { RequiredGlobalActions } from '../../index'; import type { RequiredGlobalActions } from '../../index';
import type { import type {
ActionReturnType, ApiDraft, GlobalState, TabArgs, ActionReturnType, ApiDraft, GlobalState, TabArgs,
@ -36,17 +37,20 @@ import { isDeepLink } from '../../../util/deepLinkParser';
import { ensureProtocol } from '../../../util/ensureProtocol'; import { ensureProtocol } from '../../../util/ensureProtocol';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { import {
areSortedArraysIntersecting, buildCollectionByKey, omit, partition, split, unique, areSortedArraysIntersecting,
buildCollectionByKey,
omit,
partition,
split,
unique,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { translate } from '../../../util/langProvider'; import { translate } from '../../../util/langProvider';
import { import { getMessageKey } from '../../../util/messageKey';
debounce, onTickEnd, rafPromise, import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers';
} from '../../../util/schedulers';
import { IS_IOS } from '../../../util/windowEnvironment'; import { IS_IOS } from '../../../util/windowEnvironment';
import { callApi, cancelApiProgress } from '../../../api/gramjs'; import { callApi, cancelApiProgress } from '../../../api/gramjs';
import { import {
getIsSavedDialog, getIsSavedDialog,
getMessageOriginalId,
getUserFullName, getUserFullName,
isChatChannel, isChatChannel,
isDeletedUser, isDeletedUser,
@ -81,6 +85,7 @@ import {
updateThreadInfo, updateThreadInfo,
updateThreadUnreadFromForwardedMessage, updateThreadUnreadFromForwardedMessage,
updateTopic, updateTopic,
updateUploadByMessageKey,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { import {
@ -125,7 +130,7 @@ import { deleteMessages } from '../apiUpdaters/messages';
const AUTOLOGIN_TOKEN_KEY = 'autologin_token'; const AUTOLOGIN_TOKEN_KEY = 'autologin_token';
const uploadProgressCallbacks = new Map<number, ApiOnProgress>(); const uploadProgressCallbacks = new Map<MessageKey, ApiOnProgress>();
const runDebouncedForMarkRead = debounce((cb) => cb(), 500, false); const runDebouncedForMarkRead = debounce((cb) => cb(), 500, false);
@ -426,13 +431,25 @@ addActionHandler('sendInviteMessages', async (global, actions, payload): Promise
addActionHandler('editMessage', (global, actions, payload): ActionReturnType => { addActionHandler('editMessage', (global, actions, payload): ActionReturnType => {
const { const {
messageList, text, entities, tabId = getCurrentTabId(), messageList, text, entities, attachments, tabId = getCurrentTabId(),
} = payload; } = payload;
if (!messageList) { if (!messageList) {
return; 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 { chatId, threadId, type: messageListType } = messageList;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const message = selectEditingMessage(global, chatId, threadId, messageListType); const message = selectEditingMessage(global, chatId, threadId, messageListType);
@ -440,26 +457,46 @@ addActionHandler('editMessage', (global, actions, payload): ActionReturnType =>
return; return;
} }
void callApi('editMessage', {
chat, message, text, entities, noWebPage: selectNoWebPage(global, chatId, threadId),
});
actions.setEditingId({ messageId: undefined, tabId }); 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 { chatId, messageId } = payload!;
const message = selectChatMessage(global, chatId, messageId); 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) { if (progressCallback) {
cancelApiProgress(progressCallback); cancelApiProgress(progressCallback);
} }
actions.apiUpdate({ if (isMessageLocal(message)) {
'@type': 'deleteMessages', actions.apiUpdate({
ids: [messageId], '@type': 'deleteMessages',
chatId, ids: [messageId],
}); chatId,
});
}
}); });
addActionHandler('saveDraft', (global, actions, payload): ActionReturnType => { addActionHandler('saveDraft', (global, actions, payload): ActionReturnType => {
@ -1349,24 +1386,15 @@ async function sendMessage<T extends GlobalState>(global: T, params: {
wasDrafted?: boolean; wasDrafted?: boolean;
lastMessageId?: number; lastMessageId?: number;
}) { }) {
let localId: number | undefined; let currentMessageKey: MessageKey | undefined;
const progressCallback = params.attachment ? (progress: number, messageLocalId: number) => { const progressCallback = params.attachment ? (progress: number, messageKey: MessageKey) => {
if (!uploadProgressCallbacks.has(messageLocalId)) { if (!uploadProgressCallbacks.has(messageKey)) {
localId = messageLocalId; currentMessageKey = messageKey;
uploadProgressCallbacks.set(messageLocalId, progressCallback!); uploadProgressCallbacks.set(messageKey, progressCallback!);
} }
global = getGlobal(); global = getGlobal();
global = updateUploadByMessageKey(global, messageKey, progress);
global = {
...global,
fileUploads: {
byMessageLocalId: {
...global.fileUploads.byMessageLocalId,
[messageLocalId]: { progress },
},
},
};
setGlobal(global); setGlobal(global);
} : undefined; } : undefined;
@ -1377,8 +1405,12 @@ async function sendMessage<T extends GlobalState>(global: T, params: {
await callApi('sendMessage', params, progressCallback); await callApi('sendMessage', params, progressCallback);
if (progressCallback && localId) { if (progressCallback && currentMessageKey) {
uploadProgressCallbacks.delete(localId); global = getGlobal();
global = updateUploadByMessageKey(global, currentMessageKey, undefined);
setGlobal(global);
uploadProgressCallbacks.delete(currentMessageKey);
} }
} }

View File

@ -5,11 +5,11 @@ import { GENERAL_REFETCH_INTERVAL } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { buildCollectionByKey, omit } from '../../../util/iteratees';
import * as mediaLoader from '../../../util/mediaLoader'; import * as mediaLoader from '../../../util/mediaLoader';
import { getMessageKey } from '../../../util/messageKey';
import requestActionTimeout from '../../../util/requestActionTimeout'; import requestActionTimeout from '../../../util/requestActionTimeout';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { import {
getDocumentMediaHash, getDocumentMediaHash,
getMessageKey,
getUserReactions, getUserReactions,
isMessageLocal, isMessageLocal,
isSameReaction, isSameReaction,

View File

@ -12,10 +12,11 @@ import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { areDeepEqual } from '../../../util/areDeepEqual'; import { areDeepEqual } from '../../../util/areDeepEqual';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { omit, pickTruthy, unique } from '../../../util/iteratees'; import { omit, pickTruthy, unique } from '../../../util/iteratees';
import { getMessageKey } from '../../../util/messageKey';
import { notifyAboutMessage } from '../../../util/notifications'; import { notifyAboutMessage } from '../../../util/notifications';
import { onTickEnd } from '../../../util/schedulers'; import { onTickEnd } from '../../../util/schedulers';
import { import {
checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage, checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage, isLocalMessageId,
isMessageLocal, isUserId, isMessageLocal, isUserId,
} from '../../helpers'; } from '../../helpers';
import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies';
@ -278,7 +279,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = { global = {
...global, ...global,
fileUploads: { 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); : selectChatMessage(global, chatId, id);
// Preserve locally uploaded media. // Preserve locally uploaded media.
if (currentMessage && messageUpdate.content) { if (currentMessage && messageUpdate.content && !isLocalMessageId(id)) {
const { const {
photo, video, sticker, document, photo, video, sticker, document,
} = getMessageContent(currentMessage); } = getMessageContent(currentMessage);
if (photo && messageUpdate.content.photo) { if (photo && messageUpdate.content.photo) {
messageUpdate.content.photo.blobUrl = photo.blobUrl; messageUpdate.content.photo.blobUrl ??= photo.blobUrl;
messageUpdate.content.photo.thumbnail = photo.thumbnail; messageUpdate.content.photo.thumbnail ??= photo.thumbnail;
} else if (video && messageUpdate.content.video) { } else if (video && messageUpdate.content.video) {
messageUpdate.content.video.blobUrl = video.blobUrl; messageUpdate.content.video.blobUrl ??= video.blobUrl;
} else if (sticker && messageUpdate.content.sticker) { } else if (sticker && messageUpdate.content.sticker) {
messageUpdate.content.sticker.isPreloadedGlobally = sticker.isPreloadedGlobally; messageUpdate.content.sticker.isPreloadedGlobally ??= sticker.isPreloadedGlobally;
} else if (document && messageUpdate.content.document) { } else if (document && messageUpdate.content.document) {
messageUpdate.content.document.previewBlobUrl = document.previewBlobUrl; messageUpdate.content.document.previewBlobUrl ??= document.previewBlobUrl;
} }
} }

View File

@ -1,6 +1,7 @@
/* eslint-disable eslint-multitab-tt/no-immediate-global */ /* eslint-disable eslint-multitab-tt/no-immediate-global */
import { addCallback, removeCallback } from '../lib/teact/teactn'; import { addCallback, removeCallback } from '../lib/teact/teactn';
import type { ApiMessage } from '../api/types';
import type { ActionReturnType, GlobalState, MessageList } from './types'; import type { ActionReturnType, GlobalState, MessageList } from './types';
import { MAIN_THREAD_ID } from '../api/types'; import { MAIN_THREAD_ID } from '../api/types';
@ -219,6 +220,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
untypedCached.appConfig.peerColors = undefined; untypedCached.appConfig.peerColors = undefined;
untypedCached.appConfig.darkPeerColors = undefined; untypedCached.appConfig.darkPeerColors = undefined;
} }
if (!cached.fileUploads.byMessageKey) {
cached.fileUploads.byMessageKey = {};
}
} }
function updateCache() { function updateCache() {
@ -458,8 +463,16 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
return acc; return acc;
}, {} as GlobalState['messages']['byChatId'][string]['threadsById']); }, {} 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<number, ApiMessage>);
byChatId[chatId] = { byChatId[chatId] = {
byId, byId: cleanedById,
threadsById, threadsById,
}; };
}); });
@ -470,6 +483,33 @@ function reduceMessages<T extends GlobalState>(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<T extends GlobalState>(global: T): GlobalState['settings'] { function reduceSettings<T extends GlobalState>(global: T): GlobalState['settings'] {
const { byKey, themes, performance } = global.settings; const { byKey, themes, performance } = global.settings;

View File

@ -1,4 +1,5 @@
import type { import type {
ApiAttachment,
ApiAudio, ApiAudio,
ApiDimensions, ApiDimensions,
ApiDocument, ApiDocument,
@ -13,6 +14,7 @@ import type {
} from '../../api/types'; } from '../../api/types';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import { getMessageKey } from '../../util/messageKey';
import { import {
IS_OPFS_SUPPORTED, IS_OPFS_SUPPORTED,
IS_OPUS_SUPPORTED, IS_OPUS_SUPPORTED,
@ -21,7 +23,7 @@ import {
MAX_BUFFER_SIZE, MAX_BUFFER_SIZE,
} from '../../util/windowEnvironment'; } from '../../util/windowEnvironment';
import { getDocumentHasPreview } from '../../components/common/helpers/documentInfo'; import { getDocumentHasPreview } from '../../components/common/helpers/documentInfo';
import { getMessageKey, isMessageLocal, matchLinkInMessageText } from './messages'; import { getAttachmentType, matchLinkInMessageText } from './messages';
type MediaContainer = { type MediaContainer = {
content: MediaContent; 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) { export function getMessagePhoto(message: MediaContainer) {
return message.content.photo; return message.content.photo;
} }
@ -101,6 +114,11 @@ export function isMessageDocumentVideo(message: MediaContainer) {
return document ? document.mediaType === 'video' : undefined; 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) { export function getMessageContact(message: MediaContainer) {
return message.content.contact; return message.content.contact;
} }
@ -423,8 +441,9 @@ export function getVideoDimensions(video: ApiVideo): ApiDimensions | undefined {
return undefined; return undefined;
} }
export function getMediaTransferState(message: ApiMessage, progress?: number, isLoadNeeded = false) { export function getMediaTransferState(
const isUploading = isMessageLocal(message); message: ApiMessage, progress?: number, isLoadNeeded = false, isUploading = false,
) {
const isTransferring = isUploading || isLoadNeeded; const isTransferring = isUploading || isLoadNeeded;
const transferProgress = Number(progress); const transferProgress = Number(progress);
@ -499,3 +518,18 @@ export function getMediaDuration(message: ApiMessage) {
return media.duration; 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'))
);
}

View File

@ -1,15 +1,17 @@
import type { import type {
ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiPeer, ApiStory, ApiUser, ApiAttachment, ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiPeer, ApiStory, ApiUser,
} from '../../api/types'; } from '../../api/types';
import type { LangFn } from '../../hooks/useLang'; import type { LangFn } from '../../hooks/useLang';
import { ApiMessageEntityTypes } from '../../api/types'; import { ApiMessageEntityTypes } from '../../api/types';
import { import {
CONTENT_NOT_SUPPORTED, CONTENT_NOT_SUPPORTED, LOTTIE_STICKER_MIME_TYPE,
RE_LINK_TEMPLATE, 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'; } from '../../config';
import { areSortedArraysIntersecting, unique } from '../../util/iteratees'; import { areSortedArraysIntersecting, unique } from '../../util/iteratees';
import { getMessageKey } from '../../util/messageKey';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { IS_OPUS_SUPPORTED } from '../../util/windowEnvironment'; import { IS_OPUS_SUPPORTED } from '../../util/windowEnvironment';
import { getGlobal } from '../index'; import { getGlobal } from '../index';
@ -18,28 +20,10 @@ import { getUserFullName } from './users';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
export type MessageKey = `msg${string}-${number}`;
export function getMessageHtmlId(messageId: number) { export function getMessageHtmlId(messageId: number) {
return `message${messageId.toString().replace('.', '-')}`; 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) { export function getMessageOriginalId(message: ApiMessage) {
return message.previousLocalId || message.id; return message.previousLocalId || message.id;
} }
@ -358,3 +342,26 @@ export function hasMessageTtl(message: ApiMessage) {
export function isJoinedChannelMessage(message: ApiMessage) { export function isJoinedChannelMessage(message: ApiMessage) {
return message.content.action && message.content.action.type === 'joinedChannel'; 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;
}

View File

@ -144,7 +144,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
}, },
fileUploads: { fileUploads: {
byMessageLocalId: {}, byMessageKey: {},
}, },
recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'], recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'],

View File

@ -1,16 +1,13 @@
import type { import type { ApiMessage, ApiSponsoredMessage, ApiThreadInfo } from '../../api/types';
ApiMessage, ApiSponsoredMessage, ApiThreadInfo,
} from '../../api/types';
import type { FocusDirection, ThreadId } from '../../types'; import type { FocusDirection, ThreadId } from '../../types';
import type { MessageKey } from '../../util/messageKey';
import type { import type {
GlobalState, MessageList, MessageListType, TabArgs, TabThread, GlobalState, MessageList, MessageListType, TabArgs, TabThread, Thread,
Thread,
} from '../types'; } from '../types';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { import {
IS_MOCKED_CLIENT, IS_MOCKED_CLIENT, IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID,
IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID,
} from '../../config'; } from '../../config';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { import {
@ -32,7 +29,9 @@ import {
selectOutlyingLists, selectOutlyingLists,
selectPinnedIds, selectPinnedIds,
selectScheduledIds, selectScheduledIds,
selectTabState, selectThreadIdFromMessage, selectThreadInfo, selectTabState,
selectThreadIdFromMessage,
selectThreadInfo,
selectViewportIds, selectViewportIds,
} from '../selectors'; } from '../selectors';
import { updateTabState } from './tabs'; import { updateTabState } from './tabs';
@ -788,3 +787,21 @@ export function cancelMessageMediaDownload<T extends GlobalState>(
return global; return global;
} }
export function updateUploadByMessageKey<T extends GlobalState>(
global: T,
messageKey: MessageKey,
progress: number | undefined,
) {
return {
...global,
fileUploads: {
byMessageKey: progress !== undefined
? {
...global.fileUploads.byMessageKey,
[messageKey]: { progress },
}
: omit(global.fileUploads.byMessageKey, [messageKey]),
},
};
}

View File

@ -22,6 +22,7 @@ import {
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { findLast } from '../../util/iteratees'; import { findLast } from '../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import { getMessageKey } from '../../util/messageKey';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment'; import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment';
import { import {
@ -32,9 +33,7 @@ import {
getIsSavedDialog, getIsSavedDialog,
getMainUsername, getMainUsername,
getMessageAudio, getMessageAudio,
getMessageDocument, getMessageDocument, getMessagePhoto,
getMessageOriginalId,
getMessagePhoto,
getMessageVideo, getMessageVideo,
getMessageVoice, getMessageVoice,
getMessageWebPagePhoto, getMessageWebPagePhoto,
@ -48,7 +47,10 @@ import {
isChatSuperGroup, isChatSuperGroup,
isCommonBoxChat, isCommonBoxChat,
isForwardedMessage, isForwardedMessage,
isLocalMessageId, isMessageFailed, isMessageLocal, isLocalMessageId,
isMessageDocumentSticker,
isMessageFailed,
isMessageLocal,
isMessageTranslatable, isMessageTranslatable,
isOwnMessage, isOwnMessage,
isServiceNotificationMessage, isServiceNotificationMessage,
@ -588,6 +590,7 @@ export function selectAllowedMessageActions<T extends GlobalState>(global: T, me
const hasTtl = hasMessageTtl(message); const hasTtl = hasMessageTtl(message);
const { content } = message; const { content } = message;
const messageTopic = selectTopicFromMessage(global, message); const messageTopic = selectTopicFromMessage(global, message);
const isDocumentSticker = isMessageDocumentSticker(message);
const canEditMessagesIndefinitely = isChatWithSelf const canEditMessagesIndefinitely = isChatWithSelf
|| (isSuperGroup && getHasAdminRight(chat, 'pinMessages')) || (isSuperGroup && getHasAdminRight(chat, 'pinMessages'))
@ -597,8 +600,9 @@ export function selectAllowedMessageActions<T extends GlobalState>(global: T, me
canEditMessagesIndefinitely canEditMessagesIndefinitely
|| getServerTime() - message.date < MESSAGE_EDIT_ALLOWED_TIME || 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 || (content.video?.isRound) || content.location || content.invoice || content.giveaway || content.giveawayResults
|| isDocumentSticker
) )
&& !isForwarded && !isForwarded
&& !message.viaBotId && !message.viaBotId
@ -801,7 +805,7 @@ export function selectActiveDownloads<T extends GlobalState>(
} }
export function selectUploadProgress<T extends GlobalState>(global: T, message: ApiMessage) { export function selectUploadProgress<T extends GlobalState>(global: T, message: ApiMessage) {
return global.fileUploads.byMessageLocalId[getMessageOriginalId(message)]?.progress; return global.fileUploads.byMessageKey[getMessageKey(message)]?.progress;
} }
export function selectRealLastReadId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) { export function selectRealLastReadId<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {

View File

@ -865,7 +865,7 @@ export type GlobalState = {
phoneCall?: ApiPhoneCall; phoneCall?: ApiPhoneCall;
fileUploads: { fileUploads: {
byMessageLocalId: Record<string, { byMessageKey: Record<string, {
progress: number; progress: number;
}>; }>;
}; };
@ -1352,7 +1352,7 @@ export interface ActionPayloads {
chatId: string; chatId: string;
userIds: string[]; userIds: string[];
} & WithTabId; } & WithTabId;
cancelSendingMessage: { cancelUploadMedia: {
chatId: string; chatId: string;
messageId: number; messageId: number;
}; };
@ -1382,8 +1382,9 @@ export interface ActionPayloads {
}; };
}; };
editMessage: { editMessage: {
messageList: MessageList; messageList?: MessageList;
text: string; text: string;
attachments?: ApiAttachment[];
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
} & WithTabId; } & WithTabId;
deleteHistory: { deleteHistory: {

View File

@ -11,7 +11,8 @@ export async function parseAudioMetadata(url: string): Promise<AudioMetadata> {
const { common: { title, artist, picture }, format: { duration } } = metadata; const { common: { title, artist, picture }, format: { duration } } = metadata;
const cover = selectCover(picture); 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 { return {
title, title,

View File

@ -1,12 +1,12 @@
import { getActions, getGlobal } from '../global'; import { getActions, getGlobal } from '../global';
import type { ApiMessage } from '../api/types'; import type { ApiMessage } from '../api/types';
import type { MessageKey } from '../global/helpers'; import type { MessageKey } from './messageKey';
import { AudioOrigin, GlobalSearchContent } from '../types'; import { AudioOrigin, GlobalSearchContent } from '../types';
import { requestNextMutation } from '../lib/fasterdom/fasterdom'; import { requestNextMutation } from '../lib/fasterdom/fasterdom';
import { getMessageKey, parseMessageKey } from '../global/helpers';
import { selectCurrentMessageList, selectTabState } from '../global/selectors'; import { selectCurrentMessageList, selectTabState } from '../global/selectors';
import { getMessageKey, parseMessageKey } from './messageKey';
import { isSafariPatchInProgress, patchSafariProgressiveAudio } from './patchSafariProgressiveAudio'; import { isSafariPatchInProgress, patchSafariProgressiveAudio } from './patchSafariProgressiveAudio';
import safePlay from './safePlay'; import safePlay from './safePlay';
import { IS_SAFARI } from './windowEnvironment'; import { IS_SAFARI } from './windowEnvironment';

View File

@ -16,6 +16,9 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
YOU_BLOCKED_USER: 'You blocked this user', YOU_BLOCKED_USER: 'You blocked this user',
IMAGE_PROCESS_FAILED: 'Failure while processing image', IMAGE_PROCESS_FAILED: 'Failure while processing image',
MEDIA_EMPTY: 'The provided media object is invalid', 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', MEDIA_INVALID: 'Media invalid',
PHOTO_EXT_INVALID: 'The extension of the photo is invalid', PHOTO_EXT_INVALID: 'The extension of the photo is invalid',
PHOTO_INVALID_DIMENSIONS: 'The photo dimensions are invalid', PHOTO_INVALID_DIMENSIONS: 'The photo dimensions are invalid',

19
src/util/messageKey.ts Normal file
View File

@ -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]) };
}