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,
): MediaContent {
const {

View File

@ -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;

View File

@ -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<ApiMessage> = {
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<ApiMessage> = {
...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));
}
};

View File

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

View File

@ -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<OwnProps & StateProps> = ({
openStoryReactionPicker,
closeReactionPicker,
sendStoryReaction,
editMessage,
} = getActions();
const lang = useLang();
@ -401,6 +403,8 @@ const Composer: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
canSendPhotos,
canSendDocuments,
insertNextText,
editedMessage: editingMessage,
});
const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag();
@ -891,16 +896,25 @@ const Composer: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
withQuick={dropAreaState === DropAreaState.QuickFile || prevDropAreaState === DropAreaState.QuickFile}
onHide={onDropHide!}
onFileSelect={handleFileSelect}
editingMessage={editingMessage}
/>
)}
{shouldRenderReactionSelector && (
@ -1535,6 +1550,7 @@ const Composer: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect={handleCustomEmojiSelectAttachmentModal}
onRemoveSymbol={removeSymbolAttachmentModal}
onEmojiSelect={insertTextAndUpdateCursorAttachmentModal}
editingMessage={editingMessage}
/>
<PollModal
isOpen={pollModal.isOpen}
@ -1763,7 +1779,9 @@ const Composer: FC<OwnProps & StateProps> = ({
<AttachMenu
chatId={chatId}
threadId={threadId}
isButtonVisible={!activeVoiceRecording && !editingMessage}
editingMessage={editingMessage}
hasReplaceableMedia={canMediaBeReplaced}
isButtonVisible={!activeVoiceRecording}
canAttachMedia={canAttachMedia}
canAttachPolls={canAttachPolls}
canSendPhotos={canSendPhotos}

View File

@ -7,10 +7,7 @@ import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import {
SUPPORTED_IMAGE_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../config';
import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES } from '../../config';
import {
getMediaTransferState,
getMessageMediaFormat,
@ -109,7 +106,12 @@ const Document: FC<OwnProps> = ({
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;

View File

@ -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';

View File

@ -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';

View File

@ -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<OwnProps> = ({
@ -70,6 +80,8 @@ const AttachMenu: FC<OwnProps> = ({
onMenuOpen,
onMenuClose,
onPollCreate,
hasReplaceableMedia,
editingMessage,
}) => {
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu);
@ -80,6 +92,12 @@ const AttachMenu: FC<OwnProps> = ({
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<OwnProps> = ({
return (
<div className="AttachMenu">
<ResponsiveHoverButton
id="attach-menu-button"
className={isAttachMenuOpen ? 'AttachMenu--button activated' : 'AttachMenu--button'}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Add an attachment"
ariaControls="attach-menu-controls"
hasPopup
>
<i className="icon icon-attach" />
</ResponsiveHoverButton>
{
editingMessage && hasReplaceableMedia ? (
<ResponsiveHoverButton
id="replace-menu-button"
className={isAttachMenuOpen ? 'AttachMenu--button activated' : 'AttachMenu--button'}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Replace an attachment"
ariaControls="replace-menu-controls"
hasPopup
>
<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
id="attach-menu-controls"
isOpen={isMenuOpen}
@ -187,13 +223,13 @@ const AttachMenu: FC<OwnProps> = ({
)}
{canAttachMedia && (
<>
{canSendVideoOrPhoto && (
{canSendVideoOrPhoto && !isFile && (
<MenuItem icon="photo" onClick={handleQuickSelect}>
{lang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo'
: (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))}
</MenuItem>
)}
{(canSendDocuments || canSendAudios)
{((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
&& (
<MenuItem icon="document" onClick={handleDocumentSelect}>
{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>
)}
{canAttachMedia && !isScheduled && bots?.map((bot) => (
{!editingMessage && !hasReplaceableMedia && !isScheduled && bots?.map((bot) => (
<AttachBotItem
bot={bot}
chatId={chatId}

View File

@ -5,9 +5,9 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import type {
ApiAttachment, ApiChatMember, ApiSticker,
ApiAttachment, ApiChatMember, ApiMessage, ApiSticker,
} from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { GlobalState, MessageListType } from '../../../global/types';
import type { ThreadId } from '../../../types';
import type { Signal } from '../../../util/signals';
@ -20,7 +20,7 @@ import {
SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { isUserId } from '../../../global/helpers';
import { getAttachmentType, isUserId } from '../../../global/helpers';
import { selectChatFullInfo, selectIsChatWithSelf } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import buildClassName from '../../../util/buildClassName';
@ -62,6 +62,8 @@ export type OwnProps = {
chatId: string;
threadId: ThreadId;
attachments: ApiAttachment[];
editingMessage?: ApiMessage;
messageListType?: MessageListType;
getHtml: Signal<string>;
canShowCustomSendMenu?: boolean;
isReady: boolean;
@ -89,6 +91,7 @@ type StateProps = {
currentUserId?: string;
groupChatMembers?: ApiChatMember[];
recentEmojis: string[];
editingMessage?: ApiMessage;
baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>;
shouldSuggestCustomEmoji?: boolean;
@ -106,6 +109,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
threadId,
attachments,
getHtml,
editingMessage,
canShowCustomSendMenu,
captionLimit,
isReady,
@ -150,12 +154,19 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
<i className="icon icon-close" />
</Button>
<div className="modal-title">{title}</div>
<DropdownMenu
className="attachment-modal-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem>
{hasMedia && (
<>
{
!shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? (
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="document" onClick={() => setShouldSendCompressed(false)}>
{lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')}
{notEditingFile && !isInAlbum
&& (
<DropdownMenu
className="attachmeneditingMessaget-modal-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>
{Boolean(!editingMessage) && (
<MenuItem icon="add" onClick={handleDocumentSelect}>{lang('Add')}</MenuItem>
)}
{hasMedia && (
<>
{
!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>
) : (
// 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 && (
hasSpoiler ? (
<MenuItem icon="spoiler-disable" onClick={handleDisableSpoilers}>
{lang('Attachment.DisableSpoiler')}
</MenuItem>
) : (
<MenuItem icon="spoiler" onClick={handleEnableSpoilers}>
{lang('Attachment.EnableSpoiler')}
// eslint-disable-next-line react/jsx-no-bind
<MenuItem icon="grouped" onClick={() => setShouldSendGrouped(true)}>
Group All Media
</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>
);
}
@ -622,7 +638,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onClick={handleSendClick}
onContextMenu={canShowCustomSendMenu ? handleContextMenu : undefined}
>
{shouldSchedule ? lang('Next') : lang('Send')}
{shouldSchedule && !editingMessage ? lang('Next') : editingMessage ? lang('Save') : lang('Send')}
</Button>
{canShowCustomSendMenu && (
<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 (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) {
return 'image';

View File

@ -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<OwnProps> = ({
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<number>(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<OwnProps> = ({
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);
}

View File

@ -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<boolean>(false);
const [shouldForceCompression, setShouldForceCompression] = useState<boolean>(false);
const [shouldSuggestCompression, setShouldSuggestCompression] = useState<boolean | undefined>(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';
}

View File

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

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps>(
return {
theme,
uploadsById: global.fileUploads.byMessageLocalId,
uploadsByKey: global.fileUploads.byMessageKey,
activeDownloadIds: isScheduled ? activeDownloads?.scheduledIds : activeDownloads?.ids,
};
},

View File

@ -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 {

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress),
shouldLoad && !fullMediaData,
uploadProgress !== undefined,
);
const wasLoadDisabled = usePrevious(isLoadAllowed) === false;

View File

@ -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';

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress),
(shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading,
uploadProgress !== undefined,
);
const wasLoadDisabled = usePrevious(isLoadAllowed) === false;

View File

@ -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[]) => {

View File

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

View File

@ -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<number, ApiOnProgress>();
const uploadProgressCallbacks = new Map<MessageKey, ApiOnProgress>();
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<T extends GlobalState>(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<T extends GlobalState>(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);
}
}

View File

@ -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,

View File

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

View File

@ -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<T extends GlobalState>(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<number, ApiMessage>);
byChatId[chatId] = {
byId,
byId: cleanedById,
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'] {
const { byKey, themes, performance } = global.settings;

View File

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

View File

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

View File

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

View File

@ -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<T extends GlobalState>(
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 { 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<T extends GlobalState>(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<T extends GlobalState>(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<T extends GlobalState>(
}
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) {

View File

@ -865,7 +865,7 @@ export type GlobalState = {
phoneCall?: ApiPhoneCall;
fileUploads: {
byMessageLocalId: Record<string, {
byMessageKey: Record<string, {
progress: number;
}>;
};
@ -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: {

View File

@ -11,7 +11,8 @@ export async function parseAudioMetadata(url: string): Promise<AudioMetadata> {
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,

View File

@ -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';

View File

@ -16,6 +16,9 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
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',

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