diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 053c89f4..a8eecd7b 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -503,7 +503,7 @@ export default class ChatBubbles { div.replaceWith(newDiv); if(timeSpan) { - newDiv.querySelector('.document-size').append(timeSpan); + newDiv.querySelector('.document').append(timeSpan); } }); } @@ -4127,7 +4127,7 @@ export default class ChatBubbles { nameContainer = newNameContainer; } - const lastContainer = messageDiv.lastElementChild.querySelector('.document-message, .document-size, .audio'); + const lastContainer = messageDiv.lastElementChild.querySelector('.document-message, .document, .audio'); // lastContainer && lastContainer.append(timeSpan.cloneNode(true)); lastContainer && lastContainer.append(timeSpan); diff --git a/src/components/dialogsContextMenu.ts b/src/components/dialogsContextMenu.ts index fe97822a..065169e5 100644 --- a/src/components/dialogsContextMenu.ts +++ b/src/components/dialogsContextMenu.ts @@ -108,7 +108,7 @@ export default class DialogsContextMenu { }; private onPinClick = () => { - this.managers.appMessagesManager.toggleDialogPin(this.selectedId, this.filterId).catch(async(err) => { + this.managers.appMessagesManager.toggleDialogPin(this.selectedId, this.filterId).catch(async(err: ApiError) => { if(err.type === 'PINNED_DIALOGS_TOO_MUCH') { if(this.filterId >= 1) { toastNew({langPackKey: 'PinFolderLimitReached'}); diff --git a/src/components/lazyLoadQueueBase.ts b/src/components/lazyLoadQueueBase.ts index 36852f22..efd7516f 100644 --- a/src/components/lazyLoadQueueBase.ts +++ b/src/components/lazyLoadQueueBase.ts @@ -80,7 +80,8 @@ export default class LazyLoadQueueBase { //await item.load(item.div); await this.loadItem(item); } catch(err) { - if(!['NO_ENTRY_FOUND', 'STORAGE_OFFLINE'].includes(err as string)) { + const ignoreErrors: Set = new Set(['NO_ENTRY_FOUND', 'STORAGE_OFFLINE']); + if(!ignoreErrors.has((err as ApiError)?.type)) { this.log.error('loadMediaQueue error:', err/* , item */); } } diff --git a/src/components/popups/payment.ts b/src/components/popups/payment.ts index d341b8a3..6c84631a 100644 --- a/src/components/popups/payment.ts +++ b/src/components/popups/payment.ts @@ -13,7 +13,6 @@ import { detectUnifiedCardBrand } from "../../helpers/cards/cardBrands"; import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; import findUpAsChild from "../../helpers/dom/findUpAsChild"; import findUpClassName from "../../helpers/dom/findUpClassName"; -import loadScript from "../../helpers/dom/loadScript"; import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; import replaceContent from "../../helpers/dom/replaceContent"; @@ -25,7 +24,6 @@ import ScrollSaver from "../../helpers/scrollSaver"; import tsNow from "../../helpers/tsNow"; import { AccountTmpPassword, InputInvoice, InputPaymentCredentials, LabeledPrice, Message, MessageMedia, PaymentRequestedInfo, PaymentSavedCredentials, PaymentsPaymentForm, PaymentsPaymentReceipt, PaymentsValidatedRequestedInfo, PostAddress, ShippingOption } from "../../layer"; import I18n, { i18n, LangPackKey, _i18n } from "../../lib/langPack"; -import { ApiError } from "../../lib/mtproto/apiManager"; import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText"; import wrapRichText from "../../lib/richTextProcessor/wrapRichText"; import rootScope from "../../lib/rootScope"; diff --git a/src/components/popups/paymentCardConfirmation.ts b/src/components/popups/paymentCardConfirmation.ts index 8b4e76af..073513ec 100644 --- a/src/components/popups/paymentCardConfirmation.ts +++ b/src/components/popups/paymentCardConfirmation.ts @@ -7,7 +7,6 @@ import PopupElement from "."; import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; import { AccountPassword, AccountTmpPassword } from "../../layer"; -import { ApiError } from "../../lib/mtproto/apiManager"; import { InputState } from "../inputField"; import PasswordInputField from "../passwordInputField"; import { SettingSection } from "../sidebarLeft"; diff --git a/src/components/popups/paymentShipping.ts b/src/components/popups/paymentShipping.ts index bfd3a56a..78520350 100644 --- a/src/components/popups/paymentShipping.ts +++ b/src/components/popups/paymentShipping.ts @@ -5,14 +5,9 @@ */ import PopupElement from "."; -import { attachClickEvent } from "../../helpers/dom/clickEvent"; import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; -import toggleDisability from "../../helpers/dom/toggleDisability"; -import { InputInvoice, Message, PaymentRequestedInfo, PaymentsPaymentForm, PaymentsValidatedRequestedInfo } from "../../layer"; -import getServerMessageId from "../../lib/appManagers/utils/messageId/getServerMessageId"; -import { ApiError } from "../../lib/mtproto/apiManager"; +import { InputInvoice, PaymentRequestedInfo, PaymentsPaymentForm, PaymentsValidatedRequestedInfo } from "../../layer"; import matchEmail from "../../lib/richTextProcessor/matchEmail"; -import Button from "../button"; import CheckboxField from "../checkboxField"; import CountryInputField from "../countryInputField"; import InputField from "../inputField"; diff --git a/src/components/wrappers/document.ts b/src/components/wrappers/document.ts index d00675c7..570b1736 100644 --- a/src/components/wrappers/document.ts +++ b/src/components/wrappers/document.ts @@ -21,6 +21,7 @@ import { AppManagers } from "../../lib/appManagers/managers"; import getDownloadMediaDetails from "../../lib/appManagers/utils/download/getDownloadMediaDetails"; import choosePhotoSize from "../../lib/appManagers/utils/photos/choosePhotoSize"; import { joinElementsWith } from "../../lib/langPack"; +import { MAX_FILE_SAVE_SIZE } from "../../lib/mtproto/mtproto_config"; import wrapPlainText from "../../lib/richTextProcessor/wrapPlainText"; import rootScope from "../../lib/rootScope"; import type { ThumbCache } from "../../lib/storages/thumbs"; @@ -94,6 +95,7 @@ export default async function wrapDocument({message, withTime, fontWeight, voice const icoDiv = document.createElement('div'); icoDiv.classList.add('document-ico'); + let icoTextEl: HTMLElement; const hadContext = !!cacheContext; const getCacheContext = () => { @@ -101,8 +103,10 @@ export default async function wrapDocument({message, withTime, fontWeight, voice }; cacheContext = await getCacheContext(); + let hasThumb = false; if((doc.thumbs?.length || (message.pFlags.is_outgoing && cacheContext.url && doc.type === 'photo'))/* && doc.mime_type !== 'image/gif' */) { docDiv.classList.add('document-with-thumb'); + hasThumb = true; let imgs: (HTMLImageElement | HTMLCanvasElement)[] = []; // ! WARNING, use thumbs for check when thumb will be generated for media @@ -131,14 +135,20 @@ export default async function wrapDocument({message, withTime, fontWeight, voice imgs.forEach((img) => img.classList.add('document-thumb')); } else { - icoDiv.innerText = ext; + icoTextEl = document.createElement('span'); + icoTextEl.classList.add('document-ico-text'); + icoTextEl.innerText = ext; + icoDiv.append(icoTextEl); } //let fileName = stringMiddleOverflow(doc.file_name || 'Unknown.file', 26); let fileName = doc.file_name ? wrapPlainText(doc.file_name) : 'Unknown.file'; const descriptionEl = document.createElement('div'); descriptionEl.classList.add('document-description'); + const bytesContainer = document.createElement('span'); const bytesEl = formatBytes(doc.size); + const bytesJoiner = ' / '; + const descriptionParts: (HTMLElement | string | DocumentFragment)[] = [bytesEl]; if(withTime) { @@ -149,8 +159,16 @@ export default async function wrapDocument({message, withTime, fontWeight, voice descriptionParts.push(await wrapSenderToPeer(message)); } + if(!withTime && !showSender) { + const b = document.createElement('span'); + const bytesMaxEl = formatBytes(doc.size); + b.append(bytesJoiner, bytesMaxEl); + b.style.visibility = 'hidden'; + descriptionParts.push(b); + } + docDiv.innerHTML = ` - ${(cacheContext.downloaded && !uploadFileName) || !message.mid ? '' : `
`} + ${(cacheContext.downloaded && !uploadFileName) || !message.mid || !hasThumb ? '' : `
`}
`; @@ -169,7 +187,8 @@ export default async function wrapDocument({message, withTime, fontWeight, voice } const sizeDiv = docDiv.querySelector('.document-size') as HTMLElement; - sizeDiv.append(...joinElementsWith(descriptionParts, ' · ')); + bytesContainer.append(...joinElementsWith(descriptionParts, ' · ')); + sizeDiv.append(bytesContainer); docDiv.prepend(icoDiv); @@ -179,12 +198,20 @@ export default async function wrapDocument({message, withTime, fontWeight, voice let downloadDiv: HTMLElement, preloader: ProgressivePreloader = null; const onLoad = () => { + if(doc.size <= MAX_FILE_SAVE_SIZE) { + docDiv.classList.add('downloaded'); + } + + docDiv.classList.remove('downloading'); + if(downloadDiv) { - downloadDiv.classList.add('downloaded'); - const _downloadDiv = downloadDiv; - setTimeout(() => { - _downloadDiv.remove(); - }, 200); + if(downloadDiv !== icoDiv) { + const _downloadDiv = downloadDiv; + setTimeout(() => { + _downloadDiv.remove(); + }, 200); + } + downloadDiv = null; } @@ -194,17 +221,26 @@ export default async function wrapDocument({message, withTime, fontWeight, voice }; const addByteProgress = (promise: CancellablePromise) => { + docDiv.classList.add('downloading'); + const sizeContainer = document.createElement('span'); - promise.then(() => { - onLoad(); - sizeContainer.replaceWith(bytesEl); - }, () => { - replaceContent(sizeContainer, bytesEl); + const _bytesContainer = formatBytes(doc.size); + sizeContainer.style.position = 'absolute'; + sizeContainer.style.left = '0'; + promise.then(onLoad, noop).finally(() => { + // sizeContainer.replaceWith(bytesContainer); + bytesContainer.style.visibility = ''; + sizeContainer.remove(); + // b && b.classList.remove('hide'); }); + + // b && b.classList.add('hide'); let d = formatBytes(0); - bytesEl.replaceWith(sizeContainer); - sizeContainer.append(d, ' / ', bytesEl); + bytesContainer.style.visibility = 'hidden'; + // bytesContainer.replaceWith(sizeContainer); + sizeContainer.append(d, bytesJoiner, _bytesContainer); + bytesContainer.parentElement.append(sizeContainer); promise.addNotifyListener((progress: Progress) => { const _d = formatBytes(progress.done); d.replaceWith(_d); @@ -236,6 +272,10 @@ export default async function wrapDocument({message, withTime, fontWeight, voice download = appDownloadManager.downloadToDisc({media: doc, queueId}); } + download.catch(() => { + docDiv.classList.remove('downloading'); + }); + if(downloadDiv) { preloader.attach(downloadDiv, true, download); addByteProgress(download); @@ -244,14 +284,15 @@ export default async function wrapDocument({message, withTime, fontWeight, voice const {fileName: downloadFileName} = getDownloadMediaDetails({media: doc}); if(await managers.apiFileManager.isDownloading(downloadFileName)) { - downloadDiv = docDiv.querySelector('.document-download'); + downloadDiv = docDiv.querySelector('.document-download') || icoDiv; const promise = appDownloadManager.downloadMediaVoid({media: doc}); preloader = new ProgressivePreloader(); preloader.attach(downloadDiv, false, promise); preloader.setDownloadFunction(load); + addByteProgress(promise); } else if(!cacheContext.downloaded || uploadFileName) { - downloadDiv = docDiv.querySelector('.document-download'); + downloadDiv = docDiv.querySelector('.document-download') || icoDiv; preloader = new ProgressivePreloader({ isUpload: !!uploadFileName }); diff --git a/src/config/databases/index.ts b/src/config/databases/index.ts index 6e8104b5..ee893f59 100644 --- a/src/config/databases/index.ts +++ b/src/config/databases/index.ts @@ -4,7 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import { IDBStore } from "../../lib/idb"; +import { IDBStore } from "../../lib/files/idb"; export type DatabaseStore = Omit & {name: StoreName}; export type Database = { diff --git a/src/config/databases/state.ts b/src/config/databases/state.ts index 7de18d86..1b1bb545 100644 --- a/src/config/databases/state.ts +++ b/src/config/databases/state.ts @@ -5,7 +5,7 @@ */ import type { Database } from '.'; -import type { IDBIndex } from '../../lib/idb'; +import type { IDBIndex } from '../../lib/files/idb'; const DATABASE_STATE: Database<'session' | 'stickerSets' | 'users' | 'chats' | 'messages' | 'dialogs'> = { name: 'tweb', diff --git a/src/global.d.ts b/src/global.d.ts index 81b4d911..45fd1fee 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -23,13 +23,36 @@ declare global { type Long = string | number; type MTLong = string; - type LocalErrorType = 'DOWNLOAD_CANCELED'; - type ServerErrorType = 'FILE_REFERENCE_EXPIRED'; + type ApiFileManagerError = 'DOWNLOAD_CANCELED' | 'UPLOAD_CANCELED' | 'FILE_TOO_BIG' | 'REFERENCE_IS_NOT_REFRESHED'; + type StorageError = 'STORAGE_OFFLINE' | 'NO_ENTRY_FOUND' | 'IDB_CREATE_TIMEOUT'; + type ReferenceError = 'NO_NEW_CONTEXT'; + type NetworkerError = 'NETWORK_BAD_RESPONSE'; + type FiltersError = 'PINNED_DIALOGS_TOO_MUCH'; + + type LocalFileError = ApiFileManagerError | ReferenceError | StorageError; + type LocalErrorType = LocalFileError | NetworkerError | FiltersError | 'UNKNOWN'; + + type ServerErrorType = 'FILE_REFERENCE_EXPIRED' | 'SESSION_REVOKED' | 'AUTH_KEY_DUPLICATED' | + 'SESSION_PASSWORD_NEEDED' | 'CONNECTION_NOT_INITED' | 'ERROR_EMPTY' | 'MTPROTO_CLUSTER_INVALID' | + 'BOT_PRECHECKOUT_TIMEOUT' | 'TMP_PASSWORD_INVALID' | 'PASSWORD_HASH_INVALID' | 'CHANNEL_PRIVATE'; + + type ErrorType = LocalErrorType | ServerErrorType; interface Error { - type?: LocalErrorType | ServerErrorType; + type?: ErrorType; } + type ApiError = Partial<{ + code: number, + type: ErrorType, + description: string, + originalError: any, + stack: string, + handled: boolean, + input: string, + message: ApiError + }>; + declare const electronHelpers: { openExternal(url): void; } | undefined; diff --git a/src/helpers/blob/blobConstruct.ts b/src/helpers/blob/blobConstruct.ts index 64d5defd..068fe614 100644 --- a/src/helpers/blob/blobConstruct.ts +++ b/src/helpers/blob/blobConstruct.ts @@ -16,17 +16,7 @@ export default function blobConstruct(blobParts: blobParts = [blobParts]; } - let blob; const safeMimeType = blobSafeMimeType(mimeType); - try { - blob = new Blob(blobParts, {type: safeMimeType}); - } catch(e) { - // @ts-ignore - let bb = new BlobBuilder; - blobParts.forEach((blobPart: any) => { - bb.append(blobPart); - }); - blob = bb.getBlob(safeMimeType); - } + const blob = new Blob(blobParts, {type: safeMimeType}); return blob; } diff --git a/src/helpers/fileName.ts b/src/helpers/fileName.ts index e0397081..a595077a 100644 --- a/src/helpers/fileName.ts +++ b/src/helpers/fileName.ts @@ -10,7 +10,8 @@ import type { DownloadOptions } from "../lib/mtproto/apiFileManager"; const FILENAME_JOINER = '_'; export function getFileNameByLocation(location: InputFileLocation | InputWebFileLocation, options?: Partial<{ - fileName: string + fileName: string, + downloadId: string }>) { const fileName = '';//(options?.fileName || '').split('.'); const ext = fileName[fileName.length - 1] || ''; @@ -57,7 +58,7 @@ export function getFileNameByLocation(location: InputFileLocation | InputWebFile } } - return str + (ext ? '.' + ext : ext); + return str + (options.downloadId || '') + (ext ? '.' + ext : ext); } export type FileURLType = 'photo' | 'thumb' | 'document' | 'stream' | 'download'; diff --git a/src/helpers/makeError.ts b/src/helpers/makeError.ts new file mode 100644 index 00000000..34c99246 --- /dev/null +++ b/src/helpers/makeError.ts @@ -0,0 +1,7 @@ +export default function makeError(type: Error['type']) { + const error: ApiError = { + type + }; + + return error; +} diff --git a/src/helpers/string/fileNameRFC.ts b/src/helpers/string/fileNameRFC.ts new file mode 100644 index 00000000..13a09c59 --- /dev/null +++ b/src/helpers/string/fileNameRFC.ts @@ -0,0 +1,4 @@ +export default function fileNameRFC(fileName: string) { + // Make filename RFC5987 compatible + return encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, '%2A'); +} diff --git a/src/helpers/toggleStorages.ts b/src/helpers/toggleStorages.ts index b407a57c..a4d3fded 100644 --- a/src/helpers/toggleStorages.ts +++ b/src/helpers/toggleStorages.ts @@ -4,7 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import CacheStorageController from "../lib/cacheStorage"; +import CacheStorageController from "../lib/files/cacheStorage"; import AppStorage from "../lib/storage"; import sessionStorage from "../lib/sessionStorage"; import noop from "./noop"; diff --git a/src/lib/appManagers/appDownloadManager.ts b/src/lib/appManagers/appDownloadManager.ts index 9d64a073..e51a884f 100644 --- a/src/lib/appManagers/appDownloadManager.ts +++ b/src/lib/appManagers/appDownloadManager.ts @@ -4,21 +4,20 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import type { ApiFileManager, DownloadMediaOptions, DownloadOptions } from "../mtproto/apiFileManager"; +import type { DownloadMediaOptions, DownloadOptions } from "../mtproto/apiFileManager"; +import type { AppMessagesManager } from "./appMessagesManager"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; -import { Document, InputFile, Photo, PhotoSize, WebDocument } from "../../layer"; -import { getFileNameByLocation } from "../../helpers/fileName"; +import { InputFile, Photo, PhotoSize } from "../../layer"; import getFileNameForUpload from "../../helpers/getFileNameForUpload"; import { AppManagers } from "./managers"; import rootScope from "../rootScope"; import { MOUNT_CLASS_TO } from "../../config/debug"; -import getDocumentDownloadOptions from "./utils/docs/getDocumentDownloadOptions"; -import getPhotoDownloadOptions from "./utils/photos/getPhotoDownloadOptions"; -import createDownloadAnchor from "../../helpers/dom/createDownloadAnchor"; import noop from "../../helpers/noop"; import getDownloadMediaDetails from "./utils/download/getDownloadMediaDetails"; import getDownloadFileNameFromOptions from "./utils/download/getDownloadFileNameFromOptions"; -import { AppMessagesManager } from "./appMessagesManager"; +import indexOfAndSplice from "../../helpers/array/indexOfAndSplice"; +import { MAX_FILE_SAVE_SIZE } from "../mtproto/mtproto_config"; +import createDownloadAnchor from "../../helpers/dom/createDownloadAnchor"; export type ResponseMethodBlob = 'blob'; export type ResponseMethodJson = 'json'; @@ -35,10 +34,10 @@ export type Download = DownloadBlob | DownloadUrl/* | DownloadJson */; export type Progress = {done: number, fileName: string, total: number, offset: number}; export type ProgressCallback = (details: Progress) => void; -type DownloadType = 'url' | 'blob' | 'void'; +type DownloadType = 'url' | 'blob' | 'void' | 'disc'; export class AppDownloadManager { - private downloads: {[fileName: string]: {main: Download, url?: Download, blob?: Download, void?: Download}} = {}; + private downloads: {[fileName: string]: {main: Download} & {[type in DownloadType]?: Download}} = {}; private progress: {[fileName: string]: Progress} = {}; // private progressCallbacks: {[fileName: string]: Array} = {}; private managers: AppManagers; @@ -84,7 +83,7 @@ export class AppDownloadManager { }; deferred.catch(() => { - this.clearDownload(fileName); + this.clearDownload(fileName, type); }).finally(() => { delete this.progress[fileName]; // delete this.progressCallbacks[fileName]; @@ -101,6 +100,13 @@ export class AppDownloadManager { }); } + const haveToClear = type === 'disc'; + if(haveToClear) { + deferred.catch(noop).finally(() => { + this.clearDownload(fileName, type); + }); + } + return download[type] = deferred as any; } @@ -115,8 +121,18 @@ export class AppDownloadManager { return deferred as CancellablePromise>; } - private clearDownload(fileName: string) { - delete this.downloads[fileName]; + private clearDownload(fileName: string, type?: DownloadType) { + const downloads = this.downloads[fileName]; + if(!downloads) { + return; + } + + delete downloads[type]; + + const length = Object.keys(downloads).length; + if(!length || (downloads.main && length === 1)) { + delete this.downloads[fileName]; + } } public getUpload(fileName: string): ReturnType['promise'] { @@ -161,7 +177,15 @@ export class AppDownloadManager { const {downloadOptions, fileName} = getDownloadMediaDetails(options); return this.d(fileName, () => { - const cb = type === 'url' ? this.managers.apiFileManager.downloadMediaURL : (type === 'void' ? this.managers.apiFileManager.downloadMediaVoid : this.managers.apiFileManager.downloadMedia); + let cb: any; + if(type === 'url') { + cb = this.managers.apiFileManager.downloadMediaURL; + } else if(type === 'void' || type === 'disc') { + cb = this.managers.apiFileManager.downloadMediaVoid; + } else if(type === 'blob') { + cb = this.managers.apiFileManager.downloadMedia; + } + return cb(options); }, type) as any; } @@ -207,20 +231,58 @@ export class AppDownloadManager { if(!isDocument && !options.thumb) { options.thumb = (media as Photo.photo).sizes.slice().pop() as PhotoSize.photoSize; } - - const promise = this.downloadMedia(options); - promise.then((blob) => { - const url = URL.createObjectURL(blob); - const downloadOptions = isDocument ? - getDocumentDownloadOptions(media) : - getPhotoDownloadOptions(media as any, options.thumb); - const fileName = (options.media as Document.document).file_name || getFileNameByLocation(downloadOptions.location); - createDownloadAnchor(url, fileName, () => { - URL.revokeObjectURL(url); - }); - }, noop); - return promise; + const {downloadOptions, fileName} = getDownloadMediaDetails(options); + if(downloadOptions.size && downloadOptions.size > MAX_FILE_SAVE_SIZE) { + const id = '' + (Math.random() * 0x7FFFFFFF | 0); + const url = `/download/${id}`; + options.downloadId = id; + + const promise = this.downloadMedia(options, 'disc'); + + let iframe: HTMLIFrameElement; + const onProgress = () => { + iframe = document.createElement('iframe'); + iframe.hidden = true; + // iframe.src = sw.scope + fileName; + iframe.src = url; + document.body.append(iframe); + + indexOfAndSplice(promise.listeners, onProgress); + }; + + promise.addNotifyListener(onProgress); + promise.catch(noop).finally(() => { + setTimeout(() => { + iframe?.remove(); + }, 1000); + }); + + return promise; + } else { + const promise = this.downloadMedia(options, 'blob'); + promise.then((blob) => { + const url = URL.createObjectURL(blob); + createDownloadAnchor(url, downloadOptions.fileName || fileName, () => { + URL.revokeObjectURL(url); + }); + }); + return promise; + } + + // const promise = this.downloadMedia(options); + // promise.then((blob) => { + // const url = URL.createObjectURL(blob); + // const downloadOptions = isDocument ? + // getDocumentDownloadOptions(media) : + // getPhotoDownloadOptions(media as any, options.thumb); + // const fileName = (options.media as Document.document).file_name || getFileNameByLocation(downloadOptions.location); + // createDownloadAnchor(url, fileName, () => { + // URL.revokeObjectURL(url); + // }); + // }, noop); + + // return promise; } } diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 87e6d6f3..4af4e932 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -76,7 +76,7 @@ import generateMessageId from './utils/messageId/generateMessageId'; import getUserStatusString from '../../components/wrappers/getUserStatusString'; import getChatMembersString from '../../components/wrappers/getChatMembersString'; import { STATE_INIT } from '../../config/state'; -import CacheStorageController from '../cacheStorage'; +import CacheStorageController from '../files/cacheStorage'; import themeController from '../../helpers/themeController'; import overlayCounter from '../../helpers/overlayCounter'; import appDialogsManager from './appDialogsManager'; diff --git a/src/lib/appManagers/appMessagesManager.ts b/src/lib/appManagers/appMessagesManager.ts index 37cf77e1..b1ae2c97 100644 --- a/src/lib/appManagers/appMessagesManager.ts +++ b/src/lib/appManagers/appMessagesManager.ts @@ -59,6 +59,7 @@ import appTabsManager from "./appTabsManager"; import MTProtoMessagePort from "../mtproto/mtprotoMessagePort"; import getAlbumText from "./utils/messages/getAlbumText"; import pause from "../../helpers/schedulers/pause"; +import makeError from "../../helpers/makeError"; //console.trace('include'); // TODO: если удалить диалог находясь в папке, то он не удалится из папки и будет виден в настройках @@ -3077,7 +3078,7 @@ export class AppMessagesManager extends AppManager { (this.rootScope.premium ? appConfig.dialogs_folder_pinned_limit_premium : appConfig.dialogs_folder_pinned_limit_default) : (this.rootScope.premium ? appConfig.dialogs_pinned_limit_premium : appConfig.dialogs_pinned_limit_default); if(this.dialogsStorage.getPinnedOrders(filterId).length >= max) { - return Promise.reject({type: 'PINNED_DIALOGS_TOO_MUCH'}); + return Promise.reject(makeError('PINNED_DIALOGS_TOO_MUCH')); } } diff --git a/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts b/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts index 180c9a56..3394e947 100644 --- a/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts +++ b/src/lib/appManagers/utils/download/getDownloadMediaDetails.ts @@ -19,6 +19,8 @@ export default function getDownloadMediaDetails(options: DownloadMediaOptions) { else if(media._ === 'photo') downloadOptions = getPhotoDownloadOptions(media, thumb, queueId, onlyCache); else if(isWebDocument(media)) downloadOptions = getWebDocumentDownloadOptions(media); + downloadOptions.downloadId = options.downloadId; + const fileName = getDownloadFileNameFromOptions(downloadOptions); return {fileName, downloadOptions}; } diff --git a/src/lib/fileManager.ts b/src/lib/fileManager.ts deleted file mode 100644 index 990f7d82..00000000 --- a/src/lib/fileManager.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * https://github.com/morethanwords/tweb - * Copyright (C) 2019-2021 Eduard Kuzmenko - * https://github.com/morethanwords/tweb/blob/master/LICENSE - * - * Originally from: - * https://github.com/zhukov/webogram - * Copyright (C) 2014 Igor Zhukov - * https://github.com/zhukov/webogram/blob/master/LICENSE - */ - -import blobConstruct from "../helpers/blob/blobConstruct"; - -export class FileManager { - private blobSupported = true; - - constructor() { - try { - blobConstruct([], ''); - } catch(e) { - this.blobSupported = false; - } - } - - public isAvailable() { - return this.blobSupported; - } - - public getFakeFileWriter(mimeType: string, size: number, saveFileCallback?: (blob: Blob) => Promise) { - let bytes: Uint8Array = new Uint8Array(size); - const fakeFileWriter = { - write: async(part: Uint8Array, offset: number) => { - if(!this.blobSupported) { - throw false; - } - - // sometimes file size can be bigger than the prov - const endOffset = offset + part.byteLength; - if(endOffset > bytes.byteLength) { - const newBytes = new Uint8Array(endOffset); - newBytes.set(bytes, 0); - bytes = newBytes; - } - - bytes.set(part, offset); - }, - truncate: () => { - bytes = new Uint8Array(); - }, - trim: (size: number) => { - bytes = bytes.slice(0, size); - }, - finalize: (saveToStorage = true) => { - const blob = blobConstruct(bytes, mimeType); - - if(saveToStorage && saveFileCallback) { - saveFileCallback(blob); - } - - return blob; - }, - getParts: () => bytes, - replaceParts: (parts: typeof bytes) => { - bytes = parts; - } - }; - - return fakeFileWriter; - } -} - -export default new FileManager(); diff --git a/src/lib/cacheStorage.ts b/src/lib/files/cacheStorage.ts similarity index 81% rename from src/lib/cacheStorage.ts rename to src/lib/files/cacheStorage.ts index c9180bd3..1a4dd3fa 100644 --- a/src/lib/cacheStorage.ts +++ b/src/lib/files/cacheStorage.ts @@ -4,15 +4,16 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import Modes from '../config/modes'; -import blobConstruct from '../helpers/blob/blobConstruct'; -import FileManager from './fileManager'; -//import { MOUNT_CLASS_TO } from './mtproto/mtproto_config'; -//import { logger } from './polyfill'; +import Modes from '../../config/modes'; +import blobConstruct from '../../helpers/blob/blobConstruct'; +import MemoryWriter from './memoryWriter'; +import FileManager from './memoryWriter'; +import FileStorage from './fileStorage'; +import makeError from '../../helpers/makeError'; export type CacheStorageDbName = 'cachedFiles' | 'cachedStreamChunks' | 'cachedAssets'; -export default class CacheStorageController { +export default class CacheStorageController implements FileStorage { private static STORAGES: CacheStorageController[] = []; private openDbPromise: Promise; @@ -64,7 +65,7 @@ export default class CacheStorageController { return this.get(fileName).then((response) => { if(!response) { //console.warn('getFile:', response, fileName); - throw 'NO_ENTRY_FOUND'; + throw makeError('NO_ENTRY_FOUND'); } const promise = response[method](); @@ -92,7 +93,7 @@ export default class CacheStorageController { public timeoutOperation(callback: (cache: Cache) => Promise) { if(!this.useStorage) { - return Promise.reject('STORAGE_OFFLINE'); + return Promise.reject(makeError('STORAGE_OFFLINE')); } return new Promise(async(resolve, reject) => { @@ -123,12 +124,12 @@ export default class CacheStorageController { }); } - public getFileWriter(fileName: string, fileSize: number, mimeType: string) { - const fakeWriter = FileManager.getFakeFileWriter(mimeType, fileSize, (blob) => { + public getWriter(fileName: string, fileSize: number, mimeType: string) { + const writer = new MemoryWriter(mimeType, fileSize, (blob) => { return this.saveFile(fileName, blob).catch(() => blob); }); - return Promise.resolve(fakeWriter); + return Promise.resolve(writer); } public static toggleStorage(enabled: boolean, clearWrite: boolean) { diff --git a/src/lib/files/fileStorage.ts b/src/lib/files/fileStorage.ts new file mode 100644 index 00000000..0ce44213 --- /dev/null +++ b/src/lib/files/fileStorage.ts @@ -0,0 +1,13 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import StreamWriter from "./streamWriter"; + +export default abstract class FileStorage { + public abstract getFile(fileName: string): Promise; + + public abstract getWriter(fileName: string, fileSize: number, mimeType: string): Promise; +} diff --git a/src/lib/idb.ts b/src/lib/files/idb.ts similarity index 94% rename from src/lib/idb.ts rename to src/lib/files/idb.ts index 6d1bf2f0..094da68e 100644 --- a/src/lib/idb.ts +++ b/src/lib/files/idb.ts @@ -9,10 +9,11 @@ * https://github.com/zhukov/webogram/blob/master/LICENSE */ -import { Database } from '../config/databases'; -import Modes from '../config/modes'; -import safeAssign from '../helpers/object/safeAssign'; -import { logger } from './logger'; +import { Database } from '../../config/databases'; +import Modes from '../../config/modes'; +import makeError from '../../helpers/makeError'; +import safeAssign from '../../helpers/object/safeAssign'; +import { logger } from '../logger'; /** * https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore/createIndex @@ -111,7 +112,7 @@ export class IDB { let finished = false; setTimeout(() => { if(!finished) { - request.onerror({type: 'IDB_CREATE_TIMEOUT'} as Event); + request.onerror(makeError('IDB_CREATE_TIMEOUT') as Event); } }, 3000); diff --git a/src/lib/files/memoryWriter.ts b/src/lib/files/memoryWriter.ts new file mode 100644 index 00000000..deee8abd --- /dev/null +++ b/src/lib/files/memoryWriter.ts @@ -0,0 +1,58 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import blobConstruct from "../../helpers/blob/blobConstruct"; +import StreamWriter from './streamWriter'; + +export default class MemoryWriter implements StreamWriter { + private bytes: Uint8Array; + + constructor( + private mimeType: string, + private size: number, + private saveFileCallback?: (blob: Blob) => Promise + ) { + this.bytes = new Uint8Array(size); + } + + public async write(part: Uint8Array, offset: number) { + // sometimes file size can be bigger than the prov + const endOffset = offset + part.byteLength; + if(endOffset > this.bytes.byteLength) { + const newBytes = new Uint8Array(endOffset); + newBytes.set(this.bytes, 0); + this.bytes = newBytes; + } + + this.bytes.set(part, offset); + }; + + public truncate() { + this.bytes = new Uint8Array(); + } + + public trim(size: number) { + this.bytes = this.bytes.slice(0, size); + } + + public finalize(saveToStorage = true) { + const blob = blobConstruct(this.bytes, this.mimeType); + + if(saveToStorage && this.saveFileCallback) { + this.saveFileCallback(blob); + } + + return blob; + } + + public getParts() { + return this.bytes; + } + + public replaceParts(parts: Uint8Array) { + this.bytes = parts; + } +} diff --git a/src/lib/files/streamWriter.ts b/src/lib/files/streamWriter.ts new file mode 100644 index 00000000..67825d64 --- /dev/null +++ b/src/lib/files/streamWriter.ts @@ -0,0 +1,14 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +export default abstract class StreamWriter { + public abstract write(part: Uint8Array, offset?: number): Promise; + public abstract truncate?(): void; + public abstract trim?(size: number): void; + public abstract finalize(saveToStorage?: boolean): Promise | Blob; + public abstract getParts?(): Uint8Array; + public abstract replaceParts?(parts: Uint8Array): void; +} diff --git a/src/lib/mtproto/apiFileManager.ts b/src/lib/mtproto/apiFileManager.ts index e79582da..d2a51373 100644 --- a/src/lib/mtproto/apiFileManager.ts +++ b/src/lib/mtproto/apiFileManager.ts @@ -12,12 +12,10 @@ import type { ReferenceBytes } from "./referenceDatabase"; import Modes from "../../config/modes"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; -import { getFileNameByLocation } from "../../helpers/fileName"; import { randomLong } from "../../helpers/random"; import { Document, InputFile, InputFileLocation, InputWebFileLocation, Photo, PhotoSize, UploadFile, UploadWebFile, WebDocument } from "../../layer"; import { DcId } from "../../types"; -import CacheStorageController from "../cacheStorage"; -import fileManager from "../fileManager"; +import CacheStorageController from "../files/cacheStorage"; import { logger, LogTypes } from "../logger"; import assumeType from "../../helpers/assumeType"; import ctx from "../../environment/ctx"; @@ -33,12 +31,19 @@ import getFileNameForUpload from "../../helpers/getFileNameForUpload"; import type { Progress } from "../appManagers/appDownloadManager"; import getDownloadMediaDetails from "../appManagers/utils/download/getDownloadMediaDetails"; import networkStats from "./networkStats"; -import pause from "../../helpers/schedulers/pause"; +import getDownloadFileNameFromOptions from "../appManagers/utils/download/getDownloadFileNameFromOptions"; +import { getServiceMessagePort } from "./mtproto.worker"; +import StreamWriter from "../files/streamWriter"; +import FileStorage from "../files/fileStorage"; +import fileNameRFC from "../../helpers/string/fileNameRFC"; +import { MAX_FILE_SAVE_SIZE } from "./mtproto_config"; +import throttle from "../../helpers/schedulers/throttle"; +import makeError from "../../helpers/makeError"; type Delayed = { offset: number, - writeFilePromise: CancellablePromise, - writeFileDeferred: CancellablePromise + writePromise: CancellablePromise, + writeDeferred: CancellablePromise }; export type DownloadOptions = { @@ -50,6 +55,7 @@ export type DownloadOptions = { limitPart?: number, queueId?: number, onlyCache?: boolean, + downloadId?: string // getFileMethod: Parameters[1] }; @@ -57,7 +63,8 @@ export type DownloadMediaOptions = { media: Photo.photo | Document.document | WebDocument, thumb?: PhotoSize, queueId?: number, - onlyCache?: boolean + onlyCache?: boolean, + downloadId?: string }; type DownloadPromise = CancellablePromise; @@ -75,11 +82,18 @@ export type MyUploadFile = UploadFile.uploadFile | UploadWebFile.uploadWebFile; // originalPayload: ReferenceBytes // }; -const MAX_FILE_SAVE_SIZE = 20 * 1024 * 1024; +const MAX_FILE_PART_SIZE = 1 * 1024 * 1024; const REGULAR_DOWNLOAD_DELTA = 36; const PREMIUM_DOWNLOAD_DELTA = 72; +const IGNORE_ERRORS: Set = new Set([ + 'DOWNLOAD_CANCELED', + 'UPLOAD_CANCELED', + 'UNKNOWN', + 'NO_NEW_CONTEXT' +]); + export class ApiFileManager extends AppManager { private cacheStorage = new CacheStorageController('cachedFiles'); @@ -108,7 +122,7 @@ export class ApiFileManager extends AppManager { public refreshReferencePromises: { [referenceHex: string]: { deferred: CancellablePromise, - timeout: number + timeout?: number } } = {}; @@ -117,6 +131,9 @@ export class ApiFileManager extends AppManager { private queueId = 0; private debug = Modes.debug; + private maxUploadParts = 4000; + private maxDownloadParts = 8000; + protected after() { setInterval(() => { // clear old promises for(const hex in this.refreshReferencePromises) { @@ -126,6 +143,11 @@ export class ApiFileManager extends AppManager { } } }, 1800e3); + + this.rootScope.addEventListener('app_config', (appConfig) => { + this.maxUploadParts = this.rootScope.premium ? appConfig.upload_max_fileparts_premium : appConfig.upload_max_fileparts_default; + this.maxDownloadParts = appConfig.upload_max_fileparts_premium; + }); } private downloadRequest(dcId: 'upload', id: number, cb: () => Promise, activeDelta: number, queueId?: number): Promise; @@ -154,12 +176,12 @@ export class ApiFileManager extends AppManager { const downloadLimit = dcId === 'upload' ? 24 : (this.rootScope.premium ? PREMIUM_DOWNLOAD_DELTA : REGULAR_DOWNLOAD_DELTA); //const downloadLimit = Infinity; - if(this.downloadActives[dcId] >= downloadLimit || !downloadPull || !downloadPull.length) { + if(this.downloadActives[dcId] >= downloadLimit || !downloadPull?.length) { return false; } //const data = downloadPull.shift(); - const data = findAndSplice(downloadPull, d => d.queueId === 0) || findAndSplice(downloadPull, d => d.queueId === this.queueId) || downloadPull.shift(); + const data = findAndSplice(downloadPull, (d) => d.queueId === 0) || findAndSplice(downloadPull, (d) => d.queueId === this.queueId) || downloadPull.shift(); const activeDelta = data.activeDelta || 1; this.downloadActives[dcId] += activeDelta; @@ -174,9 +196,8 @@ export class ApiFileManager extends AppManager { this.downloadCheck(dcId); networkPromise.resolve(); - }, (error: Error) => { - // @ts-ignore - if(!error || !error.type || (error.type !== 'DOWNLOAD_CANCELED' && error.type !== 'UPLOAD_CANCELED')) { + }, (error: ApiError) => { + if(!error?.type || !IGNORE_ERRORS.has(error.type)) { this.log.error('downloadCheck error:', error); } @@ -234,6 +255,9 @@ export class ApiFileManager extends AppManager { const invoke = async(): Promise => { checkCancel && checkCancel(); // do not remove async, because checkCancel will throw an error + // * IMPORTANT: reference can be changed in previous request + const reference = (location as InputFileLocation.inputDocumentFileLocation).file_reference?.slice(); + const promise = /* pause(1000).then(() => */this.apiManager.invokeApi('upload.getFile', { location, offset, @@ -243,9 +267,9 @@ export class ApiFileManager extends AppManager { fileDownload: true }) as Promise/* ) */; - return promise.catch((err) => { + return promise.catch((err: ApiError) => { if(err.type === 'FILE_REFERENCE_EXPIRED') { - return this.refreshReference(location).then(invoke); + return this.refreshReference(location as InputFileLocation.inputDocumentFileLocation, reference).then(invoke); } throw err; @@ -258,7 +282,7 @@ export class ApiFileManager extends AppManager { location.checkedReference = true; const hex = bytesToHex(reference); if(this.refreshReferencePromises[hex]) { - return this.refreshReference(location).then(invoke); + return this.refreshReference(location, reference).then(invoke); } } @@ -274,14 +298,16 @@ export class ApiFileManager extends AppManager { return bytes / 1024 / 128; } - private getLimitPart(size: number): number { + private getLimitPart(size: number, isUpload: boolean): number { if(!size) { // * sometimes size can be 0 (e.g. avatars, webDocuments) return 512 * 1024; } let bytes = 128 * 1024; - while((size / bytes) > 2000) { + const maxParts = isUpload ? this.maxUploadParts : this.maxDownloadParts; + // usually it will stick to 512Kb size if the file is too big + while((size / bytes) > maxParts && bytes < MAX_FILE_PART_SIZE) { bytes *= 2; } /* if(size < 1e6 || !size) bytes = 512; @@ -323,38 +349,41 @@ export class ApiFileManager extends AppManager { return instance.invoke('convertOpus', {fileName, bytes}); }; - private refreshReference(inputFileLocation: InputFileLocation) { - const reference = (inputFileLocation as InputFileLocation.inputDocumentFileLocation).file_reference; - const hex = bytesToHex(reference); - + private refreshReference( + inputFileLocation: InputFileLocation.inputDocumentFileLocation, + reference: typeof inputFileLocation['file_reference'], + hex = bytesToHex(reference) + ) { let r = this.refreshReferencePromises[hex]; if(!r) { const deferred = deferredPromise(); r = this.refreshReferencePromises[hex] = { deferred, - timeout: ctx.setTimeout(() => { - this.log.error('Didn\'t refresh the reference:', inputFileLocation); - deferred.reject('REFERENCE_IS_NOT_REFRESHED'); - }, 60000) + + // ! I don't remember what it was for... + // timeout: ctx.setTimeout(() => { + // this.log.error('Didn\'t refresh the reference:', inputFileLocation); + // deferred.reject(makeError('REFERENCE_IS_NOT_REFRESHED')); + // }, 60000) }; - deferred.catch(noop).finally(() => { - clearTimeout(r.timeout); - }); + // deferred.catch(noop).finally(() => { + // clearTimeout(r.timeout); + // }); + + this.referenceDatabase.refreshReference(reference).then((reference) => { + if(hex === bytesToHex(reference)) { + deferred.reject(makeError('REFERENCE_IS_NOT_REFRESHED')); + } - this.referenceDatabase.refreshReference(reference).then(deferred.resolve, deferred.reject); - // const task = {type: 'refreshReference', payload: reference}; - // notifySomeone(task); + deferred.resolve(reference); + }, deferred.reject); } // have to replace file_reference in any way, because location can be different everytime if it's stream return r.deferred.then((reference) => { - if(hex === bytesToHex(reference)) { - throw 'REFERENCE_IS_NOT_REFRESHED'; - } - - (inputFileLocation as InputFileLocation.inputDocumentFileLocation).file_reference = reference; + inputFileLocation.file_reference = reference; }); } @@ -371,16 +400,14 @@ export class ApiFileManager extends AppManager { } public download(options: DownloadOptions): DownloadPromise { - if(!fileManager.isAvailable()) { - return Promise.reject({type: 'BROWSER_BLOB_NOT_SUPPORTED'}); - } - const size = options.size ?? 0; - const {dcId, location} = options; + const {dcId, location, downloadId} = options; let process: ApiFileManager['uncompressTGS'] | ApiFileManager['convertWebp']; - if(options.mimeType === 'application/x-tgwallpattern') { + if(downloadId) { + + } else if(options.mimeType === 'application/x-tgwallpattern') { process = this.uncompressTGV; options.mimeType = 'image/svg+xml'; } else if(options.mimeType === 'image/webp' && !getEnvironment().IS_WEBP_SUPPORTED) { @@ -394,9 +421,9 @@ export class ApiFileManager extends AppManager { options.mimeType = 'audio/wav'; } - const fileName = getFileNameByLocation(location, {fileName: options.fileName}); - const cachedPromise = this.downloadPromises[fileName]; - const fileStorage = this.getFileStorage(); + const fileName = getDownloadFileNameFromOptions(options); + const cachedPromise = options.downloadId ? undefined : this.downloadPromises[fileName]; + let fileStorage: FileStorage = this.getFileStorage(); this.debug && this.log('downloadFile', fileName, size, location, options.mimeType); @@ -429,28 +456,77 @@ export class ApiFileManager extends AppManager { const deferred: DownloadPromise = deferredPromise(); const mimeType = options.mimeType || 'image/jpeg'; - let error: Error; + let error: ApiError; let resolved = false; - let cacheFileWriter: ReturnType; - let errorHandler = (_error: Error) => { + let cacheWriter: StreamWriter; + let errorHandler = (_error: typeof error) => { error = _error; delete this.downloadPromises[fileName]; deferred.reject(error); errorHandler = () => {}; - if(cacheFileWriter && (!error || error.type !== 'DOWNLOAD_CANCELED')) { - cacheFileWriter.truncate(); + if(cacheWriter && (!error || error.type !== 'DOWNLOAD_CANCELED')) { + cacheWriter.truncate?.(); } }; const id = this.tempId++; + if(downloadId) { + const headers = { + 'Content-Type': 'application/octet-stream; charset=utf-8', + 'Content-Disposition': "attachment; filename*=UTF-8''" + fileNameRFC(options.fileName), + // 'Content-Disposition': `attachment; filename="${options.fileName}"`, + // 'Content-Type': 'application/octet-stream; charset=utf-8', + ...(size ? {'Content-Length': size} : {}) + }; + + const serviceMessagePort = getServiceMessagePort(); + const promise = serviceMessagePort.invoke('download', { + fileName, + headers, + id: downloadId + }); + + promise.catch(errorHandler); + deferred.catch(() => { + getServiceMessagePort().invoke('downloadCancel', downloadId); + }); + + class f implements StreamWriter { + constructor() { + + } + + public async write(part: Uint8Array, offset?: number) { + return serviceMessagePort.invoke('downloadChunk', { + id: downloadId, + chunk: part + }); + } + + public finalize(saveToStorage?: boolean): Promise { + return serviceMessagePort.invoke('downloadFinalize', downloadId).then(() => null); + } + } + + class d implements FileStorage { + public getFile(fileName: string): Promise { + return Promise.reject(); + } + + public getWriter(fileName: string, fileSize: number, mimeType: string): Promise { + return Promise.resolve(new f()); + } + } + + fileStorage = new d(); + } + fileStorage.getFile(fileName).then(async(blob: Blob) => { - //this.log('maybe cached', fileName); //throw ''; if(blob.size < size) { - //this.log('downloadFile need to deleteFile 2, wrong size:', blob.size, size); if(!options.onlyCache) { await this.delete(fileName); } @@ -459,118 +535,121 @@ export class ApiFileManager extends AppManager { } deferred.resolve(blob); - }).catch((err) => { + }).catch(async(err: ApiError) => { if(options.onlyCache) { errorHandler(err); return; } //this.log('not cached', fileName); - const limit = options.limitPart || this.getLimitPart(size); - const fileWriterPromise = fileStorage.getFileWriter(fileName, size || limit, mimeType); - - fileWriterPromise.then((fileWriter) => { - cacheFileWriter = fileWriter; - let offset: number; - let startOffset = 0; - let writeFilePromise: CancellablePromise = Promise.resolve(), - writeFileDeferred: CancellablePromise; - //const maxRequests = 13107200 / limit; // * 100 Mb speed - const maxRequests = Infinity; - - //console.error('maxRequests', maxRequests); - - const processDownloaded = async(bytes: Uint8Array) => { - if(process) { - //const perf = performance.now(); - const processed = await process(bytes, fileName); - //this.log('downloadFile process downloaded time', performance.now() - perf, mimeType, process); - return processed; - } - - return bytes; - }; - - const r = location._ === 'inputWebFileLocation' ? this.requestWebFilePart.bind(this) : this.requestFilePart.bind(this); - - const delayed: Delayed[] = []; - offset = startOffset; - do { - ////this.log('offset:', startOffset); - writeFileDeferred = deferredPromise(); - delayed.push({offset, writeFilePromise, writeFileDeferred}); - writeFilePromise = writeFileDeferred; - offset += limit; - } while(offset < size); - - let done = 0; - const superpuper = async() => { - //if(!delayed.length) return; - - const {offset, writeFilePromise, writeFileDeferred} = delayed.shift(); - try { - checkCancel(); + const limit = options.limitPart || this.getLimitPart(size, false); + const writerPromise = fileStorage.getWriter(fileName, size || limit, mimeType); - // @ts-ignore - const result = await r(dcId, location as any, offset, limit, id, options.queueId, checkCancel); + const writer = cacheWriter = await writerPromise; + + let offset: number; + let startOffset = 0; + let writePromise: CancellablePromise = Promise.resolve(), + writeDeferred: CancellablePromise; + //const maxRequests = 13107200 / limit; // * 100 Mb speed + const maxRequests = Infinity; + + const processDownloaded = async(bytes: Uint8Array) => { + if(process) { + //const perf = performance.now(); + const processed = await process(bytes, fileName); + //this.log('downloadFile process downloaded time', performance.now() - perf, mimeType, process); + return processed; + } - const bytes = result.bytes; + return bytes; + }; - if(delayed.length) { - superpuper(); - } + const r = location._ === 'inputWebFileLocation' ? this.requestWebFilePart.bind(this) : this.requestFilePart.bind(this); + + const delayed: Delayed[] = []; + offset = startOffset; + do { + writeDeferred = deferredPromise(); + delayed.push({offset, writePromise, writeDeferred}); + writePromise = writeDeferred; + offset += limit; + } while(offset < size); + + const progress: Progress = {done: 0, offset, total: size, fileName}; + const dispatchProgress = () => { + progress.done = done; + deferred.notify?.(progress); + }; - this.debug && this.log('downloadFile requestFilePart result:', fileName, result); - const isFinal = (offset + limit) >= size || !bytes.byteLength; - if(bytes.byteLength) { - //done += limit; - done += bytes.byteLength; + const throttledDispatchProgress = throttle(dispatchProgress, 50, true); - //if(!isFinal) { - ////this.log('deferred notify 2:', {done: offset + limit, total: size}, deferred); - const progress: Progress = {done, offset, total: size, fileName}; - deferred.notify(progress); - //} + let done = 0; + const superpuper = async() => { + //if(!delayed.length) return; - await writeFilePromise; - checkCancel(); + const {offset, writePromise, writeDeferred} = delayed.shift(); + try { + checkCancel(); - await fileWriter.write(bytes, offset); - } + // @ts-ignore + const result = await r(dcId, location as any, offset, limit, id, options.queueId, checkCancel); - if(isFinal && process) { - const bytes = fileWriter.getParts(); - const processedResult = await processDownloaded(bytes); - checkCancel(); + const bytes = result.bytes; - fileWriter.replaceParts(processedResult); - } + if(delayed.length) { + superpuper(); + } - writeFileDeferred.resolve(); + const byteLength = bytes.byteLength; + this.debug && this.log('downloadFile requestFilePart result:', fileName, result); + const isFinal = (offset + limit) >= size || !byteLength; + if(byteLength) { + done += byteLength; if(isFinal) { - resolved = true; + dispatchProgress(); + } else { + throttledDispatchProgress(); + } - const realSize = size || bytes.byteLength; - if(!size) { - fileWriter.trim(realSize); - } + await writePromise; + checkCancel(); - deferred.resolve(fileWriter.finalize(realSize < MAX_FILE_SAVE_SIZE)); - } - } catch(err) { - errorHandler(err as Error); + // const perf = performance.now(); + await writer.write(bytes, offset); + checkCancel(); + // downloadId && this.log('write time', performance.now() - perf); } - }; - for(let i = 0, length = Math.min(maxRequests, delayed.length); i < length; ++i) { - superpuper(); - } - }).catch((err) => { - if(!['STORAGE_OFFLINE'].includes(err)) { - this.log.error('saveFile error:', err); + if(isFinal && process) { + const bytes = writer.getParts(); + const processedResult = await processDownloaded(bytes); + checkCancel(); + + writer.replaceParts(processedResult); + } + + writeDeferred.resolve(); + + if(isFinal) { + resolved = true; + + const realSize = size || byteLength; + if(!size) { + writer.trim(realSize); + } + + deferred.resolve(await writer.finalize(realSize <= MAX_FILE_SAVE_SIZE)); + } + } catch(err) { + errorHandler(err as ApiError); } - }); + }; + + for(let i = 0, length = Math.min(maxRequests, delayed.length); i < length; ++i) { + superpuper(); + } }); const checkCancel = () => { @@ -581,8 +660,7 @@ export class ApiFileManager extends AppManager { deferred.cancel = () => { if(!error && !resolved) { - const error = new Error('Canceled'); - error.type = 'DOWNLOAD_CANCELED'; + const error = makeError('DOWNLOAD_CANCELED'); errorHandler(error); } }; @@ -610,8 +688,8 @@ export class ApiFileManager extends AppManager { // get original instance with correct file_reference instead of using copies const isDocument = media._ === 'document'; // const isWebDocument = media._ === 'webDocument'; - if(isDocument) media = this.appDocsManager.getDoc((media as Photo.photo).id); - else if(isPhoto) media = this.appPhotosManager.getPhoto((media as Document.document).id); + if(isDocument) media = this.appDocsManager.getDoc((media as Document.document).id); + else if(isPhoto) media = this.appPhotosManager.getPhoto((media as Photo.photo).id); const {fileName, downloadOptions} = getDownloadMediaDetails(options); @@ -653,7 +731,6 @@ export class ApiFileManager extends AppManager { } private delete(fileName: string) { - //this.log('will delete file:', fileName); delete this.downloadPromises[fileName]; return this.getFileStorage().delete(fileName); } @@ -665,16 +742,7 @@ export class ApiFileManager extends AppManager { let canceled = false, resolved = false, doneParts = 0, - partSize = 262144; // 256 Kb - - /* if(fileSize > (524288 * 3000)) { - partSize = 1024 * 1024; - activeDelta = 8; - } else */if(fileSize > 67108864) { - partSize = 524288; - } else if(fileSize < 102400) { - partSize = 32768; - } + partSize = this.getLimitPart(fileSize, true); fileName ||= getFileNameForUpload(file); @@ -694,12 +762,12 @@ export class ApiFileManager extends AppManager { }; const deferred = deferredPromise(); - if(totalParts > 4000) { - deferred.reject({type: 'FILE_TOO_BIG'}); + if(totalParts > this.maxUploadParts) { + deferred.reject(makeError('FILE_TOO_BIG')); return deferred; } - let errorHandler = (error: any) => { + let errorHandler = (error: ApiError) => { if(error?.type !== 'UPLOAD_CANCELED') { this.log.error('Up Error', error); } @@ -713,10 +781,6 @@ export class ApiFileManager extends AppManager { const id = this.tempId++; - /* setInterval(() => { - console.log(file); - }, 1e3); */ - const self = this; function* generator() { for(let offset = 0; offset < fileSize; offset += partSize) { @@ -726,7 +790,7 @@ export class ApiFileManager extends AppManager { return readBlobAsArrayBuffer(blob).then((buffer) => { if(canceled) { - throw {type: 'UPLOAD_CANCELED'}; + throw makeError('UPLOAD_CANCELED'); } self.debug && self.log('Upload file part, isBig:', isBigFile, part, buffer.byteLength, new Uint8Array(buffer).length, new Uint8Array(buffer).slice().length); @@ -800,10 +864,9 @@ export class ApiFileManager extends AppManager { } deferred.cancel = () => { - //this.log('cancel upload', canceled, resolved); if(!canceled && !resolved) { canceled = true; - errorHandler({type: 'UPLOAD_CANCELED'}); + errorHandler(makeError('UPLOAD_CANCELED')); } }; diff --git a/src/lib/mtproto/apiManager.ts b/src/lib/mtproto/apiManager.ts index cbbab997..17ada28c 100644 --- a/src/lib/mtproto/apiManager.ts +++ b/src/lib/mtproto/apiManager.ts @@ -24,7 +24,7 @@ import type { MethodDeclMap } from '../../layer'; import deferredPromise, { CancellablePromise } from '../../helpers/cancellablePromise'; import App from '../../config/app'; import { MOUNT_CLASS_TO } from '../../config/debug'; -import { IDB } from '../idb'; +import { IDB } from '../files/idb'; import CryptoWorker from "../crypto/cryptoMessagePort"; import ctx from '../../environment/ctx'; import noop from '../../helpers/noop'; @@ -38,17 +38,6 @@ import { getEnvironment } from '../../environment/utils'; import toggleStorages from '../../helpers/toggleStorages'; import type TcpObfuscated from './transports/tcpObfuscated'; -export type ApiError = Partial<{ - code: number, - type: string, - description: string, - originalError: any, - stack: string, - handled: boolean, - input: string, - message: ApiError -}>; - /* class RotatableArray { public array: Array = []; private lastIndex = -1; diff --git a/src/lib/mtproto/api_methods.ts b/src/lib/mtproto/api_methods.ts index ca60a95c..267da966 100644 --- a/src/lib/mtproto/api_methods.ts +++ b/src/lib/mtproto/api_methods.ts @@ -9,7 +9,6 @@ import { ignoreRestrictionReasons } from "../../helpers/restrictions"; import { MethodDeclMap, User } from "../../layer"; import { InvokeApiOptions } from "../../types"; import { AppManager } from "../appManagers/manager"; -import { ApiError } from "./apiManager"; import { MTAppConfig } from "./appConfig"; import { UserAuth } from "./mtproto_config"; import { MTMessage } from "./networker"; diff --git a/src/lib/mtproto/authorizer.ts b/src/lib/mtproto/authorizer.ts index 5cbfd895..75dcc12d 100644 --- a/src/lib/mtproto/authorizer.ts +++ b/src/lib/mtproto/authorizer.ts @@ -22,7 +22,6 @@ import CryptoWorker from "../crypto/cryptoMessagePort"; import { logger, LogTypes } from "../logger"; import DEBUG from "../../config/debug"; import { Awaited, DcId } from "../../types"; -import { ApiError } from "./apiManager"; import addPadding from "../../helpers/bytes/addPadding"; import bytesCmp from "../../helpers/bytes/bytesCmp"; import bytesFromHex from "../../helpers/bytes/bytesFromHex"; diff --git a/src/lib/mtproto/mtproto_config.ts b/src/lib/mtproto/mtproto_config.ts index dd3b4882..aba306d8 100644 --- a/src/lib/mtproto/mtproto_config.ts +++ b/src/lib/mtproto/mtproto_config.ts @@ -18,6 +18,7 @@ export const REPLIES_HIDDEN_CHANNEL_ID: ChatId = 777; export const SERVICE_PEER_ID: PeerId = 777000; export const MUTE_UNTIL = 0x7FFFFFFF; export const BOT_START_PARAM = ''; +export const MAX_FILE_SAVE_SIZE = 20 * 1024 * 1024; export const FOLDER_ID_ALL: REAL_FOLDER_ID = 0; export const FOLDER_ID_ARCHIVE: REAL_FOLDER_ID = 1; diff --git a/src/lib/mtproto/mtprotoworker.ts b/src/lib/mtproto/mtprotoworker.ts index 3342044f..afe15733 100644 --- a/src/lib/mtproto/mtprotoworker.ts +++ b/src/lib/mtproto/mtprotoworker.ts @@ -5,7 +5,7 @@ */ import type { Awaited } from '../../types'; -import type { CacheStorageDbName } from '../cacheStorage'; +import type { CacheStorageDbName } from '../files/cacheStorage'; import type { State } from '../../config/state'; import type { Message, MessagePeerReaction, PeerNotifySettings } from '../../layer'; import { CryptoMethods } from '../crypto/crypto_methods'; @@ -247,6 +247,8 @@ class ApiManagerProxy extends MTProtoMessagePort { const worker = navigator.serviceWorker; this._registerServiceWorker(); + // worker.startMessages(); + worker.addEventListener('controllerchange', () => { this.log.warn('controllerchange'); diff --git a/src/lib/mtproto/networker.ts b/src/lib/mtproto/networker.ts index a3d67e2e..aa8139d6 100644 --- a/src/lib/mtproto/networker.ts +++ b/src/lib/mtproto/networker.ts @@ -207,7 +207,7 @@ export default class MTPNetworker { const suffix = this.isFileUpload ? '-U' : this.isFileDownload ? '-D' : ''; this.name = 'NET-' + dcId + suffix; //this.log = logger(this.name, this.upload && this.dcId === 2 ? LogLevels.debug | LogLevels.warn | LogLevels.log | LogLevels.error : LogLevels.error); - this.log = logger(this.name, LogTypes.Log | LogTypes.Debug | LogTypes.Error | LogTypes.Warn, undefined); + this.log = logger(this.name, LogTypes.Log /* | LogTypes.Debug */ | LogTypes.Error | LogTypes.Warn); this.log('constructor'/* , this.authKey, this.authKeyID, this.serverSalt */); // Test resend after bad_server_salt @@ -1310,9 +1310,10 @@ export default class MTPNetworker { if(!(this.transport instanceof HTTP)) return promise; /// #endif - const baseError = { + const baseError: ApiError = { code: 406, type: 'NETWORK_BAD_RESPONSE', + // @ts-ignore transport: this.transport }; @@ -1596,13 +1597,13 @@ export default class MTPNetworker { } } - private processError(rawError: {error_message: string, error_code: number}) { + private processError(rawError: {error_message: string, error_code: number}): ApiError { const matches = (rawError.error_message || '').match(/^([A-Z_0-9]+\b)(: (.+))?/) || []; rawError.error_code = rawError.error_code; return { code: !rawError.error_code || rawError.error_code <= 0 ? 500 : rawError.error_code, - type: matches[1] || 'UNKNOWN', + type: matches[1] as any || 'UNKNOWN', description: matches[3] || ('CODE#' + rawError.error_code + ' ' + rawError.error_message), originalError: rawError }; diff --git a/src/lib/mtproto/referenceDatabase.ts b/src/lib/mtproto/referenceDatabase.ts index ecc9767f..7b49ac14 100644 --- a/src/lib/mtproto/referenceDatabase.ts +++ b/src/lib/mtproto/referenceDatabase.ts @@ -9,6 +9,7 @@ import { logger } from "../logger"; import bytesToHex from "../../helpers/bytes/bytesToHex"; import deepEqual from "../../helpers/object/deepEqual"; import { AppManager } from "../appManagers/manager"; +import makeError from "../../helpers/makeError"; export type ReferenceContext = ReferenceContext.referenceContextProfilePhoto | ReferenceContext.referenceContextMessage | ReferenceContext.referenceContextEmojiesSounds | ReferenceContext.referenceContextReactions | ReferenceContext.referenceContextUserFull; export namespace ReferenceContext { @@ -173,7 +174,7 @@ export class ReferenceDatabase extends AppManager { this.log.error('refreshReference: no new context, reference before:', hex, 'after:', newHex, context); - throw 'NO_NEW_CONTEXT'; + throw makeError('NO_NEW_CONTEXT'); }); } diff --git a/src/lib/serviceWorker/index.service.ts b/src/lib/serviceWorker/index.service.ts index c7e17423..60006f80 100644 --- a/src/lib/serviceWorker/index.service.ts +++ b/src/lib/serviceWorker/index.service.ts @@ -12,12 +12,14 @@ import { logger, LogTypes } from '../logger'; import { CACHE_ASSETS_NAME, requestCache } from './cache'; import onStreamFetch from './stream'; import { closeAllNotifications, onPing } from './push'; -import CacheStorageController from '../cacheStorage'; +import CacheStorageController from '../files/cacheStorage'; import { IS_SAFARI } from '../../environment/userAgent'; -import ServiceMessagePort from './serviceMessagePort'; +import ServiceMessagePort, { ServiceDownloadTaskPayload } from './serviceMessagePort'; import listenMessagePort from '../../helpers/listenMessagePort'; import { getWindowClients } from '../../helpers/context'; import { MessageSendPort } from '../mtproto/superMessagePort'; +import noop from '../../helpers/noop'; +import makeError from '../../helpers/makeError'; export const log = logger('SW', LogTypes.Error | LogTypes.Debug | LogTypes.Log | LogTypes.Warn); const ctx = self as any as ServiceWorkerGlobalScope; @@ -33,17 +35,35 @@ const sendMessagePort = (source: MessageSendPort) => { }; const sendMessagePortIfNeeded = (source: MessageSendPort) => { - if(!connectedWindows && !_mtprotoMessagePort) { + if(!connectedWindows.size && !_mtprotoMessagePort) { sendMessagePort(source); } }; -const onWindowConnected = (source: MessageSendPort) => { +const onWindowConnected = (source: WindowClient) => { + log('window connected', source.id); + + if(source.frameType === 'none') { + log.warn('maybe a bugged Safari starting window', source.id); + return; + } + sendMessagePortIfNeeded(source); + connectedWindows.add(source.id); +}; - ++connectedWindows; - log('window connected'); +type DownloadType = Uint8Array; +type DownloadItem = ServiceDownloadTaskPayload & { + transformStream: TransformStream, + readableStream: ReadableStream, + writableStream: WritableStream, + writer: WritableStreamDefaultWriter, + // controller: TransformStreamDefaultController, + // promise: CancellablePromise, + used?: boolean }; +const downloadMap: Map = new Map(); +const DOWNLOAD_ERROR = makeError('UNKNOWN'); export const serviceMessagePort = new ServiceMessagePort(); serviceMessagePort.addMultipleEventsListeners({ @@ -58,32 +78,117 @@ serviceMessagePort.addMultipleEventsListeners({ }, hello: (payload, source) => { - onWindowConnected(source); + onWindowConnected(source as any as WindowClient); + }, + + download: (payload) => { + const {id} = payload; + if(downloadMap.has(id)) { + return; + } + + // const writableStrategy = new ByteLengthQueuingStrategy({highWaterMark: 1024 * 1024}); + // let controller: TransformStreamDefaultController; + const transformStream = new TransformStream(/* { + start: (_controller) => controller = _controller, + }, { + highWaterMark: 1, + size: (chunk) => chunk.byteLength + }, new CountQueuingStrategy({highWaterMark: 4}) */); + + const {readable, writable} = transformStream; + const writer = writable.getWriter(); + // const promise = deferredPromise(); + // promise.catch(noop).finally(() => { + // downloadMap.delete(id); + // }); + + // writer.closed.then(promise.resolve, promise.reject); + + writer.closed.catch(noop).finally(() => { + log.error('closed writer'); + downloadMap.delete(id); + }); + + const item: DownloadItem = { + ...payload, + transformStream, + readableStream: readable, + writableStream: writable, + writer, + // promise, + // controller + }; + + downloadMap.set(id, item); + + return writer.closed.catch(() => {throw DOWNLOAD_ERROR;}); + // return promise; + }, + + downloadChunk: ({id, chunk}) => { + const item = downloadMap.get(id); + if(!item) { + return Promise.reject(); + } + + // return item.controller.enqueue(chunk); + return item.writer.write(chunk); + }, + + downloadFinalize: (id) => { + const item = downloadMap.get(id); + if(!item) { + return Promise.reject(); + } + + // item.promise.resolve(); + // return item.controller.terminate(); + return item.writer.close(); + }, + + downloadCancel: (id) => { + const item = downloadMap.get(id); + if(!item) { + return; + } + + // item.promise.reject(); + // return item.controller.error(); + return item.writer.abort(); } }); // * service worker can be killed, so won't get 'hello' event getWindowClients().then((windowClients) => { + log(`got ${windowClients.length} windows from the start`); windowClients.forEach((windowClient) => { onWindowConnected(windowClient); }); }); -let connectedWindows = 0; +let connectedWindows: Set = new Set(); listenMessagePort(serviceMessagePort, undefined, (source) => { - if(source === _mtprotoMessagePort) { + const isWindowClient = source instanceof WindowClient; + if(!isWindowClient || !connectedWindows.has(source.id)) { return; } log('window disconnected'); - connectedWindows = Math.max(0, connectedWindows - 1); - if(!connectedWindows) { + connectedWindows.delete(source.id); + if(!connectedWindows.size) { log.warn('no windows left'); if(_mtprotoMessagePort) { serviceMessagePort.detachPort(_mtprotoMessagePort); _mtprotoMessagePort = undefined; } + + if(downloadMap.size) { + for(const [id, item] of downloadMap) { + item.writer.abort().catch(noop); + } + } } }); /// #endif @@ -102,15 +207,28 @@ const onFetch = (event: FetchEvent): void => { try { const [, url, scope, params] = /http[:s]+\/\/.*?(\/(.*?)(?:$|\/(.*)$))/.exec(event.request.url) || []; - //log.debug('[fetch]:', event); + // log.debug('[fetch]:', event); switch(scope) { case 'stream': { onStreamFetch(event, params); break; } + + case 'download': { + const item = downloadMap.get(params); + if(!item || item.used) { + break; + } + + item.used = true; + const response = new Response(item.transformStream.readable, {headers: item.headers}); + event.respondWith(response); + break; + } } } catch(err) { + log.error('fetch error', err); event.respondWith(new Response('', { status: 500, statusText: 'Internal Server Error', @@ -133,13 +251,13 @@ ctx.addEventListener('activate', (event) => { event.waitUntil(ctx.clients.claim()); }); -ctx.onerror = (error) => { - log.error('error:', error); -}; +// ctx.onerror = (error) => { +// log.error('error:', error); +// }; -ctx.onunhandledrejection = (error) => { - log.error('onunhandledrejection:', error); -}; +// ctx.onunhandledrejection = (error) => { +// log.error('onunhandledrejection:', error); +// }; ctx.onoffline = ctx.ononline = onChangeState; diff --git a/src/lib/serviceWorker/push.ts b/src/lib/serviceWorker/push.ts index 2edb9939..32a837cf 100644 --- a/src/lib/serviceWorker/push.ts +++ b/src/lib/serviceWorker/push.ts @@ -13,7 +13,7 @@ import { Database } from "../../config/databases"; import DATABASE_STATE from "../../config/databases/state"; import { IS_FIREFOX } from "../../environment/userAgent"; import deepEqual from "../../helpers/object/deepEqual"; -import IDBStorage from "../idb"; +import IDBStorage from "../files/idb"; import { log, serviceMessagePort } from "./index.service"; import { ServicePushPingTaskPayload } from "./serviceMessagePort"; diff --git a/src/lib/serviceWorker/serviceMessagePort.ts b/src/lib/serviceWorker/serviceMessagePort.ts index 92f59720..f7c214d6 100644 --- a/src/lib/serviceWorker/serviceMessagePort.ts +++ b/src/lib/serviceWorker/serviceMessagePort.ts @@ -27,6 +27,12 @@ export type ServiceRequestFilePartTaskPayload = { limit: number }; +export type ServiceDownloadTaskPayload = { + fileName: string, + headers: any, + id: string, +}; + export type ServiceEvent = { port: (payload: void, source: MessageEventSource, event: MessageEvent) => void }; @@ -36,7 +42,13 @@ export default class ServiceMessagePort extends notificationsClear: () => void, toggleStorages: (payload: {enabled: boolean, clearWrite: boolean}) => void, pushPing: (payload: ServicePushPingTaskPayload, source: MessageEventSource, event: MessageEvent) => void, - hello: (payload: void, source: MessageEventSource, event: MessageEvent) => void + hello: (payload: void, source: MessageEventSource, event: MessageEvent) => void, + + // from mtproto worker + download: (payload: ServiceDownloadTaskPayload) => void, + downloadChunk: (payload: {id: ServiceDownloadTaskPayload['id'], chunk: Uint8Array}) => void + downloadFinalize: (payload: ServiceDownloadTaskPayload['id']) => void, + downloadCancel: (payload: ServiceDownloadTaskPayload['id']) => void }, { // to main thread pushClick: (payload: PushNotificationObject) => void, diff --git a/src/lib/serviceWorker/stream.ts b/src/lib/serviceWorker/stream.ts index 131cedf4..68ebf78a 100644 --- a/src/lib/serviceWorker/stream.ts +++ b/src/lib/serviceWorker/stream.ts @@ -8,7 +8,7 @@ import readBlobAsUint8Array from "../../helpers/blob/readBlobAsUint8Array"; import deferredPromise, { CancellablePromise } from "../../helpers/cancellablePromise"; import debounce from "../../helpers/schedulers/debounce"; import { InputFileLocation } from "../../layer"; -import CacheStorageController from "../cacheStorage"; +import CacheStorageController from "../files/cacheStorage"; import { DownloadOptions, MyUploadFile } from "../mtproto/apiFileManager"; import { getMtprotoMessagePort, log, serviceMessagePort } from "./index.service"; import { ServiceRequestFilePartTaskPayload } from "./serviceMessagePort"; @@ -140,8 +140,8 @@ class Stream { const key = this.getChunkKey(alignedOffset, limit); return cacheStorage.getFile(key).then((blob: Blob) => { return fromPreload ? new Uint8Array() : readBlobAsUint8Array(blob); - }, (error) => { - if(error === 'NO_ENTRY_FOUND') { + }, (error: ApiError) => { + if(error.type === 'NO_ENTRY_FOUND') { return; } }); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 05ae830a..fb8516a0 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -16,7 +16,7 @@ import deferredPromise, { CancellablePromise } from "../helpers/cancellablePromi import { IS_WORKER } from "../helpers/context"; import throttle from "../helpers/schedulers/throttle"; //import { WorkerTaskTemplate } from "../types"; -import IDBStorage from "./idb"; +import IDBStorage from "./files/idb"; function noop() {} @@ -166,8 +166,9 @@ export default class AppStorage< } // console.log('[AS]: get time', keys, performance.now() - perf); - }, (error) => { - if(!['NO_ENTRY_FOUND', 'STORAGE_OFFLINE'].includes(error)) { + }, (error: ApiError) => { + const ignoreErrors: Set = new Set(['NO_ENTRY_FOUND', 'STORAGE_OFFLINE']); + if(!ignoreErrors.has(error.type)) { this.useStorage = false; console.error('[AS]: get error:', error, keys, storeName); } diff --git a/src/lib/storages/filters.ts b/src/lib/storages/filters.ts index 9644c453..9e052bc1 100644 --- a/src/lib/storages/filters.ts +++ b/src/lib/storages/filters.ts @@ -12,6 +12,7 @@ import { AppManager } from "../appManagers/manager"; import findAndSplice from "../../helpers/array/findAndSplice"; import assumeType from "../../helpers/assumeType"; import { FOLDER_ID_ALL, FOLDER_ID_ARCHIVE, REAL_FOLDERS, REAL_FOLDER_ID, START_LOCAL_ID } from "../mtproto/mtproto_config"; +import makeError from "../../helpers/makeError"; export type MyDialogFilter = DialogFilter.dialogFilter; @@ -313,7 +314,7 @@ export default class FiltersStorage extends AppManager { if(!wasPinned) { if(filter.pinned_peers.length >= (await this.apiManager.getConfig()).pinned_infolder_count_max) { - return Promise.reject({type: 'PINNED_DIALOGS_TOO_MUCH'}); + return Promise.reject(makeError('PINNED_DIALOGS_TOO_MUCH')); } filter.pinned_peers.unshift(this.appPeersManager.getInputPeerById(peerId)); diff --git a/src/pages/pageSignQR.ts b/src/pages/pageSignQR.ts index 2fbf61d0..a5733da2 100644 --- a/src/pages/pageSignQR.ts +++ b/src/pages/pageSignQR.ts @@ -5,7 +5,6 @@ */ import type { DcId } from '../types'; -import type { ApiError } from '../lib/mtproto/apiManager'; import Page from './page'; import { AuthAuthorization, AuthLoginToken } from '../layer'; import App from '../config/app'; diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index 3bbe860d..5ab93280 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -1490,6 +1490,13 @@ $bubble-beside-button-width: 38px; } } + .document { + .time { + height: 0; + align-self: flex-start; + } + } + /* .document, .audio { .time.tgico { diff --git a/src/scss/partials/_document.scss b/src/scss/partials/_document.scss index 8febfe95..4f0d9aee 100644 --- a/src/scss/partials/_document.scss +++ b/src/scss/partials/_document.scss @@ -23,6 +23,14 @@ line-height: 1; text-align: center; + &-text { + opacity: 0; + + @include animation-level(2) { + transition: opacity .2s ease-in-out; + } + } + .document:not(.document-with-thumb) & { padding: 1.5625rem .25rem 0 .25rem; @@ -43,8 +51,8 @@ position: absolute; top: 0; right: 0; - width: var(--size); - height: var(--size); + // width: var(--size); + // height: var(--size); border-bottom-left-radius: .25rem; border-style: solid; border-width: calc(var(--size) / 2); @@ -52,25 +60,54 @@ border-bottom-color: rgba(0, 0, 0, .25); border-top-color: var(--message-background-color); border-right-color: var(--message-background-color); + + @include animation-level(2) { + transition: border-width .2s ease-in-out; + } + } + } + + &:not(.downloaded) { + @include hover() { + .document-ico:after { + border-width: 0; + } + + .document-ico-text { + opacity: 0; + } + + .preloader-container { + opacity: 1 !important; + } + } + } + + &:not(.downloading) { + .document-ico-text { + opacity: 1; + } + + .preloader-container { + opacity: 0 !important; + } + } + + &.downloading { + .document-ico:after { + border-width: 0; } } - &-ico, - &-download { + &-ico { font-size: 1.125rem; background-size: contain; } &-ico, - &-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &-download { - background-color: var(--background-color); - border-radius: $border-radius; + &-name, + &-size { + @include text-overflow(); } &.ext-zip { @@ -95,7 +132,7 @@ } .document-download { - background-color: rgba(0, 0, 0, .15); + background-color: rgba(0, 0, 0, .2); } .preloader-circular { @@ -123,13 +160,12 @@ } &-size { - white-space: nowrap; color: var(--secondary-text-color); font-size: var(--font-size-14); line-height: var(--line-height-14); //padding-right: 32px; - text-overflow: ellipsis; - overflow: hidden; + pointer-events: none; + position: relative; } .preloader-container {