From d4629cca8ceaa674fa3c51ad5bd44cc5eb340994 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 1 Apr 2022 20:43:16 +0200 Subject: [PATCH] Message List: Fix duplicated downloads (#1781) --- src/components/main/DownloadManager.tsx | 94 +++++++++++-------- .../mediaViewer/MediaViewerActions.tsx | 4 +- src/global/actions/ui/messages.ts | 21 ++++- src/global/types.ts | 14 ++- src/util/download.ts | 38 ++++++++ 5 files changed, 127 insertions(+), 44 deletions(-) diff --git a/src/components/main/DownloadManager.tsx b/src/components/main/DownloadManager.tsx index 6db293cb0..a4abc9599 100644 --- a/src/components/main/DownloadManager.tsx +++ b/src/components/main/DownloadManager.tsx @@ -1,4 +1,6 @@ -import { FC, memo, useEffect } from '../../lib/teact/teact'; +import { + FC, memo, useCallback, useEffect, +} from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import { Thread } from '../../global/types'; @@ -10,58 +12,76 @@ import { getMessageContentFilename, getMessageMediaHash, } from '../../global/helpers'; +import useDebounce from '../../hooks/useDebounce'; + type StateProps = { - activeDownloads: Record; - messages: Record; + messages: Record; threadsById: Record; }>; }; -const startedDownloads = new Set(); +const GLOBAL_UPDATE_DEBOUNCE = 1000; + +const processedMessages = new Set(); +const downloadedMessages = new Set(); const DownloadManager: FC = ({ activeDownloads, messages, }) => { - const { cancelMessageMediaDownload } = getActions(); + const { cancelMessagesMediaDownload } = getActions(); + + const debouncedGlobalUpdate = useDebounce(GLOBAL_UPDATE_DEBOUNCE, true); + + const handleMessageDownloaded = useCallback((message: ApiMessage) => { + downloadedMessages.add(message); + debouncedGlobalUpdate(() => { + if (downloadedMessages.size) { + cancelMessagesMediaDownload({ messages: Array.from(downloadedMessages) }); + downloadedMessages.clear(); + } + }); + }, [cancelMessagesMediaDownload, debouncedGlobalUpdate]); useEffect(() => { - Object.entries(activeDownloads).forEach(([chatId, messageIds]) => { - const activeMessages = messageIds.map((id) => messages[Number(chatId)].byId[id]); - activeMessages.forEach((message) => { - const downloadHash = getMessageMediaHash(message, 'download'); - if (!downloadHash) { - cancelMessageMediaDownload({ message }); - return; - } - - if (!startedDownloads.has(downloadHash)) { - const mediaData = mediaLoader.getFromMemory(downloadHash); - if (mediaData) { - startedDownloads.delete(downloadHash); - download(mediaData, getMessageContentFilename(message)); - cancelMessageMediaDownload({ message }); - return; - } - - mediaLoader.fetch(downloadHash, ApiMediaFormat.BlobUrl, true).then((result) => { - startedDownloads.delete(downloadHash); - if (result) { - download(result, getMessageContentFilename(message)); - } - cancelMessageMediaDownload({ message }); - }); - - startedDownloads.add(downloadHash); + const activeMessages = Object.entries(activeDownloads).map(([chatId, messageIds]) => ( + messageIds.map((id) => messages[chatId].byId[id]) + )).flat(); + + if (!activeMessages.length) { + processedMessages.clear(); + return; + } + + activeMessages.forEach((message) => { + if (processedMessages.has(message)) { + return; + } + processedMessages.add(message); + const downloadHash = getMessageMediaHash(message, 'download'); + if (!downloadHash) { + handleMessageDownloaded(message); + return; + } + + const mediaData = mediaLoader.getFromMemory(downloadHash); + + if (mediaData) { + download(mediaData, getMessageContentFilename(message)); + handleMessageDownloaded(message); + return; + } + + mediaLoader.fetch(downloadHash, ApiMediaFormat.BlobUrl, true).then((result) => { + if (result) { + download(result, getMessageContentFilename(message)); } + handleMessageDownloaded(message); }); }); - }, [ - cancelMessageMediaDownload, - messages, - activeDownloads, - ]); + }, [messages, activeDownloads, cancelMessagesMediaDownload, handleMessageDownloaded]); return undefined; }; diff --git a/src/components/mediaViewer/MediaViewerActions.tsx b/src/components/mediaViewer/MediaViewerActions.tsx index d7b76cdd4..60cbc2a1f 100644 --- a/src/components/mediaViewer/MediaViewerActions.tsx +++ b/src/components/mediaViewer/MediaViewerActions.tsx @@ -63,9 +63,9 @@ const MediaViewerActions: FC = ({ const handleDownloadClick = useCallback(() => { if (isDownloading) { - cancelMessageMediaDownload({ message }); + cancelMessageMediaDownload({ message: message! }); } else { - downloadMessageMedia({ message }); + downloadMessageMedia({ message: message! }); } }, [cancelMessageMediaDownload, downloadMessageMedia, isDownloading, message]); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 9565c22be..70d7664f3 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -460,7 +460,7 @@ addActionHandler('openForwardMenuForSelectedMessages', (global, actions) => { }); addActionHandler('cancelMessageMediaDownload', (global, actions, payload) => { - const { message } = payload!; + const { message } = payload; const byChatId = global.activeDownloads.byChatId[message.chatId]; if (!byChatId || !byChatId.length) return; @@ -476,9 +476,24 @@ addActionHandler('cancelMessageMediaDownload', (global, actions, payload) => { }); }); +addActionHandler('cancelMessagesMediaDownload', (global, actions, payload) => { + const { messages } = payload; + + const byChatId = global.activeDownloads.byChatId; + const newByChatId: GlobalState['activeDownloads']['byChatId'] = {}; + Object.keys(byChatId).forEach((chatId) => { + newByChatId[chatId] = byChatId[chatId].filter((id) => !messages.find((message) => message.id === id)); + }); + return { + ...global, + activeDownloads: { + byChatId: newByChatId, + }, + }; +}); + addActionHandler('downloadMessageMedia', (global, actions, payload) => { - const { message } = payload!; - if (!message) return; + const { message } = payload; setGlobal({ ...global, diff --git a/src/global/types.ts b/src/global/types.ts index fdf46dee0..8d9c81b9a 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -585,6 +585,18 @@ export interface ActionPayloads { origin: AudioOrigin; }; + // Downloads + downloadSelectedMessages: {}; + downloadMessageMedia: { + message: ApiMessage; + }; + cancelMessageMediaDownload: { + message: ApiMessage; + }; + cancelMessagesMediaDownload: { + messages: ApiMessage[]; + }; + // Users openAddContactDialog: { userId?: string; @@ -640,8 +652,6 @@ export type NonTypedActionNames = ( 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | 'setEditingId' | - // downloads - 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | // poll result diff --git a/src/util/download.ts b/src/util/download.ts index 51fcbd6bb..1321f1f9a 100644 --- a/src/util/download.ts +++ b/src/util/download.ts @@ -1,4 +1,42 @@ +import { sleep } from '../lib/gramjs/Helpers'; + +type PendingDownload = { + url: string; + filename: string; +}; + +// Chrome prevents more than 10 downloads per second +const LIMIT_PER_BATCH = 10; +const BATCH_INTERVAL = 1000; + +let pendingDownloads: PendingDownload[] = []; +let planned = false; + export default function download(url: string, filename: string) { + pendingDownloads.push({ url, filename }); + if (!planned) { + planned = true; + setTimeout(async () => { + await processQueue(); + planned = false; + }, BATCH_INTERVAL); + } +} + +async function processQueue() { + let count = 0; + for (const pendingDownload of pendingDownloads) { + downloadOne(pendingDownload); + count++; + if (count === LIMIT_PER_BATCH) { + await sleep(BATCH_INTERVAL); + count = 0; + } + } + pendingDownloads = []; +} + +function downloadOne({ url, filename }: PendingDownload) { const link = document.createElement('a'); link.href = url; link.download = filename;