Message: Support downloading all media with context menu and select mode (#1397)

This commit is contained in:
Alexander Zinchuk 2021-10-22 02:24:34 +03:00
parent d668e265b5
commit a3668e8b3d
39 changed files with 725 additions and 247 deletions

View File

@ -1,6 +1,7 @@
import React, { import React, {
FC, memo, useCallback, useEffect, useMemo, useRef, useState, FC, memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getDispatch } from '../../lib/teact/teactn';
import { import {
ApiAudio, ApiMediaFormat, ApiMessage, ApiVoice, ApiAudio, ApiMediaFormat, ApiMessage, ApiVoice,
@ -12,7 +13,6 @@ import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '.
import { import {
getMediaDuration, getMediaDuration,
getMediaTransferState, getMediaTransferState,
getMessageAudioCaption,
getMessageMediaFormat, getMessageMediaFormat,
getMessageMediaHash, getMessageMediaHash,
isMessageLocal, isMessageLocal,
@ -23,11 +23,10 @@ import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { getFileSizeString } from './helpers/documentInfo'; import { getFileSizeString } from './helpers/documentInfo';
import { decodeWaveform, interpolateArray } from '../../util/waveform'; import { decodeWaveform, interpolateArray } from '../../util/waveform';
import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useShowTransition from '../../hooks/useShowTransition'; import useShowTransition from '../../hooks/useShowTransition';
import useBuffering from '../../hooks/useBuffering'; import useBuffering from '../../hooks/useBuffering';
import useAudioPlayer from '../../hooks/useAudioPlayer'; import useAudioPlayer from '../../hooks/useAudioPlayer';
import useMediaDownload from '../../hooks/useMediaDownload';
import useLang, { LangFn } from '../../hooks/useLang'; import useLang, { LangFn } from '../../hooks/useLang';
import { captureEvents } from '../../util/captureEvents'; import { captureEvents } from '../../util/captureEvents';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
@ -51,6 +50,7 @@ type OwnProps = {
className?: string; className?: string;
isSelectable?: boolean; isSelectable?: boolean;
isSelected?: boolean; isSelected?: boolean;
isDownloading: boolean;
onPlay: (messageId: number, chatId: number) => void; onPlay: (messageId: number, chatId: number) => void;
onReadMedia?: () => void; onReadMedia?: () => void;
onCancelUpload?: () => void; onCancelUpload?: () => void;
@ -74,6 +74,7 @@ const Audio: FC<OwnProps> = ({
className, className,
isSelectable, isSelectable,
isSelected, isSelected,
isDownloading,
onPlay, onPlay,
onReadMedia, onReadMedia,
onCancelUpload, onCancelUpload,
@ -87,18 +88,24 @@ const Audio: FC<OwnProps> = ({
const seekerRef = useRef<HTMLElement>(null); const seekerRef = useRef<HTMLElement>(null);
const lang = useLang(); const lang = useLang();
const { isRtl } = lang; const { isRtl } = lang;
const dispatch = getDispatch();
const [isActivated, setIsActivated] = useState(false); const [isActivated, setIsActivated] = useState(false);
const shouldDownload = (isActivated || PRELOAD) && lastSyncTime; const shouldLoad = (isActivated || PRELOAD) && lastSyncTime;
const coverHash = getMessageMediaHash(message, 'pictogram'); const coverHash = getMessageMediaHash(message, 'pictogram');
const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl);
const { mediaData, downloadProgress } = useMediaWithDownloadProgress( const mediaData = useMedia(
getMessageMediaHash(message, 'inline'), getMessageMediaHash(message, 'inline'),
!shouldDownload, !shouldLoad,
getMessageMediaFormat(message, 'inline'), getMessageMediaFormat(message, 'inline'),
); );
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'),
!isDownloading,
);
const handleForcePlay = useCallback(() => { const handleForcePlay = useCallback(() => {
setIsActivated(true); setIsActivated(true);
onPlay(message.id, message.chatId); onPlay(message.id, message.chatId);
@ -135,20 +142,14 @@ const Audio: FC<OwnProps> = ({
setIsActivated(isPlaying); setIsActivated(isPlaying);
}, [isPlaying]); }, [isPlaying]);
const {
isDownloadStarted,
downloadProgress: directDownloadProgress,
handleDownloadClick,
} = useMediaDownload(getMessageMediaHash(message, 'download'), getMessageAudioCaption(message));
const isLoadingForPlaying = isActivated && !isBuffered; const isLoadingForPlaying = isActivated && !isBuffered;
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState( } = getMediaTransferState(
message, message,
isDownloadStarted ? directDownloadProgress : (uploadProgress || downloadProgress), uploadProgress || downloadProgress,
isLoadingForPlaying || isDownloadStarted, isLoadingForPlaying || isDownloading,
); );
const { const {
@ -173,10 +174,18 @@ const Audio: FC<OwnProps> = ({
}, [isPlaying, isUploading, message.id, message.chatId, onCancelUpload, onPlay, playPause, isActivated]); }, [isPlaying, isUploading, message.id, message.chatId, onCancelUpload, onPlay, playPause, isActivated]);
useEffect(() => { useEffect(() => {
if (isPlaying && onReadMedia && isMediaUnread) { if (onReadMedia && isMediaUnread && (isPlaying || isDownloading)) {
onReadMedia(); 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) => { const handleSeek = useCallback((e: MouseEvent | TouchEvent) => {
if (isSeeking.current && seekerRef.current) { if (isSeeking.current && seekerRef.current) {
@ -348,15 +357,15 @@ const Audio: FC<OwnProps> = ({
round round
size="tiny" size="tiny"
className="download-button" className="download-button"
ariaLabel={isDownloadStarted ? 'Cancel download' : 'Download'} ariaLabel={isDownloading ? 'Cancel download' : 'Download'}
onClick={handleDownloadClick} onClick={handleDownloadClick}
> >
<i className={isDownloadStarted ? 'icon-close' : 'icon-arrow-down'} /> <i className={isDownloading ? 'icon-close' : 'icon-arrow-down'} />
</Button> </Button>
)} )}
{origin === AudioOrigin.Search && renderWithTitle()} {origin === AudioOrigin.Search && renderWithTitle()}
{origin !== AudioOrigin.Search && audio && renderAudio( {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, date, transferProgress, onDateClick ? handleDateClick : undefined,
)} )}
{origin === AudioOrigin.SharedMedia && (voice || video) && renderWithTitle()} {origin === AudioOrigin.SharedMedia && (voice || video) && renderWithTitle()}

View File

@ -1,6 +1,7 @@
import React, { import React, {
FC, useCallback, useEffect, useState, memo, useRef, FC, useCallback, memo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getDispatch } from '../../lib/teact/teactn';
import { ApiMediaFormat, ApiMessage } from '../../api/types'; import { ApiMediaFormat, ApiMessage } from '../../api/types';
@ -12,9 +13,8 @@ import {
isMessageDocumentVideo, isMessageDocumentVideo,
} from '../../modules/helpers'; } from '../../modules/helpers';
import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { ObserveFn, useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import download from '../../util/download';
import File from './File'; import File from './File';
@ -29,6 +29,7 @@ type OwnProps = {
datetime?: number; datetime?: number;
className?: string; className?: string;
sender?: string; sender?: string;
isDownloading: boolean;
onCancelUpload?: () => void; onCancelUpload?: () => void;
onMediaClick?: () => void; onMediaClick?: () => void;
onDateClick?: (messageId: number, chatId: number) => void; onDateClick?: (messageId: number, chatId: number) => void;
@ -48,6 +49,7 @@ const Document: FC<OwnProps> = ({
onCancelUpload, onCancelUpload,
onMediaClick, onMediaClick,
onDateClick, onDateClick,
isDownloading,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -58,16 +60,14 @@ const Document: FC<OwnProps> = ({
const withMediaViewer = onMediaClick && Boolean(document.mediaType); const withMediaViewer = onMediaClick && Boolean(document.mediaType);
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const dispatch = getDispatch();
const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); const { loadProgress: downloadProgress } = useMediaWithLoadProgress<ApiMediaFormat.BlobUrl>(
const { getMessageMediaHash(message, 'download'), !isDownloading, undefined, undefined, undefined, true,
mediaData, downloadProgress,
} = useMediaWithDownloadProgress<ApiMediaFormat.BlobUrl>(
getMessageMediaHash(message, 'download'), !isDownloadAllowed, undefined, undefined, undefined, true,
); );
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloadAllowed); } = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloading);
const hasPreview = getDocumentHasPreview(document); const hasPreview = getDocumentHasPreview(document);
const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined; const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined;
@ -75,28 +75,29 @@ const Document: FC<OwnProps> = ({
const previewData = useMedia(getMessageMediaHash(message, 'pictogram'), !isIntersecting); const previewData = useMedia(getMessageMediaHash(message, 'pictogram'), !isIntersecting);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (withMediaViewer) { if (isDownloading) {
onMediaClick!(); dispatch.cancelMessageMediaDownload({ message });
} else if (isUploading) { return;
}
if (isUploading) {
if (onCancelUpload) { if (onCancelUpload) {
onCancelUpload(); onCancelUpload();
} }
} else { return;
setIsDownloadAllowed((isAllowed) => !isAllowed);
} }
}, [withMediaViewer, isUploading, onCancelUpload, onMediaClick]);
if (withMediaViewer) {
onMediaClick!();
} else {
dispatch.downloadMessageMedia({ message });
}
}, [withMediaViewer, isUploading, isDownloading, onMediaClick, onCancelUpload, dispatch, message]);
const handleDateClick = useCallback(() => { const handleDateClick = useCallback(() => {
onDateClick!(message.id, message.chatId); onDateClick!(message.id, message.chatId);
}, [onDateClick, message.id, message.chatId]); }, [onDateClick, message.id, message.chatId]);
useEffect(() => {
if (isDownloadAllowed && mediaData) {
download(mediaData, fileName);
setIsDownloadAllowed(false);
}
}, [fileName, mediaData, isDownloadAllowed]);
return ( return (
<File <File
ref={ref} ref={ref}

View File

@ -41,6 +41,7 @@ const AudioResults: FC<OwnProps & StateProps & DispatchProps> = ({
globalMessagesByChatId, globalMessagesByChatId,
foundIds, foundIds,
lastSyncTime, lastSyncTime,
activeDownloads,
searchMessagesGlobal, searchMessagesGlobal,
focusMessage, focusMessage,
openAudioPlayer, openAudioPlayer,
@ -104,6 +105,7 @@ const AudioResults: FC<OwnProps & StateProps & DispatchProps> = ({
className="scroll-item" className="scroll-item"
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
isDownloading={activeDownloads[message.chatId]?.includes(message.id)}
/> />
</div> </div>
); );

View File

@ -40,6 +40,7 @@ const FileResults: FC<OwnProps & StateProps & DispatchProps> = ({
usersById, usersById,
globalMessagesByChatId, globalMessagesByChatId,
foundIds, foundIds,
activeDownloads,
lastSyncTime, lastSyncTime,
searchMessagesGlobal, searchMessagesGlobal,
focusMessage, focusMessage,
@ -94,6 +95,7 @@ const FileResults: FC<OwnProps & StateProps & DispatchProps> = ({
sender={getSenderName(lang, message, chatsById, usersById)} sender={getSenderName(lang, message, chatsById, usersById)}
className="scroll-item" className="scroll-item"
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
isDownloading={activeDownloads[message.chatId]?.includes(message.id)}
/> />
</div> </div>
); );

View File

@ -15,6 +15,7 @@ export type StateProps = {
foundIds?: string[]; foundIds?: string[];
lastSyncTime?: number; lastSyncTime?: number;
searchChatId?: number; searchChatId?: number;
activeDownloads: Record<number, number[]>;
}; };
export function createMapStateToProps(type: ApiGlobalMessageSearchType) { export function createMapStateToProps(type: ApiGlobalMessageSearchType) {
@ -33,6 +34,8 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) {
const { byChatId: globalMessagesByChatId } = global.messages; const { byChatId: globalMessagesByChatId } = global.messages;
const foundIds = resultsByType?.[currentType]?.foundIds; const foundIds = resultsByType?.[currentType]?.foundIds;
const activeDownloads = global.activeDownloads.byChatId;
return { return {
theme: selectTheme(global), theme: selectTheme(global),
isLoading: foundIds === undefined isLoading: foundIds === undefined
@ -42,6 +45,7 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) {
globalMessagesByChatId, globalMessagesByChatId,
foundIds, foundIds,
searchChatId: chatId, searchChatId: chatId,
activeDownloads,
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
}; };
}; };

View File

@ -10,7 +10,7 @@ import { fetchBlob } from '../../../util/files';
import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useCanvasBlur from '../../../hooks/useCanvasBlur'; import useCanvasBlur from '../../../hooks/useCanvasBlur';
@ -40,15 +40,15 @@ const WallpaperTile: FC<OwnProps> = ({
const { const {
shouldRenderThumb, shouldRenderFullMedia, transitionClassNames, shouldRenderThumb, shouldRenderFullMedia, transitionClassNames,
} = useTransitionForMedia(previewBlobUrl || localBlobUrl, 'slow'); } = useTransitionForMedia(previewBlobUrl || localBlobUrl, 'slow');
const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); const [isLoadAllowed, setIsLoadAllowed] = useState(false);
const { const {
mediaData: fullMedia, downloadProgress, mediaData: fullMedia, loadProgress,
} = useMediaWithDownloadProgress(localMediaHash, !isDownloadAllowed); } = useMediaWithLoadProgress(localMediaHash, !isLoadAllowed);
const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; const wasLoadDisabled = usePrevious(isLoadAllowed) === false;
const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames } = useShowTransition( const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames } = useShowTransition(
(isDownloadAllowed && !fullMedia) || slug === UPLOADING_WALLPAPER_SLUG, (isLoadAllowed && !fullMedia) || slug === UPLOADING_WALLPAPER_SLUG,
undefined, undefined,
wasDownloadDisabled, wasLoadDisabled,
'slow', 'slow',
); );
// To prevent triggering of the effect for useCallback // To prevent triggering of the effect for useCallback
@ -73,7 +73,7 @@ const WallpaperTile: FC<OwnProps> = ({
if (fullMedia) { if (fullMedia) {
handleSelect(); handleSelect();
} else { } else {
setIsDownloadAllowed((isAllowed) => !isAllowed); setIsLoadAllowed((isAllowed) => !isAllowed);
} }
}, [fullMedia, handleSelect]); }, [fullMedia, handleSelect]);
@ -100,7 +100,7 @@ const WallpaperTile: FC<OwnProps> = ({
)} )}
{shouldRenderSpinner && ( {shouldRenderSpinner && (
<div className={buildClassName('spinner-container', spinnerClassNames)}> <div className={buildClassName('spinner-container', spinnerClassNames)}>
<ProgressSpinner progress={downloadProgress} onClick={handleClick} /> <ProgressSpinner progress={loadProgress} onClick={handleClick} />
</div> </div>
)} )}
</div> </div>

View File

@ -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<number, number[]>;
messages: Record<number, {
byId: Record<number, ApiMessage>;
threadsById: Record<number, Thread>;
}>;
};
type DispatchProps = Pick<GlobalActions, 'cancelMessageMediaDownload'>;
const startedDownloads = new Set<string>();
const DownloadsManager: FC<StateProps & DispatchProps> = ({
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<ApiMediaFormat.BlobUrl>(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));

View File

@ -36,6 +36,7 @@ import MiddleColumn from '../middle/MiddleColumn';
import RightColumn from '../right/RightColumn'; import RightColumn from '../right/RightColumn';
import MediaViewer from '../mediaViewer/MediaViewer.async'; import MediaViewer from '../mediaViewer/MediaViewer.async';
import AudioPlayer from '../middle/AudioPlayer'; import AudioPlayer from '../middle/AudioPlayer';
import DownloadManager from './DownloadManager';
import Notifications from './Notifications.async'; import Notifications from './Notifications.async';
import Dialogs from './Dialogs.async'; import Dialogs from './Dialogs.async';
import ForwardPicker from './ForwardPicker.async'; import ForwardPicker from './ForwardPicker.async';
@ -252,6 +253,7 @@ const Main: FC<StateProps & DispatchProps> = ({
onClose={handleStickerSetModalClose} onClose={handleStickerSetModalClose}
stickerSetShortName={openedStickerSetShortName} stickerSetShortName={openedStickerSetShortName}
/> />
<DownloadManager />
</div> </div>
); );
}; };

View File

@ -51,7 +51,7 @@ import captureEscKeyListener from '../../util/captureEscKeyListener';
import { stopCurrentAudio } from '../../util/audioPlayer'; import { stopCurrentAudio } from '../../util/audioPlayer';
import useForceUpdate from '../../hooks/useForceUpdate'; import useForceUpdate from '../../hooks/useForceUpdate';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useBlurSync from '../../hooks/useBlurSync'; import useBlurSync from '../../hooks/useBlurSync';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
@ -181,7 +181,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
undefined, undefined,
isGhostAnimation && ANIMATION_DURATION, isGhostAnimation && ANIMATION_DURATION,
); );
const { mediaData: fullMediaBlobUrl, downloadProgress } = useMediaWithDownloadProgress( const { mediaData: fullMediaBlobUrl, loadProgress } = useMediaWithLoadProgress(
getMediaHash(true), getMediaHash(true),
undefined, undefined,
message && getMessageMediaFormat(message, 'viewerFull'), message && getMessageMediaFormat(message, 'viewerFull'),
@ -485,7 +485,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
isGif={isGif} isGif={isGif}
posterData={bestImageData} posterData={bestImageData}
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)} posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
downloadProgress={downloadProgress} loadProgress={loadProgress}
fileSize={videoSize!} fileSize={videoSize!}
isMediaViewerOpen={isOpen} isMediaViewerOpen={isOpen}
noPlay={!isActive} noPlay={!isActive}

View File

@ -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 { ApiMessage } from '../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { getMessageMediaHash } from '../../modules/helpers'; import { getMessageMediaHash } from '../../modules/helpers';
import useLang from '../../hooks/useLang'; 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 Button from '../ui/Button';
import DropdownMenu from '../ui/DropdownMenu'; import DropdownMenu from '../ui/DropdownMenu';
@ -14,6 +23,10 @@ import ProgressSpinner from '../ui/ProgressSpinner';
import './MediaViewerActions.scss'; import './MediaViewerActions.scss';
type StateProps = {
isDownloading: boolean;
};
type OwnProps = { type OwnProps = {
mediaData?: string; mediaData?: string;
isVideo: boolean; isVideo: boolean;
@ -26,26 +39,35 @@ type OwnProps = {
onZoomToggle: NoneToVoidFunction; onZoomToggle: NoneToVoidFunction;
}; };
const MediaViewerActions: FC<OwnProps> = ({ type DispatchProps = Pick<GlobalActions, 'downloadMessageMedia' | 'cancelMessageMediaDownload'>;
const MediaViewerActions: FC<OwnProps & StateProps & DispatchProps> = ({
mediaData, mediaData,
isVideo, isVideo,
isZoomed, isZoomed,
message, message,
fileName, fileName,
isAvatar, isAvatar,
isDownloading,
onCloseMediaViewer, onCloseMediaViewer,
onForward, onForward,
onZoomToggle, onZoomToggle,
downloadMessageMedia,
cancelMessageMediaDownload,
}) => { }) => {
const { const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
isDownloadStarted, message && getMessageMediaHash(message, 'download'),
downloadProgress, !isDownloading,
handleDownloadClick,
} = useMediaDownload(
message && isVideo ? getMessageMediaHash(message, 'download') : undefined,
fileName,
); );
const handleDownloadClick = useCallback(() => {
if (isDownloading) {
cancelMessageMediaDownload({ message });
} else {
downloadMessageMedia({ message });
}
}, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]);
const lang = useLang(); const lang = useLang();
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
@ -80,10 +102,10 @@ const MediaViewerActions: FC<OwnProps> = ({
)} )}
{isVideo ? ( {isVideo ? (
<MenuItem <MenuItem
icon={isDownloadStarted ? 'close' : 'download'} icon={isDownloading ? 'close' : 'download'}
onClick={handleDownloadClick} onClick={handleDownloadClick}
> >
{isDownloadStarted ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'} {isDownloading ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'}
</MenuItem> </MenuItem>
) : ( ) : (
<MenuItem <MenuItem
@ -95,7 +117,7 @@ const MediaViewerActions: FC<OwnProps> = ({
</MenuItem> </MenuItem>
)} )}
</DropdownMenu> </DropdownMenu>
{isDownloadStarted && <ProgressSpinner progress={downloadProgress} size="s" noCross />} {isDownloading && <ProgressSpinner progress={downloadProgress} size="s" noCross />}
</div> </div>
); );
} }
@ -123,7 +145,7 @@ const MediaViewerActions: FC<OwnProps> = ({
ariaLabel={lang('AccActionDownload')} ariaLabel={lang('AccActionDownload')}
onClick={handleDownloadClick} onClick={handleDownloadClick}
> >
{isDownloadStarted ? ( {isDownloading ? (
<ProgressSpinner progress={downloadProgress} size="s" onClick={handleDownloadClick} /> <ProgressSpinner progress={downloadProgress} size="s" onClick={handleDownloadClick} />
) : ( ) : (
<i className="icon-download" /> <i className="icon-download" />
@ -163,4 +185,16 @@ const MediaViewerActions: FC<OwnProps> = ({
); );
}; };
export default MediaViewerActions; export default memo(withGlobal<OwnProps>(
(global, { message }): StateProps => {
const isDownloading = message ? selectIsDownloading(global, message) : false;
return {
isDownloading,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'downloadMessageMedia',
'cancelMessageMediaDownload',
]),
)(MediaViewerActions));

View File

@ -22,7 +22,7 @@ type OwnProps = {
isGif?: boolean; isGif?: boolean;
posterData?: string; posterData?: string;
posterSize?: ApiDimensions; posterSize?: ApiDimensions;
downloadProgress?: number; loadProgress?: number;
fileSize: number; fileSize: number;
isMediaViewerOpen?: boolean; isMediaViewerOpen?: boolean;
noPlay?: boolean; noPlay?: boolean;
@ -36,7 +36,7 @@ const VideoPlayer: FC<OwnProps> = ({
isGif, isGif,
posterData, posterData,
posterSize, posterSize,
downloadProgress, loadProgress,
fileSize, fileSize,
isMediaViewerOpen, isMediaViewerOpen,
noPlay, noPlay,
@ -196,7 +196,7 @@ const VideoPlayer: FC<OwnProps> = ({
{!isBuffered && <div className="buffering">Buffering...</div>} {!isBuffered && <div className="buffering">Buffering...</div>}
<ProgressSpinner <ProgressSpinner
size="xl" size="xl"
progress={isBuffered ? 1 : downloadProgress} progress={isBuffered ? 1 : loadProgress}
square square
onClick={onClose} onClick={onClose}
/> />

View File

@ -159,10 +159,10 @@ function renderTime(currentTime: number, duration: number) {
); );
} }
function renderFileSize(downloadedPercent: number, totalSize: number) { function renderFileSize(loadedPercent: number, totalSize: number) {
return ( return (
<div className="player-file-size"> <div className="player-file-size">
{`${formatFileSize(totalSize * downloadedPercent)} / ${formatFileSize(totalSize)}`} {`${formatFileSize(totalSize * loadedPercent)} / ${formatFileSize(totalSize)}`}
</div> </div>
); );
} }

View File

@ -9,7 +9,7 @@
margin: 0; margin: 0;
@supports (padding-bottom: env(safe-area-inset-bottom)) { @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 { .mask-image-disabled &::before {
@ -123,16 +123,53 @@
margin-left: auto; margin-left: auto;
display: flex; 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; padding: 0.6875rem;
border-radius: 50%; border-radius: 50%;
i { i {
margin-right: 0; font-size: 1.5rem;
color: var(--color-text-secondary);
} }
.item-text { &.destructive {
display: none; 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;
}
} }
} }
} }

View File

@ -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 { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions, MessageListType } from '../../global/types'; import { GlobalActions, MessageListType } from '../../global/types';
import { import {
selectCanDeleteSelectedMessages, selectCanDeleteSelectedMessages,
selectCanDownloadSelectedMessages,
selectCanReportSelectedMessages, selectCanReportSelectedMessages,
selectCurrentMessageList, selectCurrentMessageList,
selectSelectedMessagesCount, selectSelectedMessagesCount,
@ -17,7 +20,6 @@ import usePrevious from '../../hooks/usePrevious';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import Button from '../ui/Button'; import Button from '../ui/Button';
import MenuItem from '../ui/MenuItem';
import DeleteSelectedMessageModal from './DeleteSelectedMessageModal'; import DeleteSelectedMessageModal from './DeleteSelectedMessageModal';
import ReportMessageModal from '../common/ReportMessageModal'; import ReportMessageModal from '../common/ReportMessageModal';
@ -35,10 +37,13 @@ type StateProps = {
selectedMessagesCount?: number; selectedMessagesCount?: number;
canDeleteMessages?: boolean; canDeleteMessages?: boolean;
canReportMessages?: boolean; canReportMessages?: boolean;
canDownloadMessages?: boolean;
selectedMessageIds?: number[]; selectedMessageIds?: number[];
}; };
type DispatchProps = Pick<GlobalActions, 'exitMessageSelectMode' | 'openForwardMenuForSelectedMessages'>; type DispatchProps = Pick<GlobalActions, (
'exitMessageSelectMode' | 'openForwardMenuForSelectedMessages' | 'downloadSelectedMessages'
)>;
const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
canPost, canPost,
@ -48,9 +53,11 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
selectedMessagesCount, selectedMessagesCount,
canDeleteMessages, canDeleteMessages,
canReportMessages, canReportMessages,
canDownloadMessages,
selectedMessageIds, selectedMessageIds,
exitMessageSelectMode, exitMessageSelectMode,
openForwardMenuForSelectedMessages, openForwardMenuForSelectedMessages,
downloadSelectedMessages,
}) => { }) => {
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
@ -65,6 +72,11 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
: undefined; : undefined;
}, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]); }, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]);
const handleDownload = useCallback(() => {
downloadSelectedMessages();
exitMessageSelectMode();
}, [downloadSelectedMessages, exitMessageSelectMode]);
const prevSelectedMessagesCount = usePrevious(selectedMessagesCount || undefined, true); const prevSelectedMessagesCount = usePrevious(selectedMessagesCount || undefined, true);
const renderingSelectedMessagesCount = isActive ? selectedMessagesCount : prevSelectedMessagesCount; const renderingSelectedMessagesCount = isActive ? selectedMessagesCount : prevSelectedMessagesCount;
@ -78,6 +90,26 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
isActive && 'shown', isActive && 'shown',
); );
const renderButton = (
icon: string, label: string, onClick: AnyToVoidFunction, disabled?: boolean, destructive?: boolean,
) => {
return (
<div
role="button"
tabIndex={0}
className={buildClassName(
'item',
disabled && 'disabled',
destructive && 'destructive',
)}
onClick={!disabled ? onClick : undefined}
title={label}
>
<i className={`icon-${icon}`} />
</div>
);
};
return ( return (
<div className={className}> <div className={className}>
<div className="MessageSelectToolbar-inner"> <div className="MessageSelectToolbar-inner">
@ -96,39 +128,15 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
{!!selectedMessagesCount && ( {!!selectedMessagesCount && (
<div className="MessageSelectToolbar-actions"> <div className="MessageSelectToolbar-actions">
{messageListType !== 'scheduled' && ( {messageListType !== 'scheduled' && (
<MenuItem renderButton('forward', lang('Chat.ForwardActionHeader'), openForwardMenuForSelectedMessages)
icon="forward"
ariaLabel="Forward Messages"
onClick={openForwardMenuForSelectedMessages}
>
<span className="item-text">
{lang('Forward')}
</span>
</MenuItem>
)} )}
{canReportMessages && ( {canReportMessages && (
<MenuItem renderButton('flag', lang('Conversation.ReportMessages'), openReportModal)
icon="flag"
onClick={openReportModal}
disabled={!canReportMessages}
ariaLabel={lang('Conversation.ReportMessages')}
>
<span className="item-text">
{lang('Report')}
</span>
</MenuItem>
)} )}
<MenuItem {canDownloadMessages && (
destructive renderButton('download', lang('lng_media_download'), handleDownload)
icon="delete" )}
onClick={openDeleteModal} {renderButton('delete', lang('EditAdminGroupDeleteMessages'), openDeleteModal, !canDeleteMessages, true)}
disabled={!canDeleteMessages}
ariaLabel={lang('EditAdminGroupDeleteMessages')}
>
<span className="item-text">
{lang('Delete')}
</span>
</MenuItem>
</div> </div>
)} )}
</div> </div>
@ -151,6 +159,7 @@ export default memo(withGlobal<OwnProps>(
const { type: messageListType } = selectCurrentMessageList(global) || {}; const { type: messageListType } = selectCurrentMessageList(global) || {};
const { canDelete } = selectCanDeleteSelectedMessages(global); const { canDelete } = selectCanDeleteSelectedMessages(global);
const canReport = selectCanReportSelectedMessages(global); const canReport = selectCanReportSelectedMessages(global);
const canDownload = selectCanDownloadSelectedMessages(global);
const { messageIds: selectedMessageIds } = global.selectedMessages || {}; const { messageIds: selectedMessageIds } = global.selectedMessages || {};
return { return {
@ -158,8 +167,11 @@ export default memo(withGlobal<OwnProps>(
selectedMessagesCount: selectSelectedMessagesCount(global), selectedMessagesCount: selectSelectedMessagesCount(global),
canDeleteMessages: canDelete, canDeleteMessages: canDelete,
canReportMessages: canReport, canReportMessages: canReport,
canDownloadMessages: canDownload,
selectedMessageIds, selectedMessageIds,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['exitMessageSelectMode', 'openForwardMenuForSelectedMessages']), (setGlobal, actions): DispatchProps => pick(actions, [
'exitMessageSelectMode', 'openForwardMenuForSelectedMessages', 'downloadSelectedMessages',
]),
)(MessageSelectToolbar)); )(MessageSelectToolbar));

View File

@ -10,7 +10,7 @@ import { withGlobal } from '../../../lib/teact/teactn';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import withSelectControl from './hocs/withSelectControl'; import withSelectControl from './hocs/withSelectControl';
import { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { selectTheme } from '../../../modules/selectors'; import { selectActiveDownloadIds, selectTheme } from '../../../modules/selectors';
import Photo from './Photo'; import Photo from './Photo';
import Video from './Video'; import Video from './Video';
@ -35,6 +35,7 @@ type OwnProps = {
type StateProps = { type StateProps = {
theme: ISettings['theme']; theme: ISettings['theme'];
uploadsById: GlobalState['fileUploads']['byMessageLocalId']; uploadsById: GlobalState['fileUploads']['byMessageLocalId'];
activeDownloadIds: number[];
}; };
type DispatchProps = Pick<GlobalActions, 'cancelSendingMessage'>; type DispatchProps = Pick<GlobalActions, 'cancelSendingMessage'>;
@ -50,6 +51,7 @@ const Album: FC<OwnProps & StateProps & DispatchProps> = ({
albumLayout, albumLayout,
onMediaClick, onMediaClick,
uploadsById, uploadsById,
activeDownloadIds,
theme, theme,
cancelSendingMessage, cancelSendingMessage,
}) => { }) => {
@ -82,6 +84,7 @@ const Album: FC<OwnProps & StateProps & DispatchProps> = ({
dimensions={dimensions} dimensions={dimensions}
onClick={onMediaClick} onClick={onMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds.includes(message.id)}
theme={theme} theme={theme}
/> />
); );
@ -98,6 +101,7 @@ const Album: FC<OwnProps & StateProps & DispatchProps> = ({
dimensions={dimensions} dimensions={dimensions}
onClick={onMediaClick} onClick={onMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds.includes(message.id)}
theme={theme} theme={theme}
/> />
); );
@ -120,11 +124,14 @@ const Album: FC<OwnProps & StateProps & DispatchProps> = ({
}; };
export default withGlobal<OwnProps>( export default withGlobal<OwnProps>(
(global): StateProps => { (global, { album }): StateProps => {
const { chatId } = album.mainMessage;
const theme = selectTheme(global); const theme = selectTheme(global);
const activeDownloadIds = selectActiveDownloadIds(global, chatId);
return { return {
theme, theme,
uploadsById: global.fileUploads.byMessageLocalId, uploadsById: global.fileUploads.byMessageLocalId,
activeDownloadIds,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -6,7 +6,11 @@ import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions, MessageListType } from '../../../global/types'; import { GlobalActions, MessageListType } from '../../../global/types';
import { ApiMessage } from '../../../api/types'; import { ApiMessage } from '../../../api/types';
import { IAlbum, IAnchorPosition } from '../../../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 { pick } from '../../../util/iteratees';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -46,11 +50,14 @@ type StateProps = {
canCopy?: boolean; canCopy?: boolean;
canCopyLink?: boolean; canCopyLink?: boolean;
canSelect?: boolean; canSelect?: boolean;
canDownload?: boolean;
activeDownloads: number[];
}; };
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
'setReplyingToId' | 'setEditingId' | 'pinMessage' | 'openForwardMenu' | 'setReplyingToId' | 'setEditingId' | 'pinMessage' | 'openForwardMenu' |
'faveSticker' | 'unfaveSticker' | 'toggleMessageSelection' | 'sendScheduledMessages' | 'rescheduleMessage' 'faveSticker' | 'unfaveSticker' | 'toggleMessageSelection' | 'sendScheduledMessages' | 'rescheduleMessage' |
'downloadMessageMedia' | 'cancelMessageMediaDownload'
)>; )>;
const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
@ -77,6 +84,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canCopy, canCopy,
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload,
activeDownloads,
setReplyingToId, setReplyingToId,
setEditingId, setEditingId,
pinMessage, pinMessage,
@ -86,6 +95,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
toggleMessageSelection, toggleMessageSelection,
sendScheduledMessages, sendScheduledMessages,
rescheduleMessage, rescheduleMessage,
downloadMessageMedia,
cancelMessageMediaDownload,
}) => { }) => {
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const [isMenuOpen, setIsMenuOpen] = useState(true); const [isMenuOpen, setIsMenuOpen] = useState(true);
@ -94,6 +105,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
const [isPinModalOpen, setIsPinModalOpen] = useState(false); const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id))
: activeDownloads.includes(message.id);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
setIsMenuOpen(false); setIsMenuOpen(false);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -205,6 +219,17 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
closeMenu(); closeMenu();
}, [chatUsername, closeMenu, message.chatId, message.id]); }, [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]); const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
if (noOptions) { if (noOptions) {
@ -236,6 +261,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canCopy={canCopy} canCopy={canCopy}
canCopyLink={canCopyLink} canCopyLink={canCopyLink}
canSelect={canSelect} canSelect={canSelect}
canDownload={canDownload}
isDownloading={isDownloading}
onReply={handleReply} onReply={handleReply}
onEdit={handleEdit} onEdit={handleEdit}
onPin={handlePin} onPin={handlePin}
@ -250,6 +277,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onReschedule={handleOpenCalendar} onReschedule={handleOpenCalendar}
onClose={closeMenu} onClose={closeMenu}
onCopyLink={handleCopyLink} onCopyLink={handleCopyLink}
onDownload={handleDownloadClick}
/> />
<DeleteMessageModal <DeleteMessageModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
@ -285,6 +313,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { message, messageListType }): StateProps => { (global, { message, messageListType }): StateProps => {
const { threadId } = selectCurrentMessageList(global) || {}; const { threadId } = selectCurrentMessageList(global) || {};
const activeDownloads = selectActiveDownloadIds(global, message.chatId);
const { const {
noOptions, noOptions,
canReply, canReply,
@ -299,6 +328,7 @@ export default memo(withGlobal<OwnProps>(
canCopy, canCopy,
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload,
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isPinned = messageListType === 'pinned'; const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled'; const isScheduled = messageListType === 'scheduled';
@ -319,6 +349,8 @@ export default memo(withGlobal<OwnProps>(
canCopy, canCopy,
canCopyLink: !isScheduled && canCopyLink, canCopyLink: !isScheduled && canCopyLink,
canSelect, canSelect,
canDownload,
activeDownloads,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [
@ -331,5 +363,7 @@ export default memo(withGlobal<OwnProps>(
'toggleMessageSelection', 'toggleMessageSelection',
'sendScheduledMessages', 'sendScheduledMessages',
'rescheduleMessage', 'rescheduleMessage',
'downloadMessageMedia',
'cancelMessageMediaDownload',
]), ]),
)(ContextMenuContainer)); )(ContextMenuContainer));

View File

@ -44,6 +44,7 @@ import {
selectShouldLoopStickers, selectShouldLoopStickers,
selectTheme, selectTheme,
selectAllowedMessageActions, selectAllowedMessageActions,
selectIsDownloading,
} from '../../../modules/selectors'; } from '../../../modules/selectors';
import { import {
getMessageContent, getMessageContent,
@ -153,6 +154,7 @@ type StateProps = {
isInSelectMode?: boolean; isInSelectMode?: boolean;
isSelected?: boolean; isSelected?: boolean;
isGroupSelected?: boolean; isGroupSelected?: boolean;
isDownloading: boolean;
threadId?: number; threadId?: number;
isPinnedList?: boolean; isPinnedList?: boolean;
shouldAutoLoadMedia?: boolean; shouldAutoLoadMedia?: boolean;
@ -217,6 +219,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
threadId, threadId,
messageListType, messageListType,
isPinnedList, isPinnedList,
isDownloading,
shouldAutoLoadMedia, shouldAutoLoadMedia,
shouldAutoPlayMedia, shouldAutoPlayMedia,
shouldLoopStickers, shouldLoopStickers,
@ -533,6 +536,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
shouldAffectAppendix={hasCustomAppendix} shouldAffectAppendix={hasCustomAppendix}
onClick={handleMediaClick} onClick={handleMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
theme={theme} theme={theme}
/> />
)} )}
@ -543,6 +547,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
shouldAutoLoad={shouldAutoLoadMedia} shouldAutoLoad={shouldAutoLoadMedia}
shouldAutoPlay={shouldAutoPlayMedia} shouldAutoPlay={shouldAutoPlayMedia}
lastSyncTime={lastSyncTime} lastSyncTime={lastSyncTime}
isDownloading={isDownloading}
/> />
)} )}
{!isAlbum && video && !video.isRound && ( {!isAlbum && video && !video.isRound && (
@ -556,6 +561,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime={lastSyncTime} lastSyncTime={lastSyncTime}
onClick={handleMediaClick} onClick={handleMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
/> />
)} )}
{(audio || voice) && ( {(audio || voice) && (
@ -570,6 +576,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
onPlay={handleAudioPlay} onPlay={handleAudioPlay}
onReadMedia={voice && (!isOwn || isChatWithSelf) ? handleReadMedia : undefined} onReadMedia={voice && (!isOwn || isChatWithSelf) ? handleReadMedia : undefined}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
/> />
)} )}
{document && ( {document && (
@ -581,6 +588,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
isSelected={isSelected} isSelected={isSelected}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
/> />
)} )}
{contact && ( {contact && (
@ -612,6 +620,7 @@ const Message: FC<OwnProps & StateProps & DispatchProps> = ({
lastSyncTime={lastSyncTime} lastSyncTime={lastSyncTime}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
onCancelMediaTransfer={handleCancelUpload} onCancelMediaTransfer={handleCancelUpload}
isDownloading={isDownloading}
theme={theme} theme={theme}
/> />
)} )}
@ -859,6 +868,7 @@ export default memo(withGlobal<OwnProps>(
} }
const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {}; const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {};
const isDownloading = selectIsDownloading(global, message);
return { return {
theme: selectTheme(global), theme: selectTheme(global),
@ -887,6 +897,7 @@ export default memo(withGlobal<OwnProps>(
!!message.groupedId && !message.isInAlbum && selectIsDocumentGroupSelected(global, chatId, message.groupedId) !!message.groupedId && !message.isInAlbum && selectIsDocumentGroupSelected(global, chatId, message.groupedId)
), ),
threadId, threadId,
isDownloading,
isPinnedList: messageListType === 'pinned', isPinnedList: messageListType === 'pinned',
shouldAutoLoadMedia: chat ? selectShouldAutoLoadMedia(global, message, chat, sender) : undefined, shouldAutoLoadMedia: chat ? selectShouldAutoLoadMedia(global, message, chat, sender) : undefined,
shouldAutoPlayMedia: selectShouldAutoPlayMedia(global, message), shouldAutoPlayMedia: selectShouldAutoPlayMedia(global, message),

View File

@ -33,6 +33,8 @@ type OwnProps = {
canCopy?: boolean; canCopy?: boolean;
canCopyLink?: boolean; canCopyLink?: boolean;
canSelect?: boolean; canSelect?: boolean;
canDownload?: boolean;
isDownloading?: boolean;
onReply: () => void; onReply: () => void;
onEdit: () => void; onEdit: () => void;
onPin: () => void; onPin: () => void;
@ -48,6 +50,7 @@ type OwnProps = {
onClose: () => void; onClose: () => void;
onCloseAnimationEnd?: () => void; onCloseAnimationEnd?: () => void;
onCopyLink?: () => void; onCopyLink?: () => void;
onDownload?: () => void;
}; };
const SCROLLBAR_WIDTH = 10; const SCROLLBAR_WIDTH = 10;
@ -70,6 +73,8 @@ const MessageContextMenu: FC<OwnProps> = ({
canCopy, canCopy,
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload,
isDownloading,
onReply, onReply,
onEdit, onEdit,
onPin, onPin,
@ -85,6 +90,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
onCopyLink, onCopyLink,
onDownload,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -152,6 +158,11 @@ const MessageContextMenu: FC<OwnProps> = ({
))} ))}
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>} {canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>} {canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
{canDownload && (
<MenuItem icon="download" onClick={onDownload}>
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
</MenuItem>
)}
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>} {canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>} {canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>} {canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}

View File

@ -14,7 +14,7 @@ import {
isOwnMessage, isOwnMessage,
} from '../../../modules/helpers'; } from '../../../modules/helpers';
import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef'; import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
@ -38,6 +38,7 @@ export type OwnProps = {
shouldAffectAppendix?: boolean; shouldAffectAppendix?: boolean;
dimensions?: IMediaDimensions & { isSmall?: boolean }; dimensions?: IMediaDimensions & { isSmall?: boolean };
nonInteractive?: boolean; nonInteractive?: boolean;
isDownloading: boolean;
theme: ISettings['theme']; theme: ISettings['theme'];
onClick?: (id: number) => void; onClick?: (id: number) => void;
onCancelUpload?: (message: ApiMessage) => void; onCancelUpload?: (message: ApiMessage) => void;
@ -58,6 +59,7 @@ const Photo: FC<OwnProps> = ({
dimensions, dimensions,
nonInteractive, nonInteractive,
shouldAffectAppendix, shouldAffectAppendix,
isDownloading,
theme, theme,
onClick, onClick,
onCancelUpload, onCancelUpload,
@ -70,22 +72,30 @@ const Photo: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad);
const shouldDownload = isDownloadAllowed && isIntersecting; const shouldLoad = isLoadAllowed && isIntersecting;
const { const {
mediaData, downloadProgress, mediaData, loadProgress,
} = useMediaWithDownloadProgress(getMessageMediaHash(message, size), !shouldDownload); } = useMediaWithLoadProgress(getMessageMediaHash(message, size), !shouldLoad);
const fullMediaData = localBlobUrl || mediaData; const fullMediaData = localBlobUrl || mediaData;
const thumbRef = useBlurredMediaThumbRef(message, fullMediaData); const thumbRef = useBlurredMediaThumbRef(message, fullMediaData);
const {
loadProgress: downloadProgress,
} = useMediaWithLoadProgress(getMessageMediaHash(message, 'download'), !isDownloading);
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState(message, uploadProgress || downloadProgress, shouldDownload && !fullMediaData); } = getMediaTransferState(
const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; message,
uploadProgress || isDownloading ? downloadProgress : loadProgress,
shouldLoad && !fullMediaData,
);
const wasLoadDisabled = usePrevious(isLoadAllowed) === false;
const { const {
shouldRender: shouldRenderSpinner, shouldRender: shouldRenderSpinner,
transitionClassNames: spinnerClassNames, transitionClassNames: spinnerClassNames,
} = useShowTransition(isTransferring, undefined, wasDownloadDisabled, 'slow'); } = useShowTransition(isTransferring, undefined, wasLoadDisabled, 'slow');
const { const {
shouldRenderThumb, shouldRenderFullMedia, transitionClassNames, shouldRenderThumb, shouldRenderFullMedia, transitionClassNames,
} = useTransitionForMedia(fullMediaData, 'slow'); } = useTransitionForMedia(fullMediaData, 'slow');
@ -96,7 +106,7 @@ const Photo: FC<OwnProps> = ({
onCancelUpload(message); onCancelUpload(message);
} }
} else if (!fullMediaData) { } else if (!fullMediaData) {
setIsDownloadAllowed((isAllowed) => !isAllowed); setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (onClick) { } else if (onClick) {
onClick(message.id); onClick(message.id);
} }
@ -164,11 +174,11 @@ const Photo: FC<OwnProps> = ({
<ProgressSpinner progress={transferProgress} onClick={isUploading ? handleClick : undefined} /> <ProgressSpinner progress={transferProgress} onClick={isUploading ? handleClick : undefined} />
</div> </div>
)} )}
{!fullMediaData && !isDownloadAllowed && ( {!fullMediaData && !isLoadAllowed && (
<i className="icon-download" /> <i className="icon-download" />
)} )}
{isTransferring && ( {isTransferring && (
<span className="message-upload-progress">{Math.round(transferProgress * 100)}%</span> <span className="message-transfer-progress">{Math.round(transferProgress * 100)}%</span>
)} )}
</div> </div>
); );

