Browse Source

Upload fix

Fixed upload preloader
master
morethanwords 4 years ago
parent
commit
8a5a91b3c4
  1. 2
      src/components/audio.ts
  2. 58
      src/components/preloader.ts
  3. 4
      src/components/wrappers.ts
  4. 5
      src/lib/appManagers/appDocsManager.ts
  5. 56
      src/lib/appManagers/appDownloadManager.ts
  6. 10
      src/lib/appManagers/appImManager.ts
  7. 136
      src/lib/appManagers/appMessagesManager.ts
  8. 67
      src/lib/mtproto/apiFileManager.ts
  9. 1
      src/lib/mtproto/mtproto.worker.ts
  10. 4
      src/lib/mtproto/mtprotoworker.ts
  11. 14
      src/types.d.ts
  12. 2
      webpack.common.js

2
src/components/audio.ts

@ -303,7 +303,7 @@ export default class AudioElement extends HTMLElement { @@ -303,7 +303,7 @@ export default class AudioElement extends HTMLElement {
downloadDiv.innerHTML = '<div class="tgico-download"></div>';
}
if(doc.type != 'audio' && !uploading) {
if(doc.type != 'audio' || uploading) {
this.append(downloadDiv);
}

58
src/components/preloader.ts

@ -53,37 +53,41 @@ export default class ProgressivePreloader { @@ -53,37 +53,41 @@ export default class ProgressivePreloader {
}
}
public attach(elem: Element, reset = true, promise?: CancellablePromise<any>, append = true) {
if(promise/* && false */) {
this.promise = promise;
public attachPromise(promise: CancellablePromise<any>) {
this.promise = promise;
const tempID = --this.tempID;
const tempID = --this.tempID;
const onEnd = () => {
promise.notify = null;
const onEnd = () => {
promise.notify = null;
if(tempID == this.tempID) {
this.detach();
this.promise = promise = null;
}
};
//promise.catch(onEnd);
promise.finally(onEnd);
if(promise.addNotifyListener) {
promise.addNotifyListener((details: {done: number, total: number}) => {
/* if(details.done >= details.total) {
onEnd();
} */
if(tempID != this.tempID) return;
//console.log('preloader download', promise, details);
const percents = details.done / details.total * 100;
this.setProgress(percents);
});
if(tempID == this.tempID) {
this.detach();
this.promise = promise = null;
}
};
//promise.catch(onEnd);
promise.finally(onEnd);
if(promise.addNotifyListener) {
promise.addNotifyListener((details: {done: number, total: number}) => {
/* if(details.done >= details.total) {
onEnd();
} */
if(tempID != this.tempID) return;
//console.log('preloader download', promise, details);
const percents = details.done / details.total * 100;
this.setProgress(percents);
});
}
}
public attach(elem: Element, reset = true, promise?: CancellablePromise<any>, append = true) {
if(promise/* && false */) {
this.attachPromise(promise);
}
this.detached = false;

4
src/components/wrappers.ts

@ -59,7 +59,7 @@ export function wrapVideo({doc, container, message, boxWidth, boxHeight, withTai @@ -59,7 +59,7 @@ export function wrapVideo({doc, container, message, boxWidth, boxHeight, withTai
let img: HTMLImageElement;
if(message) {
if(doc.type == 'video') {
if(doc.type == 'video' && doc.thumbs?.length) {
return wrapPhoto(doc, message, container, boxWidth, boxHeight, withTail, isOut, lazyLoadQueue, middleware);
}
@ -390,7 +390,7 @@ export function wrapPhoto(photo: MTPhoto | MTDocument, message: any, container: @@ -390,7 +390,7 @@ export function wrapPhoto(photo: MTPhoto | MTDocument, message: any, container:
});
};
return cacheContext.downloaded ? load() : lazyLoadQueue.push({div: container, load: load, wasSeen: true});
return cacheContext.downloaded || !lazyLoadQueue ? load() : lazyLoadQueue.push({div: container, load: load, wasSeen: true});
}
export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, onlyThumb, emoji, width, height, withThumb, loop}: {

5
src/lib/appManagers/appDocsManager.ts

@ -124,7 +124,10 @@ class AppDocsManager { @@ -124,7 +124,10 @@ class AppDocsManager {
if((doc.type == 'gif' && doc.size > 8e6) || doc.type == 'audio' || doc.type == 'video') {
doc.supportsStreaming = true;
doc.url = this.getFileURL(doc);
if(!doc.url) {
doc.url = this.getFileURL(doc);
}
}
if(!doc.file_name) {

56
src/lib/appManagers/appDownloadManager.ts

@ -3,6 +3,7 @@ import apiManager from "../mtproto/mtprotoworker"; @@ -3,6 +3,7 @@ import apiManager from "../mtproto/mtprotoworker";
import { deferredPromise, CancellablePromise } from "../polyfill";
import type { DownloadOptions } from "../mtproto/apiFileManager";
import { getFileNameByLocation } from "../bin_utils";
import { InputFile } from "../../types";
export type ResponseMethodBlob = 'blob';
export type ResponseMethodJson = 'json';
@ -23,6 +24,8 @@ export class AppDownloadManager { @@ -23,6 +24,8 @@ export class AppDownloadManager {
private progress: {[fileName: string]: Progress} = {};
private progressCallbacks: {[fileName: string]: Array<ProgressCallback>} = {};
private uploadID = 0;
constructor() {
$rootScope.$on('download_progress', (e) => {
const details = e.detail as {done: number, fileName: string, total: number, offset: number};
@ -40,39 +43,56 @@ export class AppDownloadManager { @@ -40,39 +43,56 @@ export class AppDownloadManager {
});
}
public download(options: DownloadOptions, responseMethod?: ResponseMethodBlob): DownloadBlob;
public download(options: DownloadOptions, responseMethod?: ResponseMethodJson): DownloadJson;
public download(options: DownloadOptions, responseMethod: ResponseMethod = 'blob'): DownloadBlob {
const fileName = getFileNameByLocation(options.location, {fileName: options.fileName});
if(this.downloads.hasOwnProperty(fileName)) return this.downloads[fileName];
private getNewDeferred(fileName: string) {
const deferred = deferredPromise<Blob>();
apiManager.downloadFile(options)
.then(deferred.resolve, deferred.reject)
.finally(() => {
delete this.progressCallbacks[fileName];
});
//console.log('Will download file:', fileName, url);
deferred.cancel = () => {
const error = new Error('Download canceled');
error.name = 'AbortError';
apiManager.cancelDownload(fileName);
delete this.downloads[fileName];
delete this.progress[fileName];
delete this.progressCallbacks[fileName];
this.clearDownload(fileName);
deferred.reject(error);
deferred.cancel = () => {};
};
deferred.finally(() => {
delete this.progress[fileName];
delete this.progressCallbacks[fileName];
});
return this.downloads[fileName] = deferred;
}
private clearDownload(fileName: string) {
delete this.downloads[fileName];
}
public download(options: DownloadOptions): DownloadBlob {
const fileName = getFileNameByLocation(options.location, {fileName: options.fileName});
if(this.downloads.hasOwnProperty(fileName)) return this.downloads[fileName];
const deferred = this.getNewDeferred(fileName);
apiManager.downloadFile(options).then(deferred.resolve, deferred.reject);
//console.log('Will download file:', fileName, url);
return deferred;
}
public upload(file: File | Blob) {
const fileName = /* (file as File).name || */'upload-' + this.uploadID++;
const deferred = this.getNewDeferred(fileName);
apiManager.uploadFile({file, fileName}).then(deferred.resolve, deferred.reject);
deferred.finally(() => {
this.clearDownload(fileName);
});
return deferred as any as CancellablePromise<InputFile>;
}
public getDownload(fileName: string) {
return this.downloads[fileName];
}

10
src/lib/appManagers/appImManager.ts

@ -39,6 +39,7 @@ import appAudio from '../../components/appAudio'; @@ -39,6 +39,7 @@ import appAudio from '../../components/appAudio';
import appPollsManager from './appPollsManager';
import { ripple } from '../../components/ripple';
import { horizontalMenu } from '../../components/horizontalMenu';
import AudioElement from '../../components/audio';
//console.log('appImManager included33!');
@ -2251,15 +2252,14 @@ export class AppImManager { @@ -2251,15 +2252,14 @@ export class AppImManager {
case 'audio':
case 'voice':
case 'document': {
let doc = appDocsManager.getDoc(message.id);
const doc = appDocsManager.getDoc(message.id);
this.log('will wrap pending doc:', doc);
let docDiv = wrapDocument(doc, false, true, message.id);
const docDiv = wrapDocument(doc, false, true, message.id);
if(doc.type == 'audio' || doc.type == 'voice') {
// @ts-ignore
docDiv.preloader = preloader;
(docDiv as AudioElement).preloader = preloader;
} else {
let icoDiv = docDiv.querySelector('.audio-download, .document-ico');
const icoDiv = docDiv.querySelector('.audio-download, .document-ico');
preloader.attach(icoDiv, false);
}

136
src/lib/appManagers/appMessagesManager.ts

@ -23,6 +23,7 @@ import searchIndexManager from '../searchIndexManager'; @@ -23,6 +23,7 @@ import searchIndexManager from '../searchIndexManager';
import { MTDocument, MTPhotoSize } from "../../types";
import { logger, LogLevels } from "../logger";
import type {ApiFileManager} from '../mtproto/apiFileManager';
import appDownloadManager from "./appDownloadManager";
//console.trace('include');
@ -1121,9 +1122,9 @@ export class AppMessagesManager { @@ -1121,9 +1122,9 @@ export class AppMessagesManager {
flags |= 256;
}
let preloader = new ProgressivePreloader(null, true);
const preloader = new ProgressivePreloader(null, true);
var media = {
const media = {
_: 'messageMediaPending',
type: attachType,
file_name: fileName || apiFileName,
@ -1132,22 +1133,10 @@ export class AppMessagesManager { @@ -1132,22 +1133,10 @@ export class AppMessagesManager {
preloader: preloader,
w: options.width,
h: options.height,
url: options.objectURL,
progress: {
percent: 1,
total: file.size,
done: 0,
cancel: () => {}
}
};
preloader.preloader.onclick = () => {
this.log('cancelling upload', media);
this.setTyping('sendMessageCancelAction');
media.progress.cancel();
url: options.objectURL
};
var message: any = {
const message: any = {
_: 'message',
id: messageID,
from_id: fromID,
@ -1168,7 +1157,7 @@ export class AppMessagesManager { @@ -1168,7 +1157,7 @@ export class AppMessagesManager {
pending: true
};
var toggleError = (on: boolean) => {
const toggleError = (on: boolean) => {
if(on) {
message.error = true;
} else {
@ -1178,10 +1167,10 @@ export class AppMessagesManager { @@ -1178,10 +1167,10 @@ export class AppMessagesManager {
$rootScope.$broadcast('messages_pending');
};
var uploaded = false,
let uploaded = false,
uploadPromise: ReturnType<ApiFileManager['uploadFile']> = null;
let invoke = (flags: number, inputMedia: any) => {
const invoke = (flags: number, inputMedia: any) => {
this.setTyping('sendMessageCancelAction');
return apiManager.invokeApi('messages.sendMedia', {
@ -1221,9 +1210,9 @@ export class AppMessagesManager { @@ -1221,9 +1210,9 @@ export class AppMessagesManager {
flags |= 128; // clear_draft
if(isDocument) {
let {id, access_hash, file_reference} = file as MTDocument;
const {id, access_hash, file_reference} = file as MTDocument;
let inputMedia = {
const inputMedia = {
_: 'inputMediaDocument',
flags: 0,
id: {
@ -1236,16 +1225,13 @@ export class AppMessagesManager { @@ -1236,16 +1225,13 @@ export class AppMessagesManager {
invoke(flags, inputMedia);
} else if(file instanceof File || file instanceof Blob) {
let deferred = deferredPromise<void>();
const deferred = deferredPromise<void>();
this.sendFilePromise.then(() => {
if(!uploaded || message.error) {
uploaded = false;
//uploadPromise = apiFileManager.uploadFile(file);
uploadPromise = fetch('/upload', {
method: 'POST',
body: file
}).then(res => res.json());
uploadPromise = appDownloadManager.upload(file);
preloader.attachPromise(uploadPromise);
}
uploadPromise && uploadPromise.then((inputFile) => {
@ -1277,28 +1263,23 @@ export class AppMessagesManager { @@ -1277,28 +1263,23 @@ export class AppMessagesManager {
toggleError(true);
});
uploadPromise.notify = (progress: {done: number, total: number}) => {
uploadPromise.addNotifyListener((progress: {done: number, total: number}) => {
this.log('upload progress', progress);
media.progress.done = progress.done;
media.progress.percent = Math.max(1, Math.floor(100 * progress.done / progress.total));
this.setTyping({_: actionName, progress: media.progress.percent | 0});
preloader.setProgress(media.progress.percent); // lol, nice
$rootScope.$broadcast('history_update', {peerID: peerID});
};
media.progress.cancel = () => {
if(!uploaded) {
const percents = Math.max(1, Math.floor(100 * progress.done / progress.total));
this.setTyping({_: actionName, progress: percents | 0});
});
uploadPromise.catch(err => {
if(err.name === 'AbortError' && !uploaded) {
this.log('cancelling upload', media);
deferred.resolve();
uploadPromise.cancel();
this.cancelPendingMessage(randomIDS);
this.setTyping('sendMessageCancelAction');
}
};
// @ts-ignore
uploadPromise['finally'](() => {
deferred.resolve();
preloader.detach();
});
uploadPromise.finally(deferred.resolve);
});
this.sendFilePromise = deferred;
@ -1382,12 +1363,6 @@ export class AppMessagesManager { @@ -1382,12 +1363,6 @@ export class AppMessagesManager {
_: 'messageMediaPending',
type: 'album',
preloader: preloader,
progress: {
percent: 1,
total: file.size,
done: 0,
cancel: () => {}
},
document: undefined as any,
photo: undefined as any
};
@ -1442,12 +1417,6 @@ export class AppMessagesManager { @@ -1442,12 +1417,6 @@ export class AppMessagesManager {
media.photo = photo;
}
preloader.preloader.onclick = () => {
this.log('cancelling upload', media);
this.setTyping('sendMessageCancelAction');
media.progress.cancel();
};
let message = {
_: 'message',
id: messageID,
@ -1509,36 +1478,52 @@ export class AppMessagesManager { @@ -1509,36 +1478,52 @@ export class AppMessagesManager {
let inputs: any[] = [];
for(let i = 0, length = files.length; i < length; ++i) {
let file = files[i];
let message = messages[i];
let media = message.media;
let preloader = media.preloader;
let actionName = file.type.indexOf('video/') === 0 ? 'sendMessageUploadVideoAction' : 'sendMessageUploadPhotoAction';
let deferred = deferredPromise<void>();
const file = files[i];
const message = messages[i];
const media = message.media;
const preloader = media.preloader;
const actionName = file.type.indexOf('video/') === 0 ? 'sendMessageUploadVideoAction' : 'sendMessageUploadPhotoAction';
const deferred = deferredPromise<void>();
let canceled = false;
let apiFileName: string;
if(file.type.indexOf('video/') === 0) {
apiFileName = 'video.mp4';
} else {
apiFileName = 'photo.' + file.type.split('/')[1];
}
await this.sendFilePromise;
this.sendFilePromise = deferred;
if(!uploaded || message.error) {
uploaded = false;
//uploadPromise = apiFileManager.uploadFile(file);
uploadPromise = fetch('/upload', {
method: 'POST',
body: file
}).then(res => res.json());
uploadPromise = appDownloadManager.upload(file);
preloader.attachPromise(uploadPromise);
}
uploadPromise.notify = (progress: {done: number, total: number}) => {
uploadPromise.addNotifyListener((progress: {done: number, total: number}) => {
this.log('upload progress', progress);
media.progress.percent = Math.max(1, Math.floor(100 * progress.done / progress.total));
this.setTyping({_: actionName, progress: media.progress.percent | 0});
preloader.setProgress(media.progress.percent); // lol, nice
$rootScope.$broadcast('history_update', {peerID: peerID});
};
const percents = Math.max(1, Math.floor(100 * progress.done / progress.total));
this.setTyping({_: actionName, progress: percents | 0});
});
uploadPromise.catch(err => {
if(err.name === 'AbortError' && !uploaded) {
this.log('cancelling upload item', media);
canceled = true;
}
});
await uploadPromise.then((inputFile) => {
this.log('appMessagesManager: sendAlbum file uploaded:', inputFile);
if(canceled) {
return;
}
inputFile.name = apiFileName;
let inputMedia: any;
let details = options.sendFileDetails[i];
if(details.duration) {
@ -1568,6 +1553,10 @@ export class AppMessagesManager { @@ -1568,6 +1553,10 @@ export class AppMessagesManager {
peer: inputPeer,
media: inputMedia
}).then(messageMedia => {
if(canceled) {
return;
}
let inputMedia: any;
if(messageMedia.photo) {
let photo = messageMedia.photo;
@ -1598,7 +1587,6 @@ export class AppMessagesManager { @@ -1598,7 +1587,6 @@ export class AppMessagesManager {
this.log('appMessagesManager: sendAlbum uploadPromise.finally!');
deferred.resolve();
preloader.detach();
}
uploaded = true;

67
src/lib/mtproto/apiFileManager.ts

@ -5,7 +5,7 @@ import FileManager from "../filemanager"; @@ -5,7 +5,7 @@ import FileManager from "../filemanager";
import apiManager from "./apiManager";
import { deferredPromise, CancellablePromise } from "../polyfill";
import { logger, LogLevels } from "../logger";
import { InputFileLocation, FileLocation, UploadFile } from "../../types";
import { InputFileLocation, FileLocation, UploadFile, InputFile } from "../../types";
import { isSafari } from "../../helpers/userAgent";
import cryptoWorker from "../crypto/cryptoworker";
import { notifySomeone, notifyAll } from "../../helpers/context";
@ -33,6 +33,10 @@ export class ApiFileManager { @@ -33,6 +33,10 @@ export class ApiFileManager {
[fileName: string]: CancellablePromise<Blob>
} = {};
public uploadPromises: {
[fileName: string]: CancellablePromise<InputFile>
} = {};
public downloadPulls: {
[x: string]: Array<{
cb: () => Promise<UploadFile | void>,
@ -108,8 +112,8 @@ export class ApiFileManager { @@ -108,8 +112,8 @@ export class ApiFileManager {
}
public cancelDownload(fileName: string) {
const promise = this.cachedDownloadPromises[fileName];
if(promise) {
const promise = this.cachedDownloadPromises[fileName] || this.uploadPromises[fileName];
if(promise && !promise.isRejected && !promise.isFulfilled) {
promise.cancel();
return true;
}
@ -401,10 +405,11 @@ export class ApiFileManager { @@ -401,10 +405,11 @@ export class ApiFileManager {
return this.getFileStorage().deleteFile(fileName);
}
public uploadFile(file: Blob | File) {
var fileSize = file.size,
isBigFile = fileSize >= 10485760,
canceled = false,
public uploadFile({file, fileName}: {file: Blob | File, fileName: string}) {
const fileSize = file.size,
isBigFile = fileSize >= 10485760;
let canceled = false,
resolved = false,
doneParts = 0,
partSize = 262144, // 256 Kb
@ -418,27 +423,27 @@ export class ApiFileManager { @@ -418,27 +423,27 @@ export class ApiFileManager {
activeDelta = 1;
}
var totalParts = Math.ceil(fileSize / partSize);
const totalParts = Math.ceil(fileSize / partSize);
const fileID: [number, number] = [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)];
var fileID = [nextRandomInt(0xFFFFFFFF), nextRandomInt(0xFFFFFFFF)];
let _part = 0;
var _part = 0,
resultInputFile = {
_: isBigFile ? 'inputFileBig' : 'inputFile',
id: fileID,
parts: totalParts,
name: file instanceof File ? file.name : '',
md5_checksum: ''
const resultInputFile: InputFile = {
_: isBigFile ? 'inputFileBig' : 'inputFile',
id: fileID,
parts: totalParts,
name: fileName,
md5_checksum: ''
};
let deferredHelper: {
const deferredHelper: {
resolve?: (input: typeof resultInputFile) => void,
reject?: (error: any) => void,
notify?: (details: {done: number, total: number}) => void
} = {
notify: (details: {done: number, total: number}) => {}
};
let deferred: CancellablePromise<typeof resultInputFile> = new Promise((resolve, reject) => {
const deferred: CancellablePromise<typeof resultInputFile> = new Promise((resolve, reject) => {
if(totalParts > 3000) {
return reject({type: 'FILE_TOO_BIG'});
}
@ -459,13 +464,13 @@ export class ApiFileManager { @@ -459,13 +464,13 @@ export class ApiFileManager {
errorHandler = () => {};
};
let method = isBigFile ? 'upload.saveBigFilePart' : 'upload.saveFilePart';
const method = isBigFile ? 'upload.saveBigFilePart' : 'upload.saveFilePart';
for(let offset = 0; offset < fileSize; offset += partSize) {
let part = _part++; // 0, 1
const part = _part++; // 0, 1
this.downloadRequest('upload', () => {
return new Promise<void>((uploadResolve, uploadReject) => {
var reader = new FileReader();
var blob = file.slice(offset, offset + partSize);
const reader = new FileReader();
const blob = file.slice(offset, offset + partSize);
reader.onloadend = (e) => {
if(canceled) {
@ -475,6 +480,7 @@ export class ApiFileManager { @@ -475,6 +480,7 @@ export class ApiFileManager {
if(e.target.readyState != FileReader.DONE) {
this.log.error('wrong readyState!');
uploadReject();
return;
}
@ -494,18 +500,19 @@ export class ApiFileManager { @@ -494,18 +500,19 @@ export class ApiFileManager {
uploadResolve();
//////this.log('Progress', doneParts * partSize / fileSize);
deferred.notify({done: doneParts * partSize, total: fileSize});
if(doneParts >= totalParts) {
deferred.resolve(resultInputFile);
resolved = true;
} else {
deferred.notify({done: doneParts * partSize, total: fileSize});
}
}, errorHandler);
};
reader.readAsArrayBuffer(blob);
});
}, activeDelta);
}, activeDelta).catch(errorHandler);
}
deferred.cancel = () => {
@ -516,7 +523,15 @@ export class ApiFileManager { @@ -516,7 +523,15 @@ export class ApiFileManager {
}
};
return deferred;
deferred.notify = (progress: {done: number, total: number}) => {
notifyAll({progress: {fileName, ...progress}});
};
deferred.finally(() => {
delete this.uploadPromises[fileName];
});
return this.uploadPromises[fileName] = deferred;
}
}

1
src/lib/mtproto/mtproto.worker.ts

@ -106,6 +106,7 @@ ctx.addEventListener('message', async(e) => { @@ -106,6 +106,7 @@ ctx.addEventListener('message', async(e) => {
});
case 'cancelDownload':
case 'uploadFile':
case 'downloadFile': {
try {
// @ts-ignore

4
src/lib/mtproto/mtprotoworker.ts

@ -219,6 +219,10 @@ class ApiManagerProxy extends CryptoWorkerMethods { @@ -219,6 +219,10 @@ class ApiManagerProxy extends CryptoWorkerMethods {
public downloadFile(options: DownloadOptions) {
return this.performTaskWorker('downloadFile', options);
}
public uploadFile(options: {file: Blob | File, fileName: string}) {
return this.performTaskWorker('uploadFile', options);
}
}
const apiManagerProxy = new ApiManagerProxy();

14
src/types.d.ts vendored

@ -150,4 +150,16 @@ export type WorkerTaskTemplate = { @@ -150,4 +150,16 @@ export type WorkerTaskTemplate = {
type: string,
id: number,
payload: any
};
};
export type inputFile = {
_: 'inputFile',
parts: number,
id: [number, number],
name: string,
md5_checksum: string
};
export type inputFileBig = Omit<inputFile, 'md5_checksum' | '_'> & {_: 'inputFileBig'};
export type InputFile = inputFile | inputFileBig;

2
webpack.common.js

@ -6,7 +6,7 @@ const postcssPresetEnv = require('postcss-preset-env'); @@ -6,7 +6,7 @@ const postcssPresetEnv = require('postcss-preset-env');
const ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin');
const fs = require('fs');
const allowedIPs = ['195.66.140.39', '192.168.31.144', '127.0.0.1', '192.168.31.1', '192.168.31.192', '176.100.18.181', '46.219.250.22'];
const allowedIPs = ['195.66.140.39', '192.168.31.144', '127.0.0.1', '192.168.31.1', '192.168.31.192', '176.100.18.181', '46.219.250.22', '193.42.119.184'];
const devMode = process.env.NODE_ENV !== 'production';
const useLocal = false;

Loading…
Cancel
Save