From a3668e8b3dfc708766cb01ae871b7b5d182b8979 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 22 Oct 2021 02:24:34 +0300 Subject: [PATCH] Message: Support downloading all media with context menu and select mode (#1397) --- src/components/common/Audio.tsx | 47 ++++++---- src/components/common/Document.tsx | 45 +++++----- src/components/left/search/AudioResults.tsx | 2 + src/components/left/search/FileResults.tsx | 2 + .../search/helpers/createMapStateToProps.ts | 4 + .../left/settings/WallpaperTile.tsx | 18 ++-- src/components/main/DownloadManager.tsx | 81 +++++++++++++++++ src/components/main/Main.tsx | 2 + src/components/mediaViewer/MediaViewer.tsx | 6 +- .../mediaViewer/MediaViewerActions.tsx | 64 ++++++++++---- src/components/mediaViewer/VideoPlayer.tsx | 6 +- .../mediaViewer/VideoPlayerControls.tsx | 4 +- .../middle/MessageSelectToolbar.scss | 47 ++++++++-- .../middle/MessageSelectToolbar.tsx | 80 ++++++++++------- src/components/middle/message/Album.tsx | 11 ++- .../middle/message/ContextMenuContainer.tsx | 38 +++++++- src/components/middle/message/Message.tsx | 11 +++ .../middle/message/MessageContextMenu.tsx | 11 +++ src/components/middle/message/Photo.tsx | 32 ++++--- src/components/middle/message/RoundVideo.tsx | 41 ++++++--- src/components/middle/message/Video.tsx | 46 ++++++---- src/components/middle/message/WebPage.tsx | 4 + .../middle/message/_message-content.scss | 2 +- src/components/right/Profile.scss | 2 +- src/components/right/Profile.tsx | 9 ++ src/global/cache.ts | 6 ++ src/global/initial.ts | 4 + src/global/types.ts | 6 ++ src/hooks/useAudioPlayer.ts | 4 +- src/hooks/useMediaDownload.ts | 37 -------- ...rogress.ts => useMediaWithLoadProgress.ts} | 35 +++++--- src/hooks/useUniqueId.ts | 15 ++++ src/modules/actions/ui/messages.ts | 47 ++++++++++ src/modules/helpers/messageMedia.ts | 19 ++-- src/modules/helpers/messages.ts | 47 +++++++++- src/modules/selectors/messages.ts | 34 ++++++- src/util/audioPlayer.ts | 8 +- src/util/download.ts | 7 +- src/util/mediaLoader.ts | 88 ++++++++++++++----- 39 files changed, 725 insertions(+), 247 deletions(-) create mode 100644 src/components/main/DownloadManager.tsx delete mode 100644 src/hooks/useMediaDownload.ts rename src/hooks/{useMediaWithDownloadProgress.ts => useMediaWithLoadProgress.ts} (72%) create mode 100644 src/hooks/useUniqueId.ts diff --git a/src/components/common/Audio.tsx b/src/components/common/Audio.tsx index 9a6b53cb..40b0c86d 100644 --- a/src/components/common/Audio.tsx +++ b/src/components/common/Audio.tsx @@ -1,6 +1,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; +import { getDispatch } from '../../lib/teact/teactn'; import { ApiAudio, ApiMediaFormat, ApiMessage, ApiVoice, @@ -12,7 +13,6 @@ import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '. import { getMediaDuration, getMediaTransferState, - getMessageAudioCaption, getMessageMediaFormat, getMessageMediaHash, isMessageLocal, @@ -23,11 +23,10 @@ import buildClassName from '../../util/buildClassName'; import renderText from './helpers/renderText'; import { getFileSizeString } from './helpers/documentInfo'; import { decodeWaveform, interpolateArray } from '../../util/waveform'; -import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useShowTransition from '../../hooks/useShowTransition'; import useBuffering from '../../hooks/useBuffering'; import useAudioPlayer from '../../hooks/useAudioPlayer'; -import useMediaDownload from '../../hooks/useMediaDownload'; import useLang, { LangFn } from '../../hooks/useLang'; import { captureEvents } from '../../util/captureEvents'; import useMedia from '../../hooks/useMedia'; @@ -51,6 +50,7 @@ type OwnProps = { className?: string; isSelectable?: boolean; isSelected?: boolean; + isDownloading: boolean; onPlay: (messageId: number, chatId: number) => void; onReadMedia?: () => void; onCancelUpload?: () => void; @@ -74,6 +74,7 @@ const Audio: FC = ({ className, isSelectable, isSelected, + isDownloading, onPlay, onReadMedia, onCancelUpload, @@ -87,18 +88,24 @@ const Audio: FC = ({ const seekerRef = useRef(null); const lang = useLang(); const { isRtl } = lang; + const dispatch = getDispatch(); const [isActivated, setIsActivated] = useState(false); - const shouldDownload = (isActivated || PRELOAD) && lastSyncTime; + const shouldLoad = (isActivated || PRELOAD) && lastSyncTime; const coverHash = getMessageMediaHash(message, 'pictogram'); const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); - const { mediaData, downloadProgress } = useMediaWithDownloadProgress( + const mediaData = useMedia( getMessageMediaHash(message, 'inline'), - !shouldDownload, + !shouldLoad, getMessageMediaFormat(message, 'inline'), ); + const { loadProgress: downloadProgress } = useMediaWithLoadProgress( + getMessageMediaHash(message, 'download'), + !isDownloading, + ); + const handleForcePlay = useCallback(() => { setIsActivated(true); onPlay(message.id, message.chatId); @@ -135,20 +142,14 @@ const Audio: FC = ({ setIsActivated(isPlaying); }, [isPlaying]); - const { - isDownloadStarted, - downloadProgress: directDownloadProgress, - handleDownloadClick, - } = useMediaDownload(getMessageMediaHash(message, 'download'), getMessageAudioCaption(message)); - const isLoadingForPlaying = isActivated && !isBuffered; const { isUploading, isTransferring, transferProgress, } = getMediaTransferState( message, - isDownloadStarted ? directDownloadProgress : (uploadProgress || downloadProgress), - isLoadingForPlaying || isDownloadStarted, + uploadProgress || downloadProgress, + isLoadingForPlaying || isDownloading, ); const { @@ -173,10 +174,18 @@ const Audio: FC = ({ }, [isPlaying, isUploading, message.id, message.chatId, onCancelUpload, onPlay, playPause, isActivated]); useEffect(() => { - if (isPlaying && onReadMedia && isMediaUnread) { + if (onReadMedia && isMediaUnread && (isPlaying || isDownloading)) { onReadMedia(); } - }, [isPlaying, isMediaUnread, onReadMedia]); + }, [isPlaying, isMediaUnread, onReadMedia, isDownloading]); + + const handleDownloadClick = useCallback(() => { + if (isDownloading) { + dispatch.cancelMessageMediaDownload({ message }); + } else { + dispatch.downloadMessageMedia({ message }); + } + }, [dispatch, isDownloading, message]); const handleSeek = useCallback((e: MouseEvent | TouchEvent) => { if (isSeeking.current && seekerRef.current) { @@ -348,15 +357,15 @@ const Audio: FC = ({ round size="tiny" className="download-button" - ariaLabel={isDownloadStarted ? 'Cancel download' : 'Download'} + ariaLabel={isDownloading ? 'Cancel download' : 'Download'} onClick={handleDownloadClick} > - + )} {origin === AudioOrigin.Search && renderWithTitle()} {origin !== AudioOrigin.Search && audio && renderAudio( - lang, audio, duration, isPlaying, playProgress, bufferedProgress, seekerRef, (isDownloadStarted || isUploading), + lang, audio, duration, isPlaying, playProgress, bufferedProgress, seekerRef, (isDownloading || isUploading), date, transferProgress, onDateClick ? handleDateClick : undefined, )} {origin === AudioOrigin.SharedMedia && (voice || video) && renderWithTitle()} diff --git a/src/components/common/Document.tsx b/src/components/common/Document.tsx index bfd6c96f..75c736b1 100644 --- a/src/components/common/Document.tsx +++ b/src/components/common/Document.tsx @@ -1,6 +1,7 @@ import React, { - FC, useCallback, useEffect, useState, memo, useRef, + FC, useCallback, memo, useRef, } from '../../lib/teact/teact'; +import { getDispatch } from '../../lib/teact/teactn'; import { ApiMediaFormat, ApiMessage } from '../../api/types'; @@ -12,9 +13,8 @@ import { isMessageDocumentVideo, } from '../../modules/helpers'; import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver'; -import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useMedia from '../../hooks/useMedia'; -import download from '../../util/download'; import File from './File'; @@ -29,6 +29,7 @@ type OwnProps = { datetime?: number; className?: string; sender?: string; + isDownloading: boolean; onCancelUpload?: () => void; onMediaClick?: () => void; onDateClick?: (messageId: number, chatId: number) => void; @@ -48,6 +49,7 @@ const Document: FC = ({ onCancelUpload, onMediaClick, onDateClick, + isDownloading, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -58,16 +60,14 @@ const Document: FC = ({ const withMediaViewer = onMediaClick && Boolean(document.mediaType); const isIntersecting = useIsIntersecting(ref, observeIntersection); + const dispatch = getDispatch(); - const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); - const { - mediaData, downloadProgress, - } = useMediaWithDownloadProgress( - getMessageMediaHash(message, 'download'), !isDownloadAllowed, undefined, undefined, undefined, true, + const { loadProgress: downloadProgress } = useMediaWithLoadProgress( + getMessageMediaHash(message, 'download'), !isDownloading, undefined, undefined, undefined, true, ); const { isUploading, isTransferring, transferProgress, - } = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloadAllowed); + } = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloading); const hasPreview = getDocumentHasPreview(document); const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined; @@ -75,28 +75,29 @@ const Document: FC = ({ const previewData = useMedia(getMessageMediaHash(message, 'pictogram'), !isIntersecting); const handleClick = useCallback(() => { - if (withMediaViewer) { - onMediaClick!(); - } else if (isUploading) { + if (isDownloading) { + dispatch.cancelMessageMediaDownload({ message }); + return; + } + + if (isUploading) { if (onCancelUpload) { onCancelUpload(); } - } else { - setIsDownloadAllowed((isAllowed) => !isAllowed); + return; } - }, [withMediaViewer, isUploading, onCancelUpload, onMediaClick]); + + if (withMediaViewer) { + onMediaClick!(); + } else { + dispatch.downloadMessageMedia({ message }); + } + }, [withMediaViewer, isUploading, isDownloading, onMediaClick, onCancelUpload, dispatch, message]); const handleDateClick = useCallback(() => { onDateClick!(message.id, message.chatId); }, [onDateClick, message.id, message.chatId]); - useEffect(() => { - if (isDownloadAllowed && mediaData) { - download(mediaData, fileName); - setIsDownloadAllowed(false); - } - }, [fileName, mediaData, isDownloadAllowed]); - return ( = ({ globalMessagesByChatId, foundIds, lastSyncTime, + activeDownloads, searchMessagesGlobal, focusMessage, openAudioPlayer, @@ -104,6 +105,7 @@ const AudioResults: FC = ({ className="scroll-item" onPlay={handlePlayAudio} onDateClick={handleMessageFocus} + isDownloading={activeDownloads[message.chatId]?.includes(message.id)} /> ); diff --git a/src/components/left/search/FileResults.tsx b/src/components/left/search/FileResults.tsx index 94bd67e1..7cf9429d 100644 --- a/src/components/left/search/FileResults.tsx +++ b/src/components/left/search/FileResults.tsx @@ -40,6 +40,7 @@ const FileResults: FC = ({ usersById, globalMessagesByChatId, foundIds, + activeDownloads, lastSyncTime, searchMessagesGlobal, focusMessage, @@ -94,6 +95,7 @@ const FileResults: FC = ({ sender={getSenderName(lang, message, chatsById, usersById)} className="scroll-item" onDateClick={handleMessageFocus} + isDownloading={activeDownloads[message.chatId]?.includes(message.id)} /> ); diff --git a/src/components/left/search/helpers/createMapStateToProps.ts b/src/components/left/search/helpers/createMapStateToProps.ts index f59438de..6d3a9fe4 100644 --- a/src/components/left/search/helpers/createMapStateToProps.ts +++ b/src/components/left/search/helpers/createMapStateToProps.ts @@ -15,6 +15,7 @@ export type StateProps = { foundIds?: string[]; lastSyncTime?: number; searchChatId?: number; + activeDownloads: Record; }; export function createMapStateToProps(type: ApiGlobalMessageSearchType) { @@ -33,6 +34,8 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) { const { byChatId: globalMessagesByChatId } = global.messages; const foundIds = resultsByType?.[currentType]?.foundIds; + const activeDownloads = global.activeDownloads.byChatId; + return { theme: selectTheme(global), isLoading: foundIds === undefined @@ -42,6 +45,7 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) { globalMessagesByChatId, foundIds, searchChatId: chatId, + activeDownloads, lastSyncTime: global.lastSyncTime, }; }; diff --git a/src/components/left/settings/WallpaperTile.tsx b/src/components/left/settings/WallpaperTile.tsx index c2a7c51b..434ec0d1 100644 --- a/src/components/left/settings/WallpaperTile.tsx +++ b/src/components/left/settings/WallpaperTile.tsx @@ -10,7 +10,7 @@ import { fetchBlob } from '../../../util/files'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import buildClassName from '../../../util/buildClassName'; import useMedia from '../../../hooks/useMedia'; -import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useShowTransition from '../../../hooks/useShowTransition'; import usePrevious from '../../../hooks/usePrevious'; import useCanvasBlur from '../../../hooks/useCanvasBlur'; @@ -40,15 +40,15 @@ const WallpaperTile: FC = ({ const { shouldRenderThumb, shouldRenderFullMedia, transitionClassNames, } = useTransitionForMedia(previewBlobUrl || localBlobUrl, 'slow'); - const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); + const [isLoadAllowed, setIsLoadAllowed] = useState(false); const { - mediaData: fullMedia, downloadProgress, - } = useMediaWithDownloadProgress(localMediaHash, !isDownloadAllowed); - const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; + mediaData: fullMedia, loadProgress, + } = useMediaWithLoadProgress(localMediaHash, !isLoadAllowed); + const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames } = useShowTransition( - (isDownloadAllowed && !fullMedia) || slug === UPLOADING_WALLPAPER_SLUG, + (isLoadAllowed && !fullMedia) || slug === UPLOADING_WALLPAPER_SLUG, undefined, - wasDownloadDisabled, + wasLoadDisabled, 'slow', ); // To prevent triggering of the effect for useCallback @@ -73,7 +73,7 @@ const WallpaperTile: FC = ({ if (fullMedia) { handleSelect(); } else { - setIsDownloadAllowed((isAllowed) => !isAllowed); + setIsLoadAllowed((isAllowed) => !isAllowed); } }, [fullMedia, handleSelect]); @@ -100,7 +100,7 @@ const WallpaperTile: FC = ({ )} {shouldRenderSpinner && (
- +
)} diff --git a/src/components/main/DownloadManager.tsx b/src/components/main/DownloadManager.tsx new file mode 100644 index 00000000..98b4bacc --- /dev/null +++ b/src/components/main/DownloadManager.tsx @@ -0,0 +1,81 @@ +import { FC, memo, useEffect } from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions, Thread } from '../../global/types'; +import { ApiMediaFormat, ApiMessage } from '../../api/types'; + +import * as mediaLoader from '../../util/mediaLoader'; +import download from '../../util/download'; +import { + getMessageContentFilename, getMessageMediaHash, +} from '../../modules/helpers'; +import { pick } from '../../util/iteratees'; + +type StateProps = { + activeDownloads: Record; + messages: Record; + threadsById: Record; + }>; +}; + +type DispatchProps = Pick; + +const startedDownloads = new Set(); + +const DownloadsManager: FC = ({ + activeDownloads, + messages, + cancelMessageMediaDownload, +}) => { + useEffect(() => { + Object.entries(activeDownloads).forEach(([chatId, messageIds]) => { + const activeMessages = messageIds.map((id) => messages[Number(chatId)].byId[id]); + activeMessages.forEach((message) => { + const downloadHash = getMessageMediaHash(message, 'download'); + if (!downloadHash) { + cancelMessageMediaDownload({ message }); + return; + } + + if (!startedDownloads.has(downloadHash)) { + const mediaData = mediaLoader.getFromMemory(downloadHash); + if (mediaData) { + startedDownloads.delete(downloadHash); + download(mediaData, getMessageContentFilename(message)); + cancelMessageMediaDownload({ message }); + return; + } + + mediaLoader.fetch(downloadHash, ApiMediaFormat.BlobUrl, true).then((result) => { + startedDownloads.delete(downloadHash); + if (result) { + download(result, getMessageContentFilename(message)); + } + cancelMessageMediaDownload({ message }); + }); + + startedDownloads.add(downloadHash); + } + }); + }); + }, [ + cancelMessageMediaDownload, + messages, + activeDownloads, + ]); + + return undefined; +}; + +export default memo(withGlobal( + (global): StateProps => { + const activeDownloads = global.activeDownloads.byChatId; + const messages = global.messages.byChatId; + return { + activeDownloads, + messages, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['cancelMessageMediaDownload']), +)(DownloadsManager)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 24b7738d..fc85720b 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -36,6 +36,7 @@ import MiddleColumn from '../middle/MiddleColumn'; import RightColumn from '../right/RightColumn'; import MediaViewer from '../mediaViewer/MediaViewer.async'; import AudioPlayer from '../middle/AudioPlayer'; +import DownloadManager from './DownloadManager'; import Notifications from './Notifications.async'; import Dialogs from './Dialogs.async'; import ForwardPicker from './ForwardPicker.async'; @@ -252,6 +253,7 @@ const Main: FC = ({ onClose={handleStickerSetModalClose} stickerSetShortName={openedStickerSetShortName} /> + ); }; diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index e364541b..2681715b 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -51,7 +51,7 @@ import captureEscKeyListener from '../../util/captureEscKeyListener'; import { stopCurrentAudio } from '../../util/audioPlayer'; import useForceUpdate from '../../hooks/useForceUpdate'; import useMedia from '../../hooks/useMedia'; -import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useBlurSync from '../../hooks/useBlurSync'; import usePrevious from '../../hooks/usePrevious'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; @@ -181,7 +181,7 @@ const MediaViewer: FC = ({ undefined, isGhostAnimation && ANIMATION_DURATION, ); - const { mediaData: fullMediaBlobUrl, downloadProgress } = useMediaWithDownloadProgress( + const { mediaData: fullMediaBlobUrl, loadProgress } = useMediaWithLoadProgress( getMediaHash(true), undefined, message && getMessageMediaFormat(message, 'viewerFull'), @@ -485,7 +485,7 @@ const MediaViewer: FC = ({ isGif={isGif} posterData={bestImageData} posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)} - downloadProgress={downloadProgress} + loadProgress={loadProgress} fileSize={videoSize!} isMediaViewerOpen={isOpen} noPlay={!isActive} diff --git a/src/components/mediaViewer/MediaViewerActions.tsx b/src/components/mediaViewer/MediaViewerActions.tsx index e31d7fb1..54e8c422 100644 --- a/src/components/mediaViewer/MediaViewerActions.tsx +++ b/src/components/mediaViewer/MediaViewerActions.tsx @@ -1,11 +1,20 @@ -import React, { FC, useMemo } from '../../lib/teact/teact'; +import React, { + FC, + memo, + useCallback, + useMemo, +} from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; +import { GlobalActions } from '../../global/types'; import { ApiMessage } from '../../api/types'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { getMessageMediaHash } from '../../modules/helpers'; import useLang from '../../hooks/useLang'; -import useMediaDownload from '../../hooks/useMediaDownload'; +import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; +import { selectIsDownloading } from '../../modules/selectors'; +import { pick } from '../../util/iteratees'; import Button from '../ui/Button'; import DropdownMenu from '../ui/DropdownMenu'; @@ -14,6 +23,10 @@ import ProgressSpinner from '../ui/ProgressSpinner'; import './MediaViewerActions.scss'; +type StateProps = { + isDownloading: boolean; +}; + type OwnProps = { mediaData?: string; isVideo: boolean; @@ -26,26 +39,35 @@ type OwnProps = { onZoomToggle: NoneToVoidFunction; }; -const MediaViewerActions: FC = ({ +type DispatchProps = Pick; + +const MediaViewerActions: FC = ({ mediaData, isVideo, isZoomed, message, fileName, isAvatar, + isDownloading, onCloseMediaViewer, onForward, onZoomToggle, + downloadMessageMedia, + cancelMessageMediaDownload, }) => { - const { - isDownloadStarted, - downloadProgress, - handleDownloadClick, - } = useMediaDownload( - message && isVideo ? getMessageMediaHash(message, 'download') : undefined, - fileName, + const { loadProgress: downloadProgress } = useMediaWithLoadProgress( + message && getMessageMediaHash(message, 'download'), + !isDownloading, ); + const handleDownloadClick = useCallback(() => { + if (isDownloading) { + cancelMessageMediaDownload({ message }); + } else { + downloadMessageMedia({ message }); + } + }, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]); + const lang = useLang(); const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { @@ -80,10 +102,10 @@ const MediaViewerActions: FC = ({ )} {isVideo ? ( - {isDownloadStarted ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'} + {isDownloading ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'} ) : ( = ({ )} - {isDownloadStarted && } + {isDownloading && } ); } @@ -123,7 +145,7 @@ const MediaViewerActions: FC = ({ ariaLabel={lang('AccActionDownload')} onClick={handleDownloadClick} > - {isDownloadStarted ? ( + {isDownloading ? ( ) : ( @@ -163,4 +185,16 @@ const MediaViewerActions: FC = ({ ); }; -export default MediaViewerActions; +export default memo(withGlobal( + (global, { message }): StateProps => { + const isDownloading = message ? selectIsDownloading(global, message) : false; + + return { + isDownloading, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'downloadMessageMedia', + 'cancelMessageMediaDownload', + ]), +)(MediaViewerActions)); diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index 5bbe6f50..a2c876f4 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -22,7 +22,7 @@ type OwnProps = { isGif?: boolean; posterData?: string; posterSize?: ApiDimensions; - downloadProgress?: number; + loadProgress?: number; fileSize: number; isMediaViewerOpen?: boolean; noPlay?: boolean; @@ -36,7 +36,7 @@ const VideoPlayer: FC = ({ isGif, posterData, posterSize, - downloadProgress, + loadProgress, fileSize, isMediaViewerOpen, noPlay, @@ -196,7 +196,7 @@ const VideoPlayer: FC = ({ {!isBuffered &&
Buffering...
} diff --git a/src/components/mediaViewer/VideoPlayerControls.tsx b/src/components/mediaViewer/VideoPlayerControls.tsx index 440fd598..4aeccd47 100644 --- a/src/components/mediaViewer/VideoPlayerControls.tsx +++ b/src/components/mediaViewer/VideoPlayerControls.tsx @@ -159,10 +159,10 @@ function renderTime(currentTime: number, duration: number) { ); } -function renderFileSize(downloadedPercent: number, totalSize: number) { +function renderFileSize(loadedPercent: number, totalSize: number) { return (
- {`${formatFileSize(totalSize * downloadedPercent)} / ${formatFileSize(totalSize)}`} + {`${formatFileSize(totalSize * loadedPercent)} / ${formatFileSize(totalSize)}`}
); } diff --git a/src/components/middle/MessageSelectToolbar.scss b/src/components/middle/MessageSelectToolbar.scss index daa3a508..e9ef8deb 100644 --- a/src/components/middle/MessageSelectToolbar.scss +++ b/src/components/middle/MessageSelectToolbar.scss @@ -9,7 +9,7 @@ margin: 0; @supports (padding-bottom: env(safe-area-inset-bottom)) { - bottom: calc(.5rem + env(safe-area-inset-bottom)); + bottom: calc(0.5rem + env(safe-area-inset-bottom)); } .mask-image-disabled &::before { @@ -123,16 +123,53 @@ margin-left: auto; display: flex; - .MenuItem { + .item { + width: 100%; + background: none; + border: none !important; + box-shadow: none !important; + outline: none !important; + display: flex; + position: relative; + overflow: hidden; + line-height: 1.5rem; + white-space: nowrap; + color: var(--color-text); + --ripple-color: rgba(0, 0, 0, 0.08); + cursor: pointer; + unicode-bidi: plaintext; + padding: 0.6875rem; border-radius: 50%; i { - margin-right: 0; + font-size: 1.5rem; + color: var(--color-text-secondary); } - .item-text { - display: none; + &.destructive { + color: var(--color-error); + i { + color: inherit; + } + } + + &.disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + + &:not(.disabled):active { + background-color: var(--color-item-active); + transition: none !important; + } + + @media (hover: hover) { + &:hover, &:focus { + background-color: var(--color-chat-hover); + text-decoration: none; + } } } } diff --git a/src/components/middle/MessageSelectToolbar.tsx b/src/components/middle/MessageSelectToolbar.tsx index 89a30f74..10a09572 100644 --- a/src/components/middle/MessageSelectToolbar.tsx +++ b/src/components/middle/MessageSelectToolbar.tsx @@ -1,10 +1,13 @@ -import React, { FC, memo, useEffect } from '../../lib/teact/teact'; +import React, { + FC, memo, useCallback, useEffect, +} from '../../lib/teact/teact'; import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions, MessageListType } from '../../global/types'; import { selectCanDeleteSelectedMessages, + selectCanDownloadSelectedMessages, selectCanReportSelectedMessages, selectCurrentMessageList, selectSelectedMessagesCount, @@ -17,7 +20,6 @@ import usePrevious from '../../hooks/usePrevious'; import useLang from '../../hooks/useLang'; import Button from '../ui/Button'; -import MenuItem from '../ui/MenuItem'; import DeleteSelectedMessageModal from './DeleteSelectedMessageModal'; import ReportMessageModal from '../common/ReportMessageModal'; @@ -35,10 +37,13 @@ type StateProps = { selectedMessagesCount?: number; canDeleteMessages?: boolean; canReportMessages?: boolean; + canDownloadMessages?: boolean; selectedMessageIds?: number[]; }; -type DispatchProps = Pick; +type DispatchProps = Pick; const MessageSelectToolbar: FC = ({ canPost, @@ -48,9 +53,11 @@ const MessageSelectToolbar: FC = ({ selectedMessagesCount, canDeleteMessages, canReportMessages, + canDownloadMessages, selectedMessageIds, exitMessageSelectMode, openForwardMenuForSelectedMessages, + downloadSelectedMessages, }) => { const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); @@ -65,6 +72,11 @@ const MessageSelectToolbar: FC = ({ : undefined; }, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]); + const handleDownload = useCallback(() => { + downloadSelectedMessages(); + exitMessageSelectMode(); + }, [downloadSelectedMessages, exitMessageSelectMode]); + const prevSelectedMessagesCount = usePrevious(selectedMessagesCount || undefined, true); const renderingSelectedMessagesCount = isActive ? selectedMessagesCount : prevSelectedMessagesCount; @@ -78,6 +90,26 @@ const MessageSelectToolbar: FC = ({ isActive && 'shown', ); + const renderButton = ( + icon: string, label: string, onClick: AnyToVoidFunction, disabled?: boolean, destructive?: boolean, + ) => { + return ( +
+ +
+ ); + }; + return (
@@ -96,39 +128,15 @@ const MessageSelectToolbar: FC = ({ {!!selectedMessagesCount && (
{messageListType !== 'scheduled' && ( - - - {lang('Forward')} - - + renderButton('forward', lang('Chat.ForwardActionHeader'), openForwardMenuForSelectedMessages) )} {canReportMessages && ( - - - {lang('Report')} - - + renderButton('flag', lang('Conversation.ReportMessages'), openReportModal) )} - - - {lang('Delete')} - - + {canDownloadMessages && ( + renderButton('download', lang('lng_media_download'), handleDownload) + )} + {renderButton('delete', lang('EditAdminGroupDeleteMessages'), openDeleteModal, !canDeleteMessages, true)}
)}
@@ -151,6 +159,7 @@ export default memo(withGlobal( const { type: messageListType } = selectCurrentMessageList(global) || {}; const { canDelete } = selectCanDeleteSelectedMessages(global); const canReport = selectCanReportSelectedMessages(global); + const canDownload = selectCanDownloadSelectedMessages(global); const { messageIds: selectedMessageIds } = global.selectedMessages || {}; return { @@ -158,8 +167,11 @@ export default memo(withGlobal( selectedMessagesCount: selectSelectedMessagesCount(global), canDeleteMessages: canDelete, canReportMessages: canReport, + canDownloadMessages: canDownload, selectedMessageIds, }; }, - (setGlobal, actions): DispatchProps => pick(actions, ['exitMessageSelectMode', 'openForwardMenuForSelectedMessages']), + (setGlobal, actions): DispatchProps => pick(actions, [ + 'exitMessageSelectMode', 'openForwardMenuForSelectedMessages', 'downloadSelectedMessages', + ]), )(MessageSelectToolbar)); diff --git a/src/components/middle/message/Album.tsx b/src/components/middle/message/Album.tsx index b365a427..308f01b4 100644 --- a/src/components/middle/message/Album.tsx +++ b/src/components/middle/message/Album.tsx @@ -10,7 +10,7 @@ import { withGlobal } from '../../../lib/teact/teactn'; import { pick } from '../../../util/iteratees'; import withSelectControl from './hocs/withSelectControl'; import { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import { selectTheme } from '../../../modules/selectors'; +import { selectActiveDownloadIds, selectTheme } from '../../../modules/selectors'; import Photo from './Photo'; import Video from './Video'; @@ -35,6 +35,7 @@ type OwnProps = { type StateProps = { theme: ISettings['theme']; uploadsById: GlobalState['fileUploads']['byMessageLocalId']; + activeDownloadIds: number[]; }; type DispatchProps = Pick; @@ -50,6 +51,7 @@ const Album: FC = ({ albumLayout, onMediaClick, uploadsById, + activeDownloadIds, theme, cancelSendingMessage, }) => { @@ -82,6 +84,7 @@ const Album: FC = ({ dimensions={dimensions} onClick={onMediaClick} onCancelUpload={handleCancelUpload} + isDownloading={activeDownloadIds.includes(message.id)} theme={theme} /> ); @@ -98,6 +101,7 @@ const Album: FC = ({ dimensions={dimensions} onClick={onMediaClick} onCancelUpload={handleCancelUpload} + isDownloading={activeDownloadIds.includes(message.id)} theme={theme} /> ); @@ -120,11 +124,14 @@ const Album: FC = ({ }; export default withGlobal( - (global): StateProps => { + (global, { album }): StateProps => { + const { chatId } = album.mainMessage; const theme = selectTheme(global); + const activeDownloadIds = selectActiveDownloadIds(global, chatId); return { theme, uploadsById: global.fileUploads.byMessageLocalId, + activeDownloadIds, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 44f22c39..bde91174 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -6,7 +6,11 @@ import { withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions, MessageListType } from '../../../global/types'; import { ApiMessage } from '../../../api/types'; import { IAlbum, IAnchorPosition } from '../../../types'; -import { selectAllowedMessageActions, selectCurrentMessageList } from '../../../modules/selectors'; +import { + selectActiveDownloadIds, + selectAllowedMessageActions, + selectCurrentMessageList, +} from '../../../modules/selectors'; import { pick } from '../../../util/iteratees'; import useShowTransition from '../../../hooks/useShowTransition'; import useFlag from '../../../hooks/useFlag'; @@ -46,11 +50,14 @@ type StateProps = { canCopy?: boolean; canCopyLink?: boolean; canSelect?: boolean; + canDownload?: boolean; + activeDownloads: number[]; }; type DispatchProps = Pick; const ContextMenuContainer: FC = ({ @@ -77,6 +84,8 @@ const ContextMenuContainer: FC = ({ canCopy, canCopyLink, canSelect, + canDownload, + activeDownloads, setReplyingToId, setEditingId, pinMessage, @@ -86,6 +95,8 @@ const ContextMenuContainer: FC = ({ toggleMessageSelection, sendScheduledMessages, rescheduleMessage, + downloadMessageMedia, + cancelMessageMediaDownload, }) => { const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const [isMenuOpen, setIsMenuOpen] = useState(true); @@ -94,6 +105,9 @@ const ContextMenuContainer: FC = ({ const [isPinModalOpen, setIsPinModalOpen] = useState(false); const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); + const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id)) + : activeDownloads.includes(message.id); + const handleDelete = useCallback(() => { setIsMenuOpen(false); setIsDeleteModalOpen(true); @@ -205,6 +219,17 @@ const ContextMenuContainer: FC = ({ closeMenu(); }, [chatUsername, closeMenu, message.chatId, message.id]); + const handleDownloadClick = useCallback(() => { + (album?.messages || [message]).forEach((msg) => { + if (isDownloading) { + cancelMessageMediaDownload({ message: msg }); + } else { + downloadMessageMedia({ message: msg }); + } + }); + closeMenu(); + }, [album, message, closeMenu, isDownloading, cancelMessageMediaDownload, downloadMessageMedia]); + const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]); if (noOptions) { @@ -236,6 +261,8 @@ const ContextMenuContainer: FC = ({ canCopy={canCopy} canCopyLink={canCopyLink} canSelect={canSelect} + canDownload={canDownload} + isDownloading={isDownloading} onReply={handleReply} onEdit={handleEdit} onPin={handlePin} @@ -250,6 +277,7 @@ const ContextMenuContainer: FC = ({ onReschedule={handleOpenCalendar} onClose={closeMenu} onCopyLink={handleCopyLink} + onDownload={handleDownloadClick} /> = ({ export default memo(withGlobal( (global, { message, messageListType }): StateProps => { const { threadId } = selectCurrentMessageList(global) || {}; + const activeDownloads = selectActiveDownloadIds(global, message.chatId); const { noOptions, canReply, @@ -299,6 +328,7 @@ export default memo(withGlobal( canCopy, canCopyLink, canSelect, + canDownload, } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; const isPinned = messageListType === 'pinned'; const isScheduled = messageListType === 'scheduled'; @@ -319,6 +349,8 @@ export default memo(withGlobal( canCopy, canCopyLink: !isScheduled && canCopyLink, canSelect, + canDownload, + activeDownloads, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ @@ -331,5 +363,7 @@ export default memo(withGlobal( 'toggleMessageSelection', 'sendScheduledMessages', 'rescheduleMessage', + 'downloadMessageMedia', + 'cancelMessageMediaDownload', ]), )(ContextMenuContainer)); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 7abb8acd..650a53f6 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -44,6 +44,7 @@ import { selectShouldLoopStickers, selectTheme, selectAllowedMessageActions, + selectIsDownloading, } from '../../../modules/selectors'; import { getMessageContent, @@ -153,6 +154,7 @@ type StateProps = { isInSelectMode?: boolean; isSelected?: boolean; isGroupSelected?: boolean; + isDownloading: boolean; threadId?: number; isPinnedList?: boolean; shouldAutoLoadMedia?: boolean; @@ -217,6 +219,7 @@ const Message: FC = ({ threadId, messageListType, isPinnedList, + isDownloading, shouldAutoLoadMedia, shouldAutoPlayMedia, shouldLoopStickers, @@ -533,6 +536,7 @@ const Message: FC = ({ shouldAffectAppendix={hasCustomAppendix} onClick={handleMediaClick} onCancelUpload={handleCancelUpload} + isDownloading={isDownloading} theme={theme} /> )} @@ -543,6 +547,7 @@ const Message: FC = ({ shouldAutoLoad={shouldAutoLoadMedia} shouldAutoPlay={shouldAutoPlayMedia} lastSyncTime={lastSyncTime} + isDownloading={isDownloading} /> )} {!isAlbum && video && !video.isRound && ( @@ -556,6 +561,7 @@ const Message: FC = ({ lastSyncTime={lastSyncTime} onClick={handleMediaClick} onCancelUpload={handleCancelUpload} + isDownloading={isDownloading} /> )} {(audio || voice) && ( @@ -570,6 +576,7 @@ const Message: FC = ({ onPlay={handleAudioPlay} onReadMedia={voice && (!isOwn || isChatWithSelf) ? handleReadMedia : undefined} onCancelUpload={handleCancelUpload} + isDownloading={isDownloading} /> )} {document && ( @@ -581,6 +588,7 @@ const Message: FC = ({ isSelected={isSelected} onMediaClick={handleMediaClick} onCancelUpload={handleCancelUpload} + isDownloading={isDownloading} /> )} {contact && ( @@ -612,6 +620,7 @@ const Message: FC = ({ lastSyncTime={lastSyncTime} onMediaClick={handleMediaClick} onCancelMediaTransfer={handleCancelUpload} + isDownloading={isDownloading} theme={theme} /> )} @@ -859,6 +868,7 @@ export default memo(withGlobal( } const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {}; + const isDownloading = selectIsDownloading(global, message); return { theme: selectTheme(global), @@ -887,6 +897,7 @@ export default memo(withGlobal( !!message.groupedId && !message.isInAlbum && selectIsDocumentGroupSelected(global, chatId, message.groupedId) ), threadId, + isDownloading, isPinnedList: messageListType === 'pinned', shouldAutoLoadMedia: chat ? selectShouldAutoLoadMedia(global, message, chat, sender) : undefined, shouldAutoPlayMedia: selectShouldAutoPlayMedia(global, message), diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 369aea26..df567b9a 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -33,6 +33,8 @@ type OwnProps = { canCopy?: boolean; canCopyLink?: boolean; canSelect?: boolean; + canDownload?: boolean; + isDownloading?: boolean; onReply: () => void; onEdit: () => void; onPin: () => void; @@ -48,6 +50,7 @@ type OwnProps = { onClose: () => void; onCloseAnimationEnd?: () => void; onCopyLink?: () => void; + onDownload?: () => void; }; const SCROLLBAR_WIDTH = 10; @@ -70,6 +73,8 @@ const MessageContextMenu: FC = ({ canCopy, canCopyLink, canSelect, + canDownload, + isDownloading, onReply, onEdit, onPin, @@ -85,6 +90,7 @@ const MessageContextMenu: FC = ({ onClose, onCloseAnimationEnd, onCopyLink, + onDownload, }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); @@ -152,6 +158,11 @@ const MessageContextMenu: FC = ({ ))} {canPin && {lang('DialogPin')}} {canUnpin && {lang('DialogUnpin')}} + {canDownload && ( + + {isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')} + + )} {canForward && {lang('Forward')}} {canSelect && {lang('Common.Select')}} {canReport && {lang('lng_context_report_msg')}} diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 1dd79150..1d7b8b2e 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -14,7 +14,7 @@ import { isOwnMessage, } from '../../../modules/helpers'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; -import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import useShowTransition from '../../../hooks/useShowTransition'; import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef'; @@ -38,6 +38,7 @@ export type OwnProps = { shouldAffectAppendix?: boolean; dimensions?: IMediaDimensions & { isSmall?: boolean }; nonInteractive?: boolean; + isDownloading: boolean; theme: ISettings['theme']; onClick?: (id: number) => void; onCancelUpload?: (message: ApiMessage) => void; @@ -58,6 +59,7 @@ const Photo: FC = ({ dimensions, nonInteractive, shouldAffectAppendix, + isDownloading, theme, onClick, onCancelUpload, @@ -70,22 +72,30 @@ const Photo: FC = ({ const isIntersecting = useIsIntersecting(ref, observeIntersection); - const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad); - const shouldDownload = isDownloadAllowed && isIntersecting; + const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad); + const shouldLoad = isLoadAllowed && isIntersecting; const { - mediaData, downloadProgress, - } = useMediaWithDownloadProgress(getMessageMediaHash(message, size), !shouldDownload); + mediaData, loadProgress, + } = useMediaWithLoadProgress(getMessageMediaHash(message, size), !shouldLoad); const fullMediaData = localBlobUrl || mediaData; const thumbRef = useBlurredMediaThumbRef(message, fullMediaData); + const { + loadProgress: downloadProgress, + } = useMediaWithLoadProgress(getMessageMediaHash(message, 'download'), !isDownloading); + const { isUploading, isTransferring, transferProgress, - } = getMediaTransferState(message, uploadProgress || downloadProgress, shouldDownload && !fullMediaData); - const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; + } = getMediaTransferState( + message, + uploadProgress || isDownloading ? downloadProgress : loadProgress, + shouldLoad && !fullMediaData, + ); + const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, - } = useShowTransition(isTransferring, undefined, wasDownloadDisabled, 'slow'); + } = useShowTransition(isTransferring, undefined, wasLoadDisabled, 'slow'); const { shouldRenderThumb, shouldRenderFullMedia, transitionClassNames, } = useTransitionForMedia(fullMediaData, 'slow'); @@ -96,7 +106,7 @@ const Photo: FC = ({ onCancelUpload(message); } } else if (!fullMediaData) { - setIsDownloadAllowed((isAllowed) => !isAllowed); + setIsLoadAllowed((isAllowed) => !isAllowed); } else if (onClick) { onClick(message.id); } @@ -164,11 +174,11 @@ const Photo: FC = ({
)} - {!fullMediaData && !isDownloadAllowed && ( + {!fullMediaData && !isLoadAllowed && ( )} {isTransferring && ( - {Math.round(transferProgress * 100)}% + {Math.round(transferProgress * 100)}% )} ); diff --git a/src/components/middle/message/RoundVideo.tsx b/src/components/middle/message/RoundVideo.tsx index 461544a7..fa80bc75 100644 --- a/src/components/middle/message/RoundVideo.tsx +++ b/src/components/middle/message/RoundVideo.tsx @@ -5,14 +5,15 @@ import React, { useRef, useState, } from '../../../lib/teact/teact'; +import { getDispatch } from '../../../lib/teact/teactn'; -import { ApiMessage } from '../../../api/types'; +import { ApiMediaFormat, ApiMessage } from '../../../api/types'; import { ROUND_VIDEO_DIMENSIONS } from '../../common/helpers/mediaDimensions'; import { formatMediaDuration } from '../../../util/dateFormat'; import { getMessageMediaFormat, getMessageMediaHash } from '../../../modules/helpers'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; -import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useShowTransition from '../../../hooks/useShowTransition'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import usePrevious from '../../../hooks/usePrevious'; @@ -36,6 +37,7 @@ type OwnProps = { shouldAutoLoad?: boolean; shouldAutoPlay?: boolean; lastSyncTime?: number; + isDownloading?: boolean; }; let currentOnRelease: NoneToVoidFunction; @@ -56,6 +58,7 @@ const RoundVideo: FC = ({ shouldAutoLoad, shouldAutoPlay, lastSyncTime, + isDownloading, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -68,23 +71,30 @@ const RoundVideo: FC = ({ const isIntersecting = useIsIntersecting(ref, observeIntersection); - const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad && shouldAutoPlay); - const shouldDownload = Boolean(isDownloadAllowed && isIntersecting && lastSyncTime); - const { mediaData, downloadProgress } = useMediaWithDownloadProgress( + const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad && shouldAutoPlay); + const shouldLoad = Boolean(isLoadAllowed && isIntersecting && lastSyncTime); + const { mediaData, loadProgress } = useMediaWithLoadProgress( getMessageMediaHash(message, 'inline'), - !shouldDownload, + !shouldLoad, getMessageMediaFormat(message, 'inline'), lastSyncTime, ); + + const { loadProgress: downloadProgress } = useMediaWithLoadProgress( + getMessageMediaHash(message, 'download'), + !isDownloading, + ApiMediaFormat.BlobUrl, + lastSyncTime, + ); const thumbRef = useBlurredMediaThumbRef(message, mediaData); const { isBuffered, bufferingHandlers } = useBuffering(); - const isTransferring = isDownloadAllowed && !isBuffered; - const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; + const isTransferring = (isLoadAllowed && !isBuffered) || isDownloading; + const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const { shouldRender: shouldSpinnerRender, transitionClassNames: spinnerClassNames, - } = useShowTransition(isTransferring || !isBuffered, undefined, wasDownloadDisabled); + } = useShowTransition(isTransferring || !isBuffered, undefined, wasLoadDisabled); const { shouldRenderThumb, transitionClassNames } = useTransitionForMedia(mediaData, 'slow'); const [isActivated, setIsActivated] = useState(false); @@ -148,11 +158,16 @@ const RoundVideo: FC = ({ const handleClick = useCallback(() => { if (!mediaData) { - setIsDownloadAllowed((isAllowed) => !isAllowed); + setIsLoadAllowed((isAllowed) => !isAllowed); return; } + if (isDownloading) { + getDispatch().cancelMessageMediaDownload({ message }); + return; + } + const playerEl = playerRef.current!; if (isActivated) { if (playerEl.paused) { @@ -171,7 +186,7 @@ const RoundVideo: FC = ({ setIsActivated(true); } - }, [capturePlaying, isActivated, mediaData]); + }, [capturePlaying, isActivated, isDownloading, mediaData, message]); const handleTimeUpdate = useCallback((e: React.UIEvent) => { const playerEl = e.currentTarget; @@ -221,10 +236,10 @@ const RoundVideo: FC = ({
{shouldSpinnerRender && (
- +
)} - {!mediaData && !isDownloadAllowed && ( + {!mediaData && !isLoadAllowed && ( )}
diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index 8266226a..9efcf811 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -1,8 +1,9 @@ import React, { FC, useCallback, useRef, useState, } from '../../../lib/teact/teact'; +import { getDispatch } from '../../../lib/teact/teactn'; -import { ApiMessage } from '../../../api/types'; +import { ApiMediaFormat, ApiMessage } from '../../../api/types'; import { IMediaDimensions } from './helpers/calculateAlbumLayout'; import { formatMediaDuration } from '../../../util/dateFormat'; @@ -18,7 +19,7 @@ import { isOwnMessage, } from '../../../modules/helpers'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; -import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; +import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useMedia from '../../../hooks/useMedia'; import useShowTransition from '../../../hooks/useShowTransition'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; @@ -41,6 +42,7 @@ export type OwnProps = { uploadProgress?: number; dimensions?: IMediaDimensions; lastSyncTime?: number; + isDownloading: boolean; onClick?: (id: number) => void; onCancelUpload?: (message: ApiMessage) => void; }; @@ -57,6 +59,7 @@ const Video: FC = ({ dimensions, onClick, onCancelUpload, + isDownloading, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -68,8 +71,8 @@ const Video: FC = ({ const isIntersecting = useIsIntersecting(ref, observeIntersection); - const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad); - const shouldDownload = Boolean(isDownloadAllowed && isIntersecting && lastSyncTime); + const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad); + const shouldLoad = Boolean(isLoadAllowed && isIntersecting && lastSyncTime); const [isPlayAllowed, setIsPlayAllowed] = useState(shouldAutoPlay); const previewBlobUrl = useMedia( @@ -78,9 +81,9 @@ const Video: FC = ({ getMessageMediaFormat(message, 'pictogram'), lastSyncTime, ); - const { mediaData, downloadProgress } = useMediaWithDownloadProgress( + const { mediaData, loadProgress } = useMediaWithLoadProgress( getMessageMediaHash(message, 'inline'), - !shouldDownload, + !shouldLoad, getMessageMediaFormat(message, 'inline'), lastSyncTime, ); @@ -89,17 +92,24 @@ const Video: FC = ({ // Thumbnail is always rendered so we can only disable blur if we have preview const thumbRef = useBlurredMediaThumbRef(message, previewBlobUrl); + const { loadProgress: downloadProgress } = useMediaWithLoadProgress( + getMessageMediaHash(message, 'download'), + !isDownloading, + ApiMediaFormat.BlobUrl, + lastSyncTime, + ); + const { isBuffered, bufferingHandlers } = useBuffering(!shouldAutoLoad); const { isUploading, isTransferring, transferProgress } = getMediaTransferState( message, - uploadProgress || downloadProgress, - shouldDownload && !isBuffered, + uploadProgress || isDownloading ? downloadProgress : loadProgress, + (shouldLoad && !isBuffered) || isDownloading, ); - const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; + const wasLoadDisabled = usePrevious(isLoadAllowed) === false; const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, - } = useShowTransition(isTransferring, undefined, wasDownloadDisabled); + } = useShowTransition(isTransferring, undefined, wasLoadDisabled); const { transitionClassNames } = useTransitionForMedia(fullMediaData, 'slow'); const [playProgress, setPlayProgress] = useState(0); @@ -122,15 +132,17 @@ const Video: FC = ({ if (onCancelUpload) { onCancelUpload(message); } + } else if (isDownloading) { + getDispatch().cancelMessageMediaDownload({ message }); } else if (!fullMediaData) { - setIsDownloadAllowed((isAllowed) => !isAllowed); + setIsLoadAllowed((isAllowed) => !isAllowed); } else if (fullMediaData && !isPlayAllowed) { setIsPlayAllowed(true); videoRef.current!.play(); } else if (onClick) { onClick(message.id); } - }, [isUploading, fullMediaData, isPlayAllowed, onClick, onCancelUpload, message]); + }, [isUploading, isDownloading, fullMediaData, isPlayAllowed, onClick, onCancelUpload, message]); const className = buildClassName('media-inner dark', !isUploading && 'interactive'); const videoClassName = buildClassName('full-media', transitionClassNames); @@ -182,20 +194,20 @@ const Video: FC = ({ )} - {(isDownloadAllowed && !isPlayAllowed && !shouldRenderSpinner) && ( + {(isLoadAllowed && !isPlayAllowed && !shouldRenderSpinner) && ( )} {shouldRenderSpinner && (
- +
)} - {!isDownloadAllowed && ( + {!isLoadAllowed && ( )} {isTransferring ? ( - - {isUploading ? `${Math.round(transferProgress * 100)}%` : '...'} + + {(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'} ) : (
diff --git a/src/components/middle/message/WebPage.tsx b/src/components/middle/message/WebPage.tsx index 4d7f9ed3..7a910185 100644 --- a/src/components/middle/message/WebPage.tsx +++ b/src/components/middle/message/WebPage.tsx @@ -26,6 +26,7 @@ type OwnProps = { shouldAutoPlay?: boolean; inPreview?: boolean; lastSyncTime?: number; + isDownloading?: boolean; theme: ISettings['theme']; onMediaClick?: () => void; onCancelMediaTransfer?: () => void; @@ -39,6 +40,7 @@ const WebPage: FC = ({ shouldAutoPlay, inPreview, lastSyncTime, + isDownloading = false, theme, onMediaClick, onCancelMediaTransfer, @@ -94,6 +96,7 @@ const WebPage: FC = ({ nonInteractive={!isMediaInteractive} onClick={isMediaInteractive ? handleMediaClick : undefined} onCancelUpload={onCancelMediaTransfer} + isDownloading={isDownloading} theme={theme} /> )} @@ -116,6 +119,7 @@ const WebPage: FC = ({ lastSyncTime={lastSyncTime} onClick={isMediaInteractive ? handleMediaClick : undefined} onCancelUpload={onCancelMediaTransfer} + isDownloading={isDownloading} /> )}
diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index b67bd4cb..abc0df6f 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -490,7 +490,7 @@ } .message-media-duration, - .message-upload-progress { + .message-transfer-progress { background: rgba(0, 0, 0, .25); color: #fff; font-size: 0.75rem; diff --git a/src/components/right/Profile.scss b/src/components/right/Profile.scss index f3f2ed93..99ca6ce2 100644 --- a/src/components/right/Profile.scss +++ b/src/components/right/Profile.scss @@ -100,7 +100,7 @@ padding: 1.25rem; .ProgressSpinner, - .message-upload-progress { + .message-transfer-progress { display: none; } } diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index 9c5ad25b..74fa9744 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -30,6 +30,7 @@ import { selectCurrentMediaSearch, selectIsRightColumnShown, selectTheme, + selectActiveDownloadIds, } from '../../modules/selectors'; import { pick } from '../../util/iteratees'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; @@ -85,6 +86,7 @@ type StateProps = { isRestricted?: boolean; lastSyncTime?: number; serverTimeOffset: number; + activeDownloadIds: number[]; }; type DispatchProps = Pick = ({ isRightColumnShown, isRestricted, lastSyncTime, + activeDownloadIds, setLocalMediaSearchType, loadMoreMembers, searchMediaMessagesLocal, @@ -316,6 +319,7 @@ const Profile: FC = ({ smaller className="scroll-item" onDateClick={handleMessageFocus} + isDownloading={activeDownloadIds.includes(id)} /> )) ) : resultType === 'links' ? ( @@ -338,6 +342,7 @@ const Profile: FC = ({ className="scroll-item" onPlay={handlePlayAudio} onDateClick={handleMessageFocus} + isDownloading={activeDownloadIds.includes(id)} /> )) ) : resultType === 'voice' ? ( @@ -353,6 +358,7 @@ const Profile: FC = ({ className="scroll-item" onPlay={handlePlayAudio} onDateClick={handleMessageFocus} + isDownloading={activeDownloadIds.includes(id)} /> )) ) : resultType === 'members' ? ( @@ -465,6 +471,8 @@ export default memo(withGlobal( const canAddMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'inviteUsers') || chat.isCreator); const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); + const activeDownloadIds = selectActiveDownloadIds(global, chatId); + let resolvedUserId; if (userId) { resolvedUserId = userId; @@ -488,6 +496,7 @@ export default memo(withGlobal( isRestricted: chat?.isRestricted, lastSyncTime: global.lastSyncTime, serverTimeOffset: global.serverTimeOffset, + activeDownloadIds, usersById, chatsById, ...(hasMembersTab && members && { members }), diff --git a/src/global/cache.ts b/src/global/cache.ts index 6c64acef..165976eb 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -121,6 +121,12 @@ function readCache(initialState: GlobalState): GlobalState { if (!cached.stickers.greeting) { cached.stickers.greeting = initialState.stickers.greeting; } + + if (!cached.activeDownloads) { + cached.activeDownloads = { + byChatId: {}, + }; + } } const newState = { diff --git a/src/global/initial.ts b/src/global/initial.ts index d4e03d14..25fc20a4 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -163,4 +163,8 @@ export const INITIAL_STATE: GlobalState = { twoFaSettings: {}, shouldShowContextMenuHint: true, + + activeDownloads: { + byChatId: {}, + }, }; diff --git a/src/global/types.ts b/src/global/types.ts index e0983e61..029fd671 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -438,6 +438,10 @@ export type GlobalState = { historyCalendarSelectedAt?: number; openedStickerSetShortName?: string; + activeDownloads: { + byChatId: Record; + }; + shouldShowContextMenuHint?: boolean; }; @@ -470,6 +474,8 @@ export type ActionTypes = ( 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'reportMessages' | 'focusNextReply' | 'openChatByInvite' | + // downloads + 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | // poll result diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts index 8a23a265..473a30f8 100644 --- a/src/hooks/useAudioPlayer.ts +++ b/src/hooks/useAudioPlayer.ts @@ -5,7 +5,7 @@ import { getDispatch } from '../lib/teact/teactn'; import { AudioOrigin } from '../types'; -import { register, Track } from '../util/audioPlayer'; +import { register, Track, TrackId } from '../util/audioPlayer'; import useEffectWithPrevDeps from './useEffectWithPrevDeps'; import { isSafariPatchInProgress } from '../util/patchSafariProgressiveAudio'; import useOnChange from './useOnChange'; @@ -18,7 +18,7 @@ type Handler = (e: Event) => void; const DEFAULT_SKIP_TIME = 10; export default ( - trackId: string, + trackId: TrackId, originalDuration: number, // Sometimes incorrect for voice messages trackType: Track['type'], origin: AudioOrigin, diff --git a/src/hooks/useMediaDownload.ts b/src/hooks/useMediaDownload.ts deleted file mode 100644 index 4f699b1c..00000000 --- a/src/hooks/useMediaDownload.ts +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback, useEffect, useState } from '../lib/teact/teact'; - -import useMediaWithDownloadProgress from './useMediaWithDownloadProgress'; -import download from '../util/download'; - -export default function useMediaDownload( - mediaHash?: string, - fileName?: string, -) { - const [isDownloadStarted, setIsDownloadStarted] = useState(false); - - const { mediaData, downloadProgress } = useMediaWithDownloadProgress(mediaHash, !isDownloadStarted); - - // Download with browser when fully loaded - useEffect(() => { - if (isDownloadStarted && mediaData) { - download(mediaData, fileName!); - setIsDownloadStarted(false); - } - }, [fileName, mediaData, isDownloadStarted]); - - // Cancel download on source change - useEffect(() => { - setIsDownloadStarted(false); - }, [mediaHash]); - - const handleDownloadClick = useCallback((e: React.SyntheticEvent) => { - e.stopPropagation(); - setIsDownloadStarted((isAllowed) => !isAllowed); - }, []); - - return { - isDownloadStarted, - downloadProgress, - handleDownloadClick, - }; -} diff --git a/src/hooks/useMediaWithDownloadProgress.ts b/src/hooks/useMediaWithLoadProgress.ts similarity index 72% rename from src/hooks/useMediaWithDownloadProgress.ts rename to src/hooks/useMediaWithLoadProgress.ts index 41e4b12f..90866a2d 100644 --- a/src/hooks/useMediaWithDownloadProgress.ts +++ b/src/hooks/useMediaWithLoadProgress.ts @@ -7,12 +7,13 @@ import { ApiMediaFormat } from '../api/types'; import { throttle } from '../util/schedulers'; import * as mediaLoader from '../util/mediaLoader'; import useForceUpdate from './useForceUpdate'; +import useUniqueId from './useUniqueId'; const STREAMING_PROGRESS = 0.75; const STREAMING_TIMEOUT = 1500; const PROGRESS_THROTTLE = 500; -export default ( +export default function useMediaWithLoadProgress( mediaHash: string | undefined, noLoad = false, // @ts-ignore (workaround for "could be instantiated with a different subtype" issue) @@ -20,19 +21,20 @@ export default ( cacheBuster?: number, delay?: number | false, isHtmlAllowed = false, -) => { +) { const mediaData = mediaHash ? mediaLoader.getFromMemory(mediaHash) : undefined; const isStreaming = mediaFormat === ApiMediaFormat.Stream || ( IS_PROGRESSIVE_SUPPORTED && mediaFormat === ApiMediaFormat.Progressive ); const forceUpdate = useForceUpdate(); - const [downloadProgress, setDownloadProgress] = useState(mediaData && !isStreaming ? 1 : 0); + const id = useUniqueId(); + const [loadProgress, setLoadProgress] = useState(mediaData && !isStreaming ? 1 : 0); const startedAtRef = useRef(); const handleProgress = useMemo(() => { return throttle((progress: number) => { - if (!delay || (Date.now() - startedAtRef.current! > delay)) { - setDownloadProgress(progress); + if (startedAtRef.current && (!delay || (Date.now() - startedAtRef.current > delay))) { + setLoadProgress(progress); } }, PROGRESS_THROTTLE, true); }, [delay]); @@ -40,7 +42,7 @@ export default ( useEffect(() => { if (!noLoad && mediaHash) { if (!mediaData) { - setDownloadProgress(0); + setLoadProgress(0); if (startedAtRef.current) { mediaLoader.cancelProgress(handleProgress); @@ -48,7 +50,7 @@ export default ( startedAtRef.current = Date.now(); - mediaLoader.fetch(mediaHash, mediaFormat, isHtmlAllowed, handleProgress).then(() => { + mediaLoader.fetch(mediaHash, mediaFormat, isHtmlAllowed, handleProgress, id).then(() => { const spentTime = Date.now() - startedAtRef.current!; startedAtRef.current = undefined; @@ -60,21 +62,30 @@ export default ( }); } else if (isStreaming) { setTimeout(() => { - setDownloadProgress(STREAMING_PROGRESS); + setLoadProgress(STREAMING_PROGRESS); }, STREAMING_TIMEOUT); } } }, [ noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress, - isHtmlAllowed, + isHtmlAllowed, id, ]); useEffect(() => { if (noLoad && startedAtRef.current) { mediaLoader.cancelProgress(handleProgress); - setDownloadProgress(0); + setLoadProgress(0); + startedAtRef.current = undefined; } }, [handleProgress, noLoad]); - return { mediaData, downloadProgress }; -}; + useEffect(() => { + return () => { + if (mediaHash) { + mediaLoader.removeCallback(mediaHash, id); + } + }; + }, [id, mediaHash]); + + return { mediaData, loadProgress }; +} diff --git a/src/hooks/useUniqueId.ts b/src/hooks/useUniqueId.ts new file mode 100644 index 00000000..a4605368 --- /dev/null +++ b/src/hooks/useUniqueId.ts @@ -0,0 +1,15 @@ +import { useRef } from '../lib/teact/teact'; +import generateIdFor from '../util/generateIdFor'; + +const store: Record = {}; + +export default () => { + const idRef = useRef(); + + if (!idRef.current) { + idRef.current = generateIdFor(store); + store[idRef.current] = true; + } + + return idRef.current; +}; diff --git a/src/modules/actions/ui/messages.ts b/src/modules/actions/ui/messages.ts index 68d0b299..18eb8b67 100644 --- a/src/modules/actions/ui/messages.ts +++ b/src/modules/actions/ui/messages.ts @@ -394,6 +394,53 @@ addReducer('openForwardMenuForSelectedMessages', (global, actions) => { actions.openForwardMenu({ fromChatId, messageIds }); }); +addReducer('cancelMessageMediaDownload', (global, actions, payload) => { + const { message } = payload!; + + const byChatId = global.activeDownloads.byChatId[message.chatId]; + if (!byChatId || !byChatId.length) return; + + setGlobal({ + ...global, + activeDownloads: { + byChatId: { + ...global.activeDownloads.byChatId, + [message.chatId]: byChatId.filter((id) => id !== message.id), + }, + }, + }); +}); + +addReducer('downloadMessageMedia', (global, actions, payload) => { + const { message } = payload!; + if (!message) return; + + setGlobal({ + ...global, + activeDownloads: { + byChatId: { + ...global.activeDownloads.byChatId, + [message.chatId]: [...(global.activeDownloads.byChatId[message.chatId] || []), message.id], + }, + }, + }); +}); + +addReducer('downloadSelectedMessages', (global, actions) => { + if (!global.selectedMessages) { + return; + } + + const { chatId, messageIds } = global.selectedMessages; + const { threadId } = selectCurrentMessageList(global) || {}; + + const chatMessages = selectChatMessages(global, chatId); + if (!chatMessages || !threadId) return; + const messages = messageIds.map((id) => chatMessages[id]) + .filter((message) => selectAllowedMessageActions(global, message, threadId).canDownload); + messages.forEach((message) => actions.downloadMessageMedia({ message })); +}); + addReducer('enterMessageSelectMode', (global, actions, payload) => { const { messageId } = payload || {}; const openChat = selectCurrentChat(global); diff --git a/src/modules/helpers/messageMedia.ts b/src/modules/helpers/messageMedia.ts index 1b6ab75e..cdc4244c 100644 --- a/src/modules/helpers/messageMedia.ts +++ b/src/modules/helpers/messageMedia.ts @@ -168,6 +168,7 @@ export function getMessageMediaHash( case 'viewerPreview': return `${base}?size=x`; case 'viewerFull': + case 'download': return `${base}?size=z`; } } @@ -183,7 +184,7 @@ export function getMessageMediaHash( } return `${base}?size=m`; - default: + case 'download': return base; } } @@ -194,8 +195,10 @@ export function getMessageMediaHash( return undefined; case 'pictogram': return `${base}?size=m`; - default: + case 'inline': return base; + case 'download': + return `${base}?download`; } } @@ -204,10 +207,10 @@ export function getMessageMediaHash( case 'micro': case 'pictogram': return getAudioHasCover(audio) ? `${base}?size=m` : undefined; + case 'inline': + return getVideoOrAudioBaseHash(audio, base); case 'download': return `${base}?download`; - default: - return getVideoOrAudioBaseHash(audio, base); } } @@ -216,8 +219,10 @@ export function getMessageMediaHash( case 'micro': case 'pictogram': return undefined; - default: + case 'inline': return base; + case 'download': + return `${base}?download`; } } @@ -330,9 +335,9 @@ export function getVideoDimensions(video: ApiVideo): ApiDimensions | undefined { return undefined; } -export function getMediaTransferState(message: ApiMessage, progress?: number, isDownloadNeeded = false) { +export function getMediaTransferState(message: ApiMessage, progress?: number, isLoadNeeded = false) { const isUploading = isMessageLocal(message); - const isTransferring = isUploading || isDownloadNeeded; + const isTransferring = isUploading || isLoadNeeded; const transferProgress = Number(progress); return { diff --git a/src/modules/helpers/messages.ts b/src/modules/helpers/messages.ts index 619b285f..d8a43766 100644 --- a/src/modules/helpers/messages.ts +++ b/src/modules/helpers/messages.ts @@ -4,20 +4,25 @@ import { import { LangFn } from '../../hooks/useLang'; import { LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIONS_USER_ID, RE_LINK_TEMPLATE } from '../../config'; -import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString'; import { getUserFullName } from './users'; +import { isWebpSupported, IS_OPUS_SUPPORTED } from '../../util/environment'; import { getChatTitle } from './chats'; +import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString'; const CONTENT_NOT_SUPPORTED = 'The message is not supported on this version of Telegram'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const TRUNCATED_SUMMARY_LENGTH = 80; -export type MessageKey = string; // `msg${number}-${number}`; +export type MessageKey = `msg${number}-${number}`; export function getMessageKey(message: ApiMessage): MessageKey { const { chatId, id } = message; - return `msg${chatId}-${id}`; + return buildMessageKey(chatId, id); +} + +export function buildMessageKey(chatId: number, msgId: number): MessageKey { + return `msg${chatId}-${msgId}`; } export function parseMessageKey(key: MessageKey) { @@ -226,3 +231,39 @@ export function getMessageAudioCaption(message: ApiMessage) { return (audio && [audio.title, audio.performer].filter(Boolean).join(' — ')) || (text?.text); } + +export function getMessageContentFilename(message: ApiMessage) { + const { content } = message; + + const video = content.webPage ? content.webPage.video : content.video; + const photo = content.webPage ? content.webPage.photo : content.photo; + const document = content.webPage ? content.webPage.document : content.document; + if (document) { + return document.fileName; + } + + if (video) { + return video.fileName; + } + + if (content.sticker) { + const extension = content.sticker.isAnimated ? 'tgs' : isWebpSupported() ? 'webp' : 'png'; + return `${content.sticker.id}.${extension}`; + } + + if (content.audio) { + return content.audio.fileName; + } + + const baseFilename = getMessageKey(message); + + if (photo) { + return `${baseFilename}.png`; + } + + if (content.voice) { + return IS_OPUS_SUPPORTED ? `${baseFilename}.ogg` : `${baseFilename}.wav`; + } + + return baseFilename; +} diff --git a/src/modules/selectors/messages.ts b/src/modules/selectors/messages.ts index fe860112..e523c624 100644 --- a/src/modules/selectors/messages.ts +++ b/src/modules/selectors/messages.ts @@ -34,6 +34,7 @@ import { import { findLast } from '../../util/iteratees'; import { selectIsStickerFavorite } from './symbols'; import { getServerTime } from '../../util/serverTime'; +import { MEMO_EMPTY_ARRAY } from '../../util/memo'; const MESSAGE_EDIT_ALLOWED_TIME = 172800; // 48 hours @@ -413,11 +414,16 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes const canCopy = !isAction; const canCopyLink = !isAction && (isChannel || isSuperGroup); const canSelect = !isAction; + + const canDownload = Boolean(content.webPage?.document || content.webPage?.video || content.webPage?.photo + || content.audio || content.voice || content.photo || content.video || content.document || content.sticker); + const noOptions = [ canReply, canEdit, canPin, canUnpin, + canReport, canDelete, canDeleteForAll, canForward, @@ -426,6 +432,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes canCopy, canCopyLink, canSelect, + canDownload, ].every((ability) => !ability); return { @@ -434,8 +441,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes canEdit, canPin, canUnpin, - canDelete, canReport, + canDelete, canDeleteForAll, canForward, canFaveSticker, @@ -443,6 +450,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes canCopy, canCopyLink, canSelect, + canDownload, }; } @@ -480,6 +488,30 @@ export function selectCanReportSelectedMessages(global: GlobalState) { return messageActions.every((actions) => actions.canReport); } +export function selectCanDownloadSelectedMessages(global: GlobalState) { + const { messageIds: selectedMessageIds } = global.selectedMessages || {}; + const { chatId, threadId } = selectCurrentMessageList(global) || {}; + const chatMessages = chatId && selectChatMessages(global, chatId); + if (!chatMessages || !selectedMessageIds || !threadId) { + return false; + } + + const messageActions = selectedMessageIds + .map((id) => chatMessages[id] && selectAllowedMessageActions(global, chatMessages[id], threadId)) + .filter(Boolean); + + return messageActions.some((actions) => actions.canDownload); +} + +export function selectIsDownloading(global: GlobalState, message: ApiMessage) { + const activeInChat = global.activeDownloads.byChatId[message.chatId]; + return activeInChat ? activeInChat.includes(message.id) : false; +} + +export function selectActiveDownloadIds(global: GlobalState, chatId: number) { + return global.activeDownloads.byChatId[chatId] || MEMO_EMPTY_ARRAY; +} + export function selectUploadProgress(global: GlobalState, message: ApiMessage) { return global.fileUploads.byMessageLocalId[message.previousLocalId || message.id]?.progress; } diff --git a/src/util/audioPlayer.ts b/src/util/audioPlayer.ts index 2750521e..90ba7ef7 100644 --- a/src/util/audioPlayer.ts +++ b/src/util/audioPlayer.ts @@ -6,11 +6,11 @@ import { ApiMessage } from '../api/types'; import { IS_SAFARI } from './environment'; import safePlay from './safePlay'; import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio'; -import { getMessageKey, parseMessageKey } from '../modules/helpers'; +import { getMessageKey, MessageKey, parseMessageKey } from '../modules/helpers'; import { fastRaf } from './schedulers'; type Handler = (eventName: string, e: Event) => void; -type TrackId = string; // `${MessageKey}-${number}`; +export type TrackId = `${MessageKey}-${number}`; export interface Track { audio: HTMLAudioElement; @@ -131,7 +131,7 @@ export function stopCurrentAudio() { } export function register( - trackId: string, + trackId: TrackId, trackType: Track['type'], origin: AudioOrigin, handler: Handler, @@ -317,7 +317,7 @@ export function makeTrackId(message: ApiMessage): TrackId { } function splitTrackId(trackId: TrackId) { - const messageKey = trackId.match(/^msg(-?\d+)-(\d+)/)![0]; + const messageKey = trackId.match(/^msg(-?\d+)-(\d+)/)![0] as MessageKey; const date = Number(trackId.split('-').pop()); return { messageKey, diff --git a/src/util/download.ts b/src/util/download.ts index 90470b93..51fcbd6b 100644 --- a/src/util/download.ts +++ b/src/util/download.ts @@ -2,5 +2,10 @@ export default function download(url: string, filename: string) { const link = document.createElement('a'); link.href = url; link.download = filename; - link.click(); + try { + link.click(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); // Suppress redundant "Blob loading failed" error popup on IOS + } } diff --git a/src/util/mediaLoader.ts b/src/util/mediaLoader.ts index 4113253b..c31ebd04 100644 --- a/src/util/mediaLoader.ts +++ b/src/util/mediaLoader.ts @@ -27,20 +27,26 @@ const PROGRESSIVE_URL_PREFIX = './progressive/'; const memoryCache = new Map(); const fetchPromises = new Map>(); +const progressCallbacks = new Map>(); +const cancellableCallbacks = new Map(); export function fetch( - url: string, mediaFormat: T, isHtmlAllowed = false, onProgress?: ApiOnProgress, + url: string, + mediaFormat: T, + isHtmlAllowed = false, + onProgress?: ApiOnProgress, + callbackUniqueId?: string, ): Promise> { if (mediaFormat === ApiMediaFormat.Progressive) { return ( IS_PROGRESSIVE_SUPPORTED ? getProgressive(url) - : fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress) + : fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress, callbackUniqueId) ) as Promise>; } if (!fetchPromises.has(url)) { - const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed, onProgress) + const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed) .catch((err) => { if (DEBUG) { // eslint-disable-next-line no-console @@ -51,11 +57,22 @@ export function fetch( }) .finally(() => { fetchPromises.delete(url); + progressCallbacks.delete(url); + cancellableCallbacks.delete(url); }); fetchPromises.set(url, promise); } + if (onProgress && callbackUniqueId) { + let activeCallbacks = progressCallbacks.get(url); + if (!activeCallbacks) { + activeCallbacks = new Map(); + progressCallbacks.set(url, activeCallbacks); + } + activeCallbacks.set(callbackUniqueId, onProgress); + } + return fetchPromises.get(url) as Promise>; } @@ -64,7 +81,23 @@ export function getFromMemory(url: string) { } export function cancelProgress(progressCallback: ApiOnProgress) { - cancelApiProgress(progressCallback); + progressCallbacks.forEach((map, url) => { + map.forEach((callback) => { + if (callback === progressCallback) { + const parentCallback = cancellableCallbacks.get(url)!; + + cancelApiProgress(parentCallback); + cancellableCallbacks.delete(url); + progressCallbacks.delete(url); + } + }); + }); +} + +export function removeCallback(url: string, callbackUniqueId: string) { + const callbacks = progressCallbacks.get(url); + if (!callbacks) return; + callbacks.delete(callbackUniqueId); } function getProgressive(url: string) { @@ -76,7 +109,7 @@ function getProgressive(url: string) { } async function fetchFromCacheOrRemote( - url: string, mediaFormat: ApiMediaFormat, isHtmlAllowed: boolean, onProgress?: ApiOnProgress, + url: string, mediaFormat: ApiMediaFormat, isHtmlAllowed: boolean, ) { if (!MEDIA_CACHE_DISABLED) { const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; @@ -117,30 +150,22 @@ async function fetchFromCacheOrRemote( const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); - void callApi('downloadMedia', { url, mediaFormat }, (progress: number, arrayBuffer: ArrayBuffer) => { - if (onProgress) { - onProgress(progress); - } + const onProgress = makeOnProgress(url, mediaSource, sourceBuffer); + cancellableCallbacks.set(url, onProgress); - if (progress === 1) { - mediaSource.endOfStream(); - } - - if (!arrayBuffer) { - return; - } - - sourceBuffer.appendBuffer(arrayBuffer!); - }); + void callApi('downloadMedia', { url, mediaFormat }, onProgress); }); memoryCache.set(url, streamUrl); return streamUrl; } + const onProgress = makeOnProgress(url); + cancellableCallbacks.set(url, onProgress); + const remote = await callApi('downloadMedia', { url, mediaFormat, isHtmlAllowed }, onProgress); if (!remote) { - throw new Error('Failed to fetch media'); + throw new Error(`Failed to fetch media ${url}`); } let { prepared, mimeType } = remote; @@ -150,7 +175,7 @@ async function fetchFromCacheOrRemote( URL.revokeObjectURL(prepared as string); const media = await oggToWav(blob); prepared = prepareMedia(media); - mimeType = blob.type; + mimeType = media.type; } if (mimeType === 'image/webp' && !isWebpSupported()) { @@ -167,6 +192,27 @@ async function fetchFromCacheOrRemote( return prepared; } +function makeOnProgress(url: string, mediaSource?: MediaSource, sourceBuffer?: SourceBuffer) { + const onProgress: ApiOnProgress = (progress: number, arrayBuffer: ArrayBuffer) => { + progressCallbacks.get(url)?.forEach((callback) => { + callback(progress); + if (callback.isCanceled) onProgress.isCanceled = true; + }); + + if (progress === 1) { + mediaSource?.endOfStream(); + } + + if (!arrayBuffer) { + return; + } + + sourceBuffer?.appendBuffer(arrayBuffer); + }; + + return onProgress; +} + function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { if (mediaData instanceof Blob) { return URL.createObjectURL(mediaData);