View File

@ -5,14 +5,15 @@ import React, {
useRef, useRef,
useState, useState,
} from '../../../lib/teact/teact'; } 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 { ROUND_VIDEO_DIMENSIONS } from '../../common/helpers/mediaDimensions';
import { formatMediaDuration } from '../../../util/dateFormat'; import { formatMediaDuration } from '../../../util/dateFormat';
import { getMessageMediaFormat, getMessageMediaHash } from '../../../modules/helpers'; import { getMessageMediaFormat, getMessageMediaHash } from '../../../modules/helpers';
import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
@ -36,6 +37,7 @@ type OwnProps = {
shouldAutoLoad?: boolean; shouldAutoLoad?: boolean;
shouldAutoPlay?: boolean; shouldAutoPlay?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
isDownloading?: boolean;
}; };
let currentOnRelease: NoneToVoidFunction; let currentOnRelease: NoneToVoidFunction;
@ -56,6 +58,7 @@ const RoundVideo: FC<OwnProps> = ({
shouldAutoLoad, shouldAutoLoad,
shouldAutoPlay, shouldAutoPlay,
lastSyncTime, lastSyncTime,
isDownloading,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -68,23 +71,30 @@ const RoundVideo: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad && shouldAutoPlay); const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad && shouldAutoPlay);
const shouldDownload = Boolean(isDownloadAllowed && isIntersecting && lastSyncTime); const shouldLoad = Boolean(isLoadAllowed && isIntersecting && lastSyncTime);
const { mediaData, downloadProgress } = useMediaWithDownloadProgress( const { mediaData, loadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'inline'), getMessageMediaHash(message, 'inline'),
!shouldDownload, !shouldLoad,
getMessageMediaFormat(message, 'inline'), getMessageMediaFormat(message, 'inline'),
lastSyncTime, lastSyncTime,
); );
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'),
!isDownloading,
ApiMediaFormat.BlobUrl,
lastSyncTime,
);
const thumbRef = useBlurredMediaThumbRef(message, mediaData); const thumbRef = useBlurredMediaThumbRef(message, mediaData);
const { isBuffered, bufferingHandlers } = useBuffering(); const { isBuffered, bufferingHandlers } = useBuffering();
const isTransferring = isDownloadAllowed && !isBuffered; const isTransferring = (isLoadAllowed && !isBuffered) || isDownloading;
const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; const wasLoadDisabled = usePrevious(isLoadAllowed) === false;
const { const {
shouldRender: shouldSpinnerRender, shouldRender: shouldSpinnerRender,
transitionClassNames: spinnerClassNames, transitionClassNames: spinnerClassNames,
} = useShowTransition(isTransferring || !isBuffered, undefined, wasDownloadDisabled); } = useShowTransition(isTransferring || !isBuffered, undefined, wasLoadDisabled);
const { shouldRenderThumb, transitionClassNames } = useTransitionForMedia(mediaData, 'slow'); const { shouldRenderThumb, transitionClassNames } = useTransitionForMedia(mediaData, 'slow');
const [isActivated, setIsActivated] = useState<boolean>(false); const [isActivated, setIsActivated] = useState<boolean>(false);
@ -148,11 +158,16 @@ const RoundVideo: FC<OwnProps> = ({
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (!mediaData) { if (!mediaData) {
setIsDownloadAllowed((isAllowed) => !isAllowed); setIsLoadAllowed((isAllowed) => !isAllowed);
return; return;
} }
if (isDownloading) {
getDispatch().cancelMessageMediaDownload({ message });
return;
}
const playerEl = playerRef.current!; const playerEl = playerRef.current!;
if (isActivated) { if (isActivated) {
if (playerEl.paused) { if (playerEl.paused) {
@ -171,7 +186,7 @@ const RoundVideo: FC<OwnProps> = ({
setIsActivated(true); setIsActivated(true);
} }
}, [capturePlaying, isActivated, mediaData]); }, [capturePlaying, isActivated, isDownloading, mediaData, message]);
const handleTimeUpdate = useCallback((e: React.UIEvent<HTMLVideoElement>) => { const handleTimeUpdate = useCallback((e: React.UIEvent<HTMLVideoElement>) => {
const playerEl = e.currentTarget; const playerEl = e.currentTarget;
@ -221,10 +236,10 @@ const RoundVideo: FC<OwnProps> = ({
<div className="progress" ref={playingProgressRef} /> <div className="progress" ref={playingProgressRef} />
{shouldSpinnerRender && ( {shouldSpinnerRender && (
<div className={`media-loading ${spinnerClassNames}`}> <div className={`media-loading ${spinnerClassNames}`}>
<ProgressSpinner progress={downloadProgress} /> <ProgressSpinner progress={isDownloading ? downloadProgress : loadProgress} />
</div> </div>
)} )}
{!mediaData && !isDownloadAllowed && ( {!mediaData && !isLoadAllowed && (
<i className="icon-large-play" /> <i className="icon-large-play" />
)} )}
<div className="message-media-duration"> <div className="message-media-duration">

View File

@ -1,8 +1,9 @@
import React, { import React, {
FC, useCallback, useRef, useState, FC, useCallback, useRef, useState,
} from '../../../lib/teact/teact'; } 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 { IMediaDimensions } from './helpers/calculateAlbumLayout';
import { formatMediaDuration } from '../../../util/dateFormat'; import { formatMediaDuration } from '../../../util/dateFormat';
@ -18,7 +19,7 @@ import {
isOwnMessage, isOwnMessage,
} from '../../../modules/helpers'; } from '../../../modules/helpers';
import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMediaWithDownloadProgress from '../../../hooks/useMediaWithDownloadProgress'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useTransitionForMedia from '../../../hooks/useTransitionForMedia'; import useTransitionForMedia from '../../../hooks/useTransitionForMedia';
@ -41,6 +42,7 @@ export type OwnProps = {
uploadProgress?: number; uploadProgress?: number;
dimensions?: IMediaDimensions; dimensions?: IMediaDimensions;
lastSyncTime?: number; lastSyncTime?: number;
isDownloading: boolean;
onClick?: (id: number) => void; onClick?: (id: number) => void;
onCancelUpload?: (message: ApiMessage) => void; onCancelUpload?: (message: ApiMessage) => void;
}; };
@ -57,6 +59,7 @@ const Video: FC<OwnProps> = ({
dimensions, dimensions,
onClick, onClick,
onCancelUpload, onCancelUpload,
isDownloading,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -68,8 +71,8 @@ const Video: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isDownloadAllowed, setIsDownloadAllowed] = useState(shouldAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(shouldAutoLoad);
const shouldDownload = Boolean(isDownloadAllowed && isIntersecting && lastSyncTime); const shouldLoad = Boolean(isLoadAllowed && isIntersecting && lastSyncTime);
const [isPlayAllowed, setIsPlayAllowed] = useState(shouldAutoPlay); const [isPlayAllowed, setIsPlayAllowed] = useState(shouldAutoPlay);
const previewBlobUrl = useMedia( const previewBlobUrl = useMedia(
@ -78,9 +81,9 @@ const Video: FC<OwnProps> = ({
getMessageMediaFormat(message, 'pictogram'), getMessageMediaFormat(message, 'pictogram'),
lastSyncTime, lastSyncTime,
); );
const { mediaData, downloadProgress } = useMediaWithDownloadProgress( const { mediaData, loadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'inline'), getMessageMediaHash(message, 'inline'),
!shouldDownload, !shouldLoad,
getMessageMediaFormat(message, 'inline'), getMessageMediaFormat(message, 'inline'),
lastSyncTime, lastSyncTime,
); );
@ -89,17 +92,24 @@ const Video: FC<OwnProps> = ({
// Thumbnail is always rendered so we can only disable blur if we have preview // Thumbnail is always rendered so we can only disable blur if we have preview
const thumbRef = useBlurredMediaThumbRef(message, previewBlobUrl); const thumbRef = useBlurredMediaThumbRef(message, previewBlobUrl);
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'),
!isDownloading,
ApiMediaFormat.BlobUrl,
lastSyncTime,
);
const { isBuffered, bufferingHandlers } = useBuffering(!shouldAutoLoad); const { isBuffered, bufferingHandlers } = useBuffering(!shouldAutoLoad);
const { isUploading, isTransferring, transferProgress } = getMediaTransferState( const { isUploading, isTransferring, transferProgress } = getMediaTransferState(
message, message,
uploadProgress || downloadProgress, uploadProgress || isDownloading ? downloadProgress : loadProgress,
shouldDownload && !isBuffered, (shouldLoad && !isBuffered) || isDownloading,
); );
const wasDownloadDisabled = usePrevious(isDownloadAllowed) === false; const wasLoadDisabled = usePrevious(isLoadAllowed) === false;
const { const {
shouldRender: shouldRenderSpinner, shouldRender: shouldRenderSpinner,
transitionClassNames: spinnerClassNames, transitionClassNames: spinnerClassNames,
} = useShowTransition(isTransferring, undefined, wasDownloadDisabled); } = useShowTransition(isTransferring, undefined, wasLoadDisabled);
const { transitionClassNames } = useTransitionForMedia(fullMediaData, 'slow'); const { transitionClassNames } = useTransitionForMedia(fullMediaData, 'slow');
const [playProgress, setPlayProgress] = useState<number>(0); const [playProgress, setPlayProgress] = useState<number>(0);
@ -122,15 +132,17 @@ const Video: FC<OwnProps> = ({
if (onCancelUpload) { if (onCancelUpload) {
onCancelUpload(message); onCancelUpload(message);
} }
} else if (isDownloading) {
getDispatch().cancelMessageMediaDownload({ message });
} else if (!fullMediaData) { } else if (!fullMediaData) {
setIsDownloadAllowed((isAllowed) => !isAllowed); setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (fullMediaData && !isPlayAllowed) { } else if (fullMediaData && !isPlayAllowed) {
setIsPlayAllowed(true); setIsPlayAllowed(true);
videoRef.current!.play(); videoRef.current!.play();
} else if (onClick) { } else if (onClick) {
onClick(message.id); 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 className = buildClassName('media-inner dark', !isUploading && 'interactive');
const videoClassName = buildClassName('full-media', transitionClassNames); const videoClassName = buildClassName('full-media', transitionClassNames);
@ -182,20 +194,20 @@ const Video: FC<OwnProps> = ({
<source src={fullMediaData} /> <source src={fullMediaData} />
</video> </video>
)} )}
{(isDownloadAllowed && !isPlayAllowed && !shouldRenderSpinner) && ( {(isLoadAllowed && !isPlayAllowed && !shouldRenderSpinner) && (
<i className="icon-large-play" /> <i className="icon-large-play" />
)} )}
{shouldRenderSpinner && ( {shouldRenderSpinner && (
<div className={`media-loading ${spinnerClassNames}`}> <div className={`media-loading ${spinnerClassNames}`}>
<ProgressSpinner progress={transferProgress} onClick={isUploading ? handleClick : undefined} /> <ProgressSpinner progress={transferProgress} onClick={handleClick} />
</div> </div>
)} )}
{!isDownloadAllowed && ( {!isLoadAllowed && (
<i className="icon-download" /> <i className="icon-download" />
)} )}
{isTransferring ? ( {isTransferring ? (
<span className="message-upload-progress"> <span className="message-transfer-progress">
{isUploading ? `${Math.round(transferProgress * 100)}%` : '...'} {(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'}
</span> </span>
) : ( ) : (
<div className="message-media-duration"> <div className="message-media-duration">

View File

@ -26,6 +26,7 @@ type OwnProps = {
shouldAutoPlay?: boolean; shouldAutoPlay?: boolean;
inPreview?: boolean; inPreview?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
isDownloading?: boolean;
theme: ISettings['theme']; theme: ISettings['theme'];
onMediaClick?: () => void; onMediaClick?: () => void;
onCancelMediaTransfer?: () => void; onCancelMediaTransfer?: () => void;
@ -39,6 +40,7 @@ const WebPage: FC<OwnProps> = ({
shouldAutoPlay, shouldAutoPlay,
inPreview, inPreview,
lastSyncTime, lastSyncTime,
isDownloading = false,
theme, theme,
onMediaClick, onMediaClick,
onCancelMediaTransfer, onCancelMediaTransfer,
@ -94,6 +96,7 @@ const WebPage: FC<OwnProps> = ({
nonInteractive={!isMediaInteractive} nonInteractive={!isMediaInteractive}
onClick={isMediaInteractive ? handleMediaClick : undefined} onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer} onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading}
theme={theme} theme={theme}
/> />
)} )}
@ -116,6 +119,7 @@ const WebPage: FC<OwnProps> = ({
lastSyncTime={lastSyncTime} lastSyncTime={lastSyncTime}
onClick={isMediaInteractive ? handleMediaClick : undefined} onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer} onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading}
/> />
)} )}
</div> </div>

View File

@ -490,7 +490,7 @@
} }
.message-media-duration, .message-media-duration,
.message-upload-progress { .message-transfer-progress {
background: rgba(0, 0, 0, .25); background: rgba(0, 0, 0, .25);
color: #fff; color: #fff;
font-size: 0.75rem; font-size: 0.75rem;

View File

@ -100,7 +100,7 @@
padding: 1.25rem; padding: 1.25rem;
.ProgressSpinner, .ProgressSpinner,
.message-upload-progress { .message-transfer-progress {
display: none; display: none;
} }
} }

View File

@ -30,6 +30,7 @@ import {
selectCurrentMediaSearch, selectCurrentMediaSearch,
selectIsRightColumnShown, selectIsRightColumnShown,
selectTheme, selectTheme,
selectActiveDownloadIds,
} from '../../modules/selectors'; } from '../../modules/selectors';
import { pick } from '../../util/iteratees'; import { pick } from '../../util/iteratees';
import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { captureEvents, SwipeDirection } from '../../util/captureEvents';
@ -85,6 +86,7 @@ type StateProps = {
isRestricted?: boolean; isRestricted?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
serverTimeOffset: number; serverTimeOffset: number;
activeDownloadIds: number[];
}; };
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
@ -123,6 +125,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
isRightColumnShown, isRightColumnShown,
isRestricted, isRestricted,
lastSyncTime, lastSyncTime,
activeDownloadIds,
setLocalMediaSearchType, setLocalMediaSearchType,
loadMoreMembers, loadMoreMembers,
searchMediaMessagesLocal, searchMediaMessagesLocal,
@ -316,6 +319,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
smaller smaller
className="scroll-item" className="scroll-item"
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
isDownloading={activeDownloadIds.includes(id)}
/> />
)) ))
) : resultType === 'links' ? ( ) : resultType === 'links' ? (
@ -338,6 +342,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
className="scroll-item" className="scroll-item"
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
isDownloading={activeDownloadIds.includes(id)}
/> />
)) ))
) : resultType === 'voice' ? ( ) : resultType === 'voice' ? (
@ -353,6 +358,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
className="scroll-item" className="scroll-item"
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
isDownloading={activeDownloadIds.includes(id)}
/> />
)) ))
) : resultType === 'members' ? ( ) : resultType === 'members' ? (
@ -465,6 +471,8 @@ export default memo(withGlobal<OwnProps>(
const canAddMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'inviteUsers') || chat.isCreator); const canAddMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'inviteUsers') || chat.isCreator);
const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); const canDeleteMembers = hasMembersTab && chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator);
const activeDownloadIds = selectActiveDownloadIds(global, chatId);
let resolvedUserId; let resolvedUserId;
if (userId) { if (userId) {
resolvedUserId = userId; resolvedUserId = userId;
@ -488,6 +496,7 @@ export default memo(withGlobal<OwnProps>(
isRestricted: chat?.isRestricted, isRestricted: chat?.isRestricted,
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
serverTimeOffset: global.serverTimeOffset, serverTimeOffset: global.serverTimeOffset,
activeDownloadIds,
usersById, usersById,
chatsById, chatsById,
...(hasMembersTab && members && { members }), ...(hasMembersTab && members && { members }),

View File

@ -121,6 +121,12 @@ function readCache(initialState: GlobalState): GlobalState {
if (!cached.stickers.greeting) { if (!cached.stickers.greeting) {
cached.stickers.greeting = initialState.stickers.greeting; cached.stickers.greeting = initialState.stickers.greeting;
} }
if (!cached.activeDownloads) {
cached.activeDownloads = {
byChatId: {},
};
}
} }
const newState = { const newState = {

View File

@ -163,4 +163,8 @@ export const INITIAL_STATE: GlobalState = {
twoFaSettings: {}, twoFaSettings: {},
shouldShowContextMenuHint: true, shouldShowContextMenuHint: true,
activeDownloads: {
byChatId: {},
},
}; };

View File

@ -438,6 +438,10 @@ export type GlobalState = {
historyCalendarSelectedAt?: number; historyCalendarSelectedAt?: number;
openedStickerSetShortName?: string; openedStickerSetShortName?: string;
activeDownloads: {
byChatId: Record<number, number[]>;
};
shouldShowContextMenuHint?: boolean; shouldShowContextMenuHint?: boolean;
}; };
@ -470,6 +474,8 @@ export type ActionTypes = (
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
'reportMessages' | 'focusNextReply' | 'openChatByInvite' | 'reportMessages' | 'focusNextReply' | 'openChatByInvite' |
// downloads
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
// scheduled messages // scheduled messages
'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' |
// poll result // poll result

View File

@ -5,7 +5,7 @@ import { getDispatch } from '../lib/teact/teactn';
import { AudioOrigin } from '../types'; import { AudioOrigin } from '../types';
import { register, Track } from '../util/audioPlayer'; import { register, Track, TrackId } from '../util/audioPlayer';
import useEffectWithPrevDeps from './useEffectWithPrevDeps'; import useEffectWithPrevDeps from './useEffectWithPrevDeps';
import { isSafariPatchInProgress } from '../util/patchSafariProgressiveAudio'; import { isSafariPatchInProgress } from '../util/patchSafariProgressiveAudio';
import useOnChange from './useOnChange'; import useOnChange from './useOnChange';
@ -18,7 +18,7 @@ type Handler = (e: Event) => void;
const DEFAULT_SKIP_TIME = 10; const DEFAULT_SKIP_TIME = 10;
export default ( export default (
trackId: string, trackId: TrackId,
originalDuration: number, // Sometimes incorrect for voice messages originalDuration: number, // Sometimes incorrect for voice messages
trackType: Track['type'], trackType: Track['type'],
origin: AudioOrigin, origin: AudioOrigin,

View File

@ -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<HTMLElement>) => {
e.stopPropagation();
setIsDownloadStarted((isAllowed) => !isAllowed);
}, []);
return {
isDownloadStarted,
downloadProgress,
handleDownloadClick,
};
}

View File

@ -7,12 +7,13 @@ import { ApiMediaFormat } from '../api/types';
import { throttle } from '../util/schedulers'; import { throttle } from '../util/schedulers';
import * as mediaLoader from '../util/mediaLoader'; import * as mediaLoader from '../util/mediaLoader';
import useForceUpdate from './useForceUpdate'; import useForceUpdate from './useForceUpdate';
import useUniqueId from './useUniqueId';
const STREAMING_PROGRESS = 0.75; const STREAMING_PROGRESS = 0.75;
const STREAMING_TIMEOUT = 1500; const STREAMING_TIMEOUT = 1500;
const PROGRESS_THROTTLE = 500; const PROGRESS_THROTTLE = 500;
export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>( export default function useMediaWithLoadProgress<T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
mediaHash: string | undefined, mediaHash: string | undefined,
noLoad = false, noLoad = false,
// @ts-ignore (workaround for "could be instantiated with a different subtype" issue) // @ts-ignore (workaround for "could be instantiated with a different subtype" issue)
@ -20,19 +21,20 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
cacheBuster?: number, cacheBuster?: number,
delay?: number | false, delay?: number | false,
isHtmlAllowed = false, isHtmlAllowed = false,
) => { ) {
const mediaData = mediaHash ? mediaLoader.getFromMemory<T>(mediaHash) : undefined; const mediaData = mediaHash ? mediaLoader.getFromMemory<T>(mediaHash) : undefined;
const isStreaming = mediaFormat === ApiMediaFormat.Stream || ( const isStreaming = mediaFormat === ApiMediaFormat.Stream || (
IS_PROGRESSIVE_SUPPORTED && mediaFormat === ApiMediaFormat.Progressive IS_PROGRESSIVE_SUPPORTED && mediaFormat === ApiMediaFormat.Progressive
); );
const forceUpdate = useForceUpdate(); 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<number>(); const startedAtRef = useRef<number>();
const handleProgress = useMemo(() => { const handleProgress = useMemo(() => {
return throttle((progress: number) => { return throttle((progress: number) => {
if (!delay || (Date.now() - startedAtRef.current! > delay)) { if (startedAtRef.current && (!delay || (Date.now() - startedAtRef.current > delay))) {
setDownloadProgress(progress); setLoadProgress(progress);
} }
}, PROGRESS_THROTTLE, true); }, PROGRESS_THROTTLE, true);
}, [delay]); }, [delay]);
@ -40,7 +42,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
useEffect(() => { useEffect(() => {
if (!noLoad && mediaHash) { if (!noLoad && mediaHash) {
if (!mediaData) { if (!mediaData) {
setDownloadProgress(0); setLoadProgress(0);
if (startedAtRef.current) { if (startedAtRef.current) {
mediaLoader.cancelProgress(handleProgress); mediaLoader.cancelProgress(handleProgress);
@ -48,7 +50,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
startedAtRef.current = Date.now(); 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!; const spentTime = Date.now() - startedAtRef.current!;
startedAtRef.current = undefined; startedAtRef.current = undefined;
@ -60,21 +62,30 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
}); });
} else if (isStreaming) { } else if (isStreaming) {
setTimeout(() => { setTimeout(() => {
setDownloadProgress(STREAMING_PROGRESS); setLoadProgress(STREAMING_PROGRESS);
}, STREAMING_TIMEOUT); }, STREAMING_TIMEOUT);
} }
} }
}, [ }, [
noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress, noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress,
isHtmlAllowed, isHtmlAllowed, id,
]); ]);
useEffect(() => { useEffect(() => {
if (noLoad && startedAtRef.current) { if (noLoad && startedAtRef.current) {
mediaLoader.cancelProgress(handleProgress); mediaLoader.cancelProgress(handleProgress);
setDownloadProgress(0); setLoadProgress(0);
startedAtRef.current = undefined;
} }
}, [handleProgress, noLoad]); }, [handleProgress, noLoad]);
return { mediaData, downloadProgress }; useEffect(() => {
}; return () => {
if (mediaHash) {
mediaLoader.removeCallback(mediaHash, id);
}
};
}, [id, mediaHash]);
return { mediaData, loadProgress };
}

15
src/hooks/useUniqueId.ts Normal file
View File

@ -0,0 +1,15 @@
import { useRef } from '../lib/teact/teact';
import generateIdFor from '../util/generateIdFor';
const store: Record<string, boolean> = {};
export default () => {
const idRef = useRef<string>();
if (!idRef.current) {
idRef.current = generateIdFor(store);
store[idRef.current] = true;
}
return idRef.current;
};

View File

@ -394,6 +394,53 @@ addReducer('openForwardMenuForSelectedMessages', (global, actions) => {
actions.openForwardMenu({ fromChatId, messageIds }); 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) => { addReducer('enterMessageSelectMode', (global, actions, payload) => {
const { messageId } = payload || {}; const { messageId } = payload || {};
const openChat = selectCurrentChat(global); const openChat = selectCurrentChat(global);

View File

@ -168,6 +168,7 @@ export function getMessageMediaHash(
case 'viewerPreview': case 'viewerPreview':
return `${base}?size=x`; return `${base}?size=x`;
case 'viewerFull': case 'viewerFull':
case 'download':
return `${base}?size=z`; return `${base}?size=z`;
} }
} }
@ -183,7 +184,7 @@ export function getMessageMediaHash(
} }
return `${base}?size=m`; return `${base}?size=m`;
default: case 'download':
return base; return base;
} }
} }
@ -194,8 +195,10 @@ export function getMessageMediaHash(
return undefined; return undefined;
case 'pictogram': case 'pictogram':
return `${base}?size=m`; return `${base}?size=m`;
default: case 'inline':
return base; return base;
case 'download':
return `${base}?download`;
} }
} }
@ -204,10 +207,10 @@ export function getMessageMediaHash(
case 'micro': case 'micro':
case 'pictogram': case 'pictogram':
return getAudioHasCover(audio) ? `${base}?size=m` : undefined; return getAudioHasCover(audio) ? `${base}?size=m` : undefined;
case 'inline':
return getVideoOrAudioBaseHash(audio, base);
case 'download': case 'download':
return `${base}?download`; return `${base}?download`;
default:
return getVideoOrAudioBaseHash(audio, base);
} }
} }
@ -216,8 +219,10 @@ export function getMessageMediaHash(
case 'micro': case 'micro':
case 'pictogram': case 'pictogram':
return undefined; return undefined;
default: case 'inline':
return base; return base;
case 'download':
return `${base}?download`;
} }
} }
@ -330,9 +335,9 @@ export function getVideoDimensions(video: ApiVideo): ApiDimensions | undefined {
return 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 isUploading = isMessageLocal(message);
const isTransferring = isUploading || isDownloadNeeded; const isTransferring = isUploading || isLoadNeeded;
const transferProgress = Number(progress); const transferProgress = Number(progress);
return { return {

View File

@ -4,20 +4,25 @@ import {
import { LangFn } from '../../hooks/useLang'; import { LangFn } from '../../hooks/useLang';
import { LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIONS_USER_ID, RE_LINK_TEMPLATE } from '../../config'; 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 { getUserFullName } from './users';
import { isWebpSupported, IS_OPUS_SUPPORTED } from '../../util/environment';
import { getChatTitle } from './chats'; 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 CONTENT_NOT_SUPPORTED = 'The message is not supported on this version of Telegram';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
const TRUNCATED_SUMMARY_LENGTH = 80; const TRUNCATED_SUMMARY_LENGTH = 80;
export type MessageKey = string; // `msg${number}-${number}`; export type MessageKey = `msg${number}-${number}`;
export function getMessageKey(message: ApiMessage): MessageKey { export function getMessageKey(message: ApiMessage): MessageKey {
const { chatId, id } = message; 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) { 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); 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;
}

View File

@ -34,6 +34,7 @@ import {
import { findLast } from '../../util/iteratees'; import { findLast } from '../../util/iteratees';
import { selectIsStickerFavorite } from './symbols'; import { selectIsStickerFavorite } from './symbols';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
const MESSAGE_EDIT_ALLOWED_TIME = 172800; // 48 hours const MESSAGE_EDIT_ALLOWED_TIME = 172800; // 48 hours
@ -413,11 +414,16 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
const canCopy = !isAction; const canCopy = !isAction;
const canCopyLink = !isAction && (isChannel || isSuperGroup); const canCopyLink = !isAction && (isChannel || isSuperGroup);
const canSelect = !isAction; 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 = [ const noOptions = [
canReply, canReply,
canEdit, canEdit,
canPin, canPin,
canUnpin, canUnpin,
canReport,
canDelete, canDelete,
canDeleteForAll, canDeleteForAll,
canForward, canForward,
@ -426,6 +432,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopy, canCopy,
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload,
].every((ability) => !ability); ].every((ability) => !ability);
return { return {
@ -434,8 +441,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canEdit, canEdit,
canPin, canPin,
canUnpin, canUnpin,
canDelete,
canReport, canReport,
canDelete,
canDeleteForAll, canDeleteForAll,
canForward, canForward,
canFaveSticker, canFaveSticker,
@ -443,6 +450,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopy, canCopy,
canCopyLink, canCopyLink,
canSelect, canSelect,
canDownload,
}; };
} }
@ -480,6 +488,30 @@ export function selectCanReportSelectedMessages(global: GlobalState) {
return messageActions.every((actions) => actions.canReport); 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) { export function selectUploadProgress(global: GlobalState, message: ApiMessage) {
return global.fileUploads.byMessageLocalId[message.previousLocalId || message.id]?.progress; return global.fileUploads.byMessageLocalId[message.previousLocalId || message.id]?.progress;
} }

View File

@ -6,11 +6,11 @@ import { ApiMessage } from '../api/types';
import { IS_SAFARI } from './environment'; import { IS_SAFARI } from './environment';
import safePlay from './safePlay'; import safePlay from './safePlay';
import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio'; import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio';
import { getMessageKey, parseMessageKey } from '../modules/helpers'; import { getMessageKey, MessageKey, parseMessageKey } from '../modules/helpers';
import { fastRaf } from './schedulers'; import { fastRaf } from './schedulers';
type Handler = (eventName: string, e: Event) => void; type Handler = (eventName: string, e: Event) => void;
type TrackId = string; // `${MessageKey}-${number}`; export type TrackId = `${MessageKey}-${number}`;
export interface Track { export interface Track {
audio: HTMLAudioElement; audio: HTMLAudioElement;
@ -131,7 +131,7 @@ export function stopCurrentAudio() {
} }
export function register( export function register(
trackId: string, trackId: TrackId,
trackType: Track['type'], trackType: Track['type'],
origin: AudioOrigin, origin: AudioOrigin,
handler: Handler, handler: Handler,
@ -317,7 +317,7 @@ export function makeTrackId(message: ApiMessage): TrackId {
} }
function splitTrackId(trackId: 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()); const date = Number(trackId.split('-').pop());
return { return {
messageKey, messageKey,

View File

@ -2,5 +2,10 @@ export default function download(url: string, filename: string) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = filename; 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
}
} }

View File

@ -27,20 +27,26 @@ const PROGRESSIVE_URL_PREFIX = './progressive/';
const memoryCache = new Map<string, ApiPreparedMedia>(); const memoryCache = new Map<string, ApiPreparedMedia>();
const fetchPromises = new Map<string, Promise<ApiPreparedMedia | undefined>>(); const fetchPromises = new Map<string, Promise<ApiPreparedMedia | undefined>>();
const progressCallbacks = new Map<string, Map<string, ApiOnProgress>>();
const cancellableCallbacks = new Map<string, ApiOnProgress>();
export function fetch<T extends ApiMediaFormat>( export function fetch<T extends ApiMediaFormat>(
url: string, mediaFormat: T, isHtmlAllowed = false, onProgress?: ApiOnProgress, url: string,
mediaFormat: T,
isHtmlAllowed = false,
onProgress?: ApiOnProgress,
callbackUniqueId?: string,
): Promise<ApiMediaFormatToPrepared<T>> { ): Promise<ApiMediaFormatToPrepared<T>> {
if (mediaFormat === ApiMediaFormat.Progressive) { if (mediaFormat === ApiMediaFormat.Progressive) {
return ( return (
IS_PROGRESSIVE_SUPPORTED IS_PROGRESSIVE_SUPPORTED
? getProgressive(url) ? getProgressive(url)
: fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress) : fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress, callbackUniqueId)
) as Promise<ApiMediaFormatToPrepared<T>>; ) as Promise<ApiMediaFormatToPrepared<T>>;
} }
if (!fetchPromises.has(url)) { if (!fetchPromises.has(url)) {
const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed, onProgress) const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed)
.catch((err) => { .catch((err) => {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -51,11 +57,22 @@ export function fetch<T extends ApiMediaFormat>(
}) })
.finally(() => { .finally(() => {
fetchPromises.delete(url); fetchPromises.delete(url);
progressCallbacks.delete(url);
cancellableCallbacks.delete(url);
}); });
fetchPromises.set(url, promise); fetchPromises.set(url, promise);
} }
if (onProgress && callbackUniqueId) {
let activeCallbacks = progressCallbacks.get(url);
if (!activeCallbacks) {
activeCallbacks = new Map<string, ApiOnProgress>();
progressCallbacks.set(url, activeCallbacks);
}
activeCallbacks.set(callbackUniqueId, onProgress);
}
return fetchPromises.get(url) as Promise<ApiMediaFormatToPrepared<T>>; return fetchPromises.get(url) as Promise<ApiMediaFormatToPrepared<T>>;
} }
@ -64,7 +81,23 @@ export function getFromMemory<T extends ApiMediaFormat>(url: string) {
} }
export function cancelProgress(progressCallback: ApiOnProgress) { 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) { function getProgressive(url: string) {
@ -76,7 +109,7 @@ function getProgressive(url: string) {
} }
async function fetchFromCacheOrRemote( async function fetchFromCacheOrRemote(
url: string, mediaFormat: ApiMediaFormat, isHtmlAllowed: boolean, onProgress?: ApiOnProgress, url: string, mediaFormat: ApiMediaFormat, isHtmlAllowed: boolean,
) { ) {
if (!MEDIA_CACHE_DISABLED) { if (!MEDIA_CACHE_DISABLED) {
const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; 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'); const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
void callApi('downloadMedia', { url, mediaFormat }, (progress: number, arrayBuffer: ArrayBuffer) => { const onProgress = makeOnProgress(url, mediaSource, sourceBuffer);
if (onProgress) { cancellableCallbacks.set(url, onProgress);
onProgress(progress);
}
if (progress === 1) { void callApi('downloadMedia', { url, mediaFormat }, onProgress);
mediaSource.endOfStream();
}
if (!arrayBuffer) {
return;
}
sourceBuffer.appendBuffer(arrayBuffer!);
});
}); });
memoryCache.set(url, streamUrl); memoryCache.set(url, streamUrl);
return streamUrl; return streamUrl;
} }
const onProgress = makeOnProgress(url);
cancellableCallbacks.set(url, onProgress);
const remote = await callApi('downloadMedia', { url, mediaFormat, isHtmlAllowed }, onProgress); const remote = await callApi('downloadMedia', { url, mediaFormat, isHtmlAllowed }, onProgress);
if (!remote) { if (!remote) {
throw new Error('Failed to fetch media'); throw new Error(`Failed to fetch media ${url}`);
} }
let { prepared, mimeType } = remote; let { prepared, mimeType } = remote;
@ -150,7 +175,7 @@ async function fetchFromCacheOrRemote(
URL.revokeObjectURL(prepared as string); URL.revokeObjectURL(prepared as string);
const media = await oggToWav(blob); const media = await oggToWav(blob);
prepared = prepareMedia(media); prepared = prepareMedia(media);
mimeType = blob.type; mimeType = media.type;
} }
if (mimeType === 'image/webp' && !isWebpSupported()) { if (mimeType === 'image/webp' && !isWebpSupported()) {
@ -167,6 +192,27 @@ async function fetchFromCacheOrRemote(
return prepared; 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 { function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia {
if (mediaData instanceof Blob) { if (mediaData instanceof Blob) {
return URL.createObjectURL(mediaData); return URL.createObjectURL(mediaData);