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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,7 +51,7 @@ import captureEscKeyListener from '../../util/captureEscKeyListener';
import { stopCurrentAudio } from '../../util/audioPlayer';
import useForceUpdate from '../../hooks/useForceUpdate';
import useMedia from '../../hooks/useMedia';
import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useBlurSync from '../../hooks/useBlurSync';
import usePrevious from '../../hooks/usePrevious';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
@ -181,7 +181,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
undefined,
isGhostAnimation && ANIMATION_DURATION,
);
const { mediaData: fullMediaBlobUrl, downloadProgress } = useMediaWithDownloadProgress(
const { mediaData: fullMediaBlobUrl, loadProgress } = useMediaWithLoadProgress(
getMediaHash(true),
undefined,
message && getMessageMediaFormat(message, 'viewerFull'),
@ -485,7 +485,7 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
isGif={isGif}
posterData={bestImageData}
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
downloadProgress={downloadProgress}
loadProgress={loadProgress}
fileSize={videoSize!}
isMediaViewerOpen={isOpen}
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 { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { getMessageMediaHash } from '../../modules/helpers';
import useLang from '../../hooks/useLang';
import useMediaDownload from '../../hooks/useMediaDownload';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import { selectIsDownloading } from '../../modules/selectors';
import { pick } from '../../util/iteratees';
import Button from '../ui/Button';
import DropdownMenu from '../ui/DropdownMenu';
@ -14,6 +23,10 @@ import ProgressSpinner from '../ui/ProgressSpinner';
import './MediaViewerActions.scss';
type StateProps = {
isDownloading: boolean;
};
type OwnProps = {
mediaData?: string;
isVideo: boolean;
@ -26,26 +39,35 @@ type OwnProps = {
onZoomToggle: NoneToVoidFunction;
};
const MediaViewerActions: FC<OwnProps> = ({
type DispatchProps = Pick<GlobalActions, 'downloadMessageMedia' | 'cancelMessageMediaDownload'>;
const MediaViewerActions: FC<OwnProps & StateProps & DispatchProps> = ({
mediaData,
isVideo,
isZoomed,
message,
fileName,
isAvatar,
isDownloading,
onCloseMediaViewer,
onForward,
onZoomToggle,
downloadMessageMedia,
cancelMessageMediaDownload,
}) => {
const {
isDownloadStarted,
downloadProgress,
handleDownloadClick,
} = useMediaDownload(
message && isVideo ? getMessageMediaHash(message, 'download') : undefined,
fileName,
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
message && getMessageMediaHash(message, 'download'),
!isDownloading,
);
const handleDownloadClick = useCallback(() => {
if (isDownloading) {
cancelMessageMediaDownload({ message });
} else {
downloadMessageMedia({ message });
}
}, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]);
const lang = useLang();
const MenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
@ -80,10 +102,10 @@ const MediaViewerActions: FC<OwnProps> = ({
)}
{isVideo ? (
<MenuItem
icon={isDownloadStarted ? 'close' : 'download'}
icon={isDownloading ? 'close' : 'download'}
onClick={handleDownloadClick}
>
{isDownloadStarted ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'}
{isDownloading ? `${Math.round(downloadProgress * 100)}% Downloading...` : 'Download'}
</MenuItem>
) : (
<MenuItem
@ -95,7 +117,7 @@ const MediaViewerActions: FC<OwnProps> = ({
</MenuItem>
)}
</DropdownMenu>
{isDownloadStarted && <ProgressSpinner progress={downloadProgress} size="s" noCross />}
{isDownloading && <ProgressSpinner progress={downloadProgress} size="s" noCross />}
</div>
);
}
@ -123,7 +145,7 @@ const MediaViewerActions: FC<OwnProps> = ({
ariaLabel={lang('AccActionDownload')}
onClick={handleDownloadClick}
>
{isDownloadStarted ? (
{isDownloading ? (
<ProgressSpinner progress={downloadProgress} size="s" onClick={handleDownloadClick} />
) : (
<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;
posterData?: string;
posterSize?: ApiDimensions;
downloadProgress?: number;
loadProgress?: number;
fileSize: number;
isMediaViewerOpen?: boolean;
noPlay?: boolean;
@ -36,7 +36,7 @@ const VideoPlayer: FC<OwnProps> = ({
isGif,
posterData,
posterSize,
downloadProgress,
loadProgress,
fileSize,
isMediaViewerOpen,
noPlay,
@ -196,7 +196,7 @@ const VideoPlayer: FC<OwnProps> = ({
{!isBuffered && <div className="buffering">Buffering...</div>}
<ProgressSpinner
size="xl"
progress={isBuffered ? 1 : downloadProgress}
progress={isBuffered ? 1 : loadProgress}
square
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 (
<div className="player-file-size">
{`${formatFileSize(totalSize * downloadedPercent)} / ${formatFileSize(totalSize)}`}
{`${formatFileSize(totalSize * loadedPercent)} / ${formatFileSize(totalSize)}`}
</div>
);
}

View File

@ -9,7 +9,7 @@
margin: 0;
@supports (padding-bottom: env(safe-area-inset-bottom)) {
bottom: calc(.5rem + env(safe-area-inset-bottom));
bottom: calc(0.5rem + env(safe-area-inset-bottom));
}
.mask-image-disabled &::before {
@ -123,16 +123,53 @@
margin-left: auto;
display: flex;
.MenuItem {
.item {
width: 100%;
background: none;
border: none !important;
box-shadow: none !important;
outline: none !important;
display: flex;
position: relative;
overflow: hidden;
line-height: 1.5rem;
white-space: nowrap;
color: var(--color-text);
--ripple-color: rgba(0, 0, 0, 0.08);
cursor: pointer;
unicode-bidi: plaintext;
padding: 0.6875rem;
border-radius: 50%;
i {
margin-right: 0;
font-size: 1.5rem;
color: var(--color-text-secondary);
}
.item-text {
display: none;
&.destructive {
color: var(--color-error);
i {
color: inherit;
}
}
&.disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
&:not(.disabled):active {
background-color: var(--color-item-active);
transition: none !important;
}
@media (hover: hover) {
&:hover, &:focus {
background-color: var(--color-chat-hover);
text-decoration: none;
}
}
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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 });
});
addReducer('cancelMessageMediaDownload', (global, actions, payload) => {
const { message } = payload!;
const byChatId = global.activeDownloads.byChatId[message.chatId];
if (!byChatId || !byChatId.length) return;
setGlobal({
...global,
activeDownloads: {
byChatId: {
...global.activeDownloads.byChatId,
[message.chatId]: byChatId.filter((id) => id !== message.id),
},
},
});
});
addReducer('downloadMessageMedia', (global, actions, payload) => {
const { message } = payload!;
if (!message) return;
setGlobal({
...global,
activeDownloads: {
byChatId: {
...global.activeDownloads.byChatId,
[message.chatId]: [...(global.activeDownloads.byChatId[message.chatId] || []), message.id],
},
},
});
});
addReducer('downloadSelectedMessages', (global, actions) => {
if (!global.selectedMessages) {
return;
}
const { chatId, messageIds } = global.selectedMessages;
const { threadId } = selectCurrentMessageList(global) || {};
const chatMessages = selectChatMessages(global, chatId);
if (!chatMessages || !threadId) return;
const messages = messageIds.map((id) => chatMessages[id])
.filter((message) => selectAllowedMessageActions(global, message, threadId).canDownload);
messages.forEach((message) => actions.downloadMessageMedia({ message }));
});
addReducer('enterMessageSelectMode', (global, actions, payload) => {
const { messageId } = payload || {};
const openChat = selectCurrentChat(global);

View File

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

View File

@ -4,20 +4,25 @@ import {
import { LangFn } from '../../hooks/useLang';
import { LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIONS_USER_ID, RE_LINK_TEMPLATE } from '../../config';
import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString';
import { getUserFullName } from './users';
import { isWebpSupported, IS_OPUS_SUPPORTED } from '../../util/environment';
import { getChatTitle } from './chats';
import parseEmojiOnlyString from '../../components/common/helpers/parseEmojiOnlyString';
const CONTENT_NOT_SUPPORTED = 'The message is not supported on this version of Telegram';
const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
const TRUNCATED_SUMMARY_LENGTH = 80;
export type MessageKey = string; // `msg${number}-${number}`;
export type MessageKey = `msg${number}-${number}`;
export function getMessageKey(message: ApiMessage): MessageKey {
const { chatId, id } = message;
return `msg${chatId}-${id}`;
return buildMessageKey(chatId, id);
}
export function buildMessageKey(chatId: number, msgId: number): MessageKey {
return `msg${chatId}-${msgId}`;
}
export function parseMessageKey(key: MessageKey) {
@ -226,3 +231,39 @@ export function getMessageAudioCaption(message: ApiMessage) {
return (audio && [audio.title, audio.performer].filter(Boolean).join(' — ')) || (text?.text);
}
export function getMessageContentFilename(message: ApiMessage) {
const { content } = message;
const video = content.webPage ? content.webPage.video : content.video;
const photo = content.webPage ? content.webPage.photo : content.photo;
const document = content.webPage ? content.webPage.document : content.document;
if (document) {
return document.fileName;
}
if (video) {
return video.fileName;
}
if (content.sticker) {
const extension = content.sticker.isAnimated ? 'tgs' : isWebpSupported() ? 'webp' : 'png';
return `${content.sticker.id}.${extension}`;
}
if (content.audio) {
return content.audio.fileName;
}
const baseFilename = getMessageKey(message);
if (photo) {
return `${baseFilename}.png`;
}
if (content.voice) {
return IS_OPUS_SUPPORTED ? `${baseFilename}.ogg` : `${baseFilename}.wav`;
}
return baseFilename;
}

View File

@ -34,6 +34,7 @@ import {
import { findLast } from '../../util/iteratees';
import { selectIsStickerFavorite } from './symbols';
import { getServerTime } from '../../util/serverTime';
import { MEMO_EMPTY_ARRAY } from '../../util/memo';
const MESSAGE_EDIT_ALLOWED_TIME = 172800; // 48 hours
@ -413,11 +414,16 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
const canCopy = !isAction;
const canCopyLink = !isAction && (isChannel || isSuperGroup);
const canSelect = !isAction;
const canDownload = Boolean(content.webPage?.document || content.webPage?.video || content.webPage?.photo
|| content.audio || content.voice || content.photo || content.video || content.document || content.sticker);
const noOptions = [
canReply,
canEdit,
canPin,
canUnpin,
canReport,
canDelete,
canDeleteForAll,
canForward,
@ -426,6 +432,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopy,
canCopyLink,
canSelect,
canDownload,
].every((ability) => !ability);
return {
@ -434,8 +441,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canEdit,
canPin,
canUnpin,
canDelete,
canReport,
canDelete,
canDeleteForAll,
canForward,
canFaveSticker,
@ -443,6 +450,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canCopy,
canCopyLink,
canSelect,
canDownload,
};
}
@ -480,6 +488,30 @@ export function selectCanReportSelectedMessages(global: GlobalState) {
return messageActions.every((actions) => actions.canReport);
}
export function selectCanDownloadSelectedMessages(global: GlobalState) {
const { messageIds: selectedMessageIds } = global.selectedMessages || {};
const { chatId, threadId } = selectCurrentMessageList(global) || {};
const chatMessages = chatId && selectChatMessages(global, chatId);
if (!chatMessages || !selectedMessageIds || !threadId) {
return false;
}
const messageActions = selectedMessageIds
.map((id) => chatMessages[id] && selectAllowedMessageActions(global, chatMessages[id], threadId))
.filter(Boolean);
return messageActions.some((actions) => actions.canDownload);
}
export function selectIsDownloading(global: GlobalState, message: ApiMessage) {
const activeInChat = global.activeDownloads.byChatId[message.chatId];
return activeInChat ? activeInChat.includes(message.id) : false;
}
export function selectActiveDownloadIds(global: GlobalState, chatId: number) {
return global.activeDownloads.byChatId[chatId] || MEMO_EMPTY_ARRAY;
}
export function selectUploadProgress(global: GlobalState, message: ApiMessage) {
return global.fileUploads.byMessageLocalId[message.previousLocalId || message.id]?.progress;
}

View File

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

View File

@ -2,5 +2,10 @@ export default function download(url: string, filename: string) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
try {
link.click();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err); // Suppress redundant "Blob loading failed" error popup on IOS
}
}

View File

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