Browse Source

Fix jumping scroll again

master
Eduard Kuzmenko 2 years ago
parent
commit
97d645f1ea
  1. 15
      git-serve-server.js
  2. 18
      src/components/animationIntersector.ts
  3. 127
      src/components/chat/bubbles.ts
  4. 2
      src/components/chat/pinnedMessage.ts
  5. 16
      src/components/wrappers.ts
  6. 2
      src/lib/appManagers/appAvatarsManager.ts
  7. 136
      src/lib/appManagers/appMessagesManager.ts
  8. 2
      src/lib/appManagers/appReactionsManager.ts
  9. 4
      src/lib/rootScope.ts

15
git-serve-server.js

@ -40,20 +40,31 @@ for(const name of Object.keys(nets)) {
} }
const useHttp = false; const useHttp = false;
const server = useHttp ? http : https; const transport = useHttp ? http : https;
let options = {}; let options = {};
if(!useHttp) { if(!useHttp) {
options.key = fs.readFileSync(__dirname + '/certs/server-key.pem'); options.key = fs.readFileSync(__dirname + '/certs/server-key.pem');
options.cert = fs.readFileSync(__dirname + '/certs/server-cert.pem'); options.cert = fs.readFileSync(__dirname + '/certs/server-cert.pem');
} }
console.log(results);
const port = 3000; const port = 3000;
const protocol = useHttp ? 'http' : 'https'; const protocol = useHttp ? 'http' : 'https';
console.log('Listening port:', port); console.log('Listening port:', port);
function createServer(host) { function createServer(host) {
server.createServer(options, app).listen(port, host, () => { const server = transport.createServer(options, app);
server.listen(port, host, () => {
console.log('Host:', `${protocol}://${host || 'localhost'}:${port}/`); console.log('Host:', `${protocol}://${host || 'localhost'}:${port}/`);
}); });
server.on('error', (e) => {
// @ts-ignore
if(e.code === 'EADDRINUSE') {
console.log('Address in use:', host);
server.close();
}
});
} }
for(const name in results) { for(const name in results) {

18
src/components/animationIntersector.ts

@ -47,14 +47,24 @@ export class AnimationIntersector {
if(entry.isIntersecting) { if(entry.isIntersecting) {
this.visible.add(player); this.visible.add(player);
this.checkAnimation(player, false); this.checkAnimation(player, false);
/* if(animation instanceof HTMLVideoElement && animation.dataset.src) {
animation.src = animation.dataset.src;
animation.load();
} */
} else { } else {
this.visible.delete(player); this.visible.delete(player);
this.checkAnimation(player, true); this.checkAnimation(player, true);
if(player.animation instanceof RLottiePlayer/* && player.animation.cachingDelta === 2 */) { const animation = player.animation;
if(animation instanceof RLottiePlayer/* && animation.cachingDelta === 2 */) {
//console.warn('will clear cache', player); //console.warn('will clear cache', player);
player.animation.clearCache(); animation.clearCache();
} }/* else if(animation instanceof HTMLVideoElement && animation.src) {
animation.dataset.src = animation.src;
animation.src = '';
animation.load();
} */
} }
break; break;

127
src/components/chat/bubbles.ts

@ -208,8 +208,6 @@ export default class ChatBubbles {
private hoverBubble: HTMLElement; private hoverBubble: HTMLElement;
private hoverReaction: HTMLElement; private hoverReaction: HTMLElement;
private onUpdateScrollSaver: ScrollSaver;
// private reactions: Map<number, ReactionsElement>; // private reactions: Map<number, ReactionsElement>;
constructor( constructor(
@ -431,8 +429,10 @@ export default class ChatBubbles {
const updatePosition = this.chat.type === 'scheduled'; const updatePosition = this.chat.type === 'scheduled';
this.saveOnUpdateScroll(); const scrollSaver = new ScrollSaver(this.scrollable, true);
scrollSaver.save();
this.safeRenderMessage(mounted.message, true, false, mounted.bubble, updatePosition); this.safeRenderMessage(mounted.message, true, false, mounted.bubble, updatePosition);
scrollSaver.restore();
if(updatePosition) { if(updatePosition) {
(this.messagesQueuePromise || Promise.resolve()).then(() => { (this.messagesQueuePromise || Promise.resolve()).then(() => {
@ -461,43 +461,41 @@ export default class ChatBubbles {
this.appendReactionsElementToBubble(bubble, message, changedResults); this.appendReactionsElementToBubble(bubble, message, changedResults);
}); });
this.listenerSetter.add(rootScope)('message_reactions', ({message, changedResults}) => { this.listenerSetter.add(rootScope)('messages_reactions', (arr) => {
if(this.peerId !== message.peerId) { let scrollSaver: ScrollSaver;
return;
}
const bubble = this.getBubbleByMessage(message); for(const {message, changedResults} of arr) {
if(!bubble) { if(this.peerId !== message.peerId) {
return; return;
} }
this.saveOnUpdateScroll(); const bubble = this.getBubbleByMessage(message);
if(!bubble) {
return;
}
const key = message.peerId + '_' + message.mid; if(!scrollSaver) {
const set = REACTIONS_ELEMENTS.get(key); scrollSaver = new ScrollSaver(this.scrollable, true);
if(set) { scrollSaver.save();
for(const element of set) {
element.update(message, changedResults);
} }
} else {
rootScope.dispatchEvent('missed_reactions_element', {message, changedResults}); const key = message.peerId + '_' + message.mid;
const set = REACTIONS_ELEMENTS.get(key);
if(set) {
for(const element of set) {
element.update(message, changedResults);
}
} else {
rootScope.dispatchEvent('missed_reactions_element', {message, changedResults});
}
}
if(scrollSaver) {
scrollSaver.restore();
} }
}); });
} }
/* this.listenerSetter.add(rootScope)('message_reactions', ({peerId, mid}) => {
if(this.peerId !== peerId) {
return;
}
const reactionsElement = this.reactions.get(mid);
if(!reactionsElement) {
return;
}
}); */
this.listenerSetter.add(rootScope)('album_edit', ({peerId, groupId, deletedMids}) => { this.listenerSetter.add(rootScope)('album_edit', ({peerId, groupId, deletedMids}) => {
//fastRaf(() => { // ! can't use delayed smth here, need original bubble to be edited //fastRaf(() => { // ! can't use delayed smth here, need original bubble to be edited
if(peerId !== this.peerId) return; if(peerId !== this.peerId) return;
@ -767,24 +765,35 @@ export default class ChatBubbles {
} }
}); });
this.listenerSetter.add(rootScope)('message_views', ({peerId, views, mid}) => { this.listenerSetter.add(rootScope)('messages_views', (arr) => {
if(this.peerId !== peerId) return;
fastRaf(() => { fastRaf(() => {
const bubble = this.bubbles[mid]; let scrollSaver: ScrollSaver;
if(!bubble) return; for(const {peerId, views, mid} of arr) {
if(this.peerId !== peerId) return;
const postViewsElements = Array.from(bubble.querySelectorAll('.post-views')) as HTMLElement[]; const bubble = this.bubbles[mid];
if(postViewsElements.length) { if(!bubble) return;
const str = formatNumber(views, 1);
let different = false; const postViewsElements = Array.from(bubble.querySelectorAll('.post-views')) as HTMLElement[];
postViewsElements.forEach(postViews => { if(postViewsElements.length) {
if(different || postViews.innerHTML !== str) { const str = formatNumber(views, 1);
this.saveOnUpdateScroll(); let different = false;
different = true; postViewsElements.forEach(postViews => {
postViews.innerHTML = str; if(different || postViews.innerHTML !== str) {
} if(!scrollSaver) {
}); scrollSaver = new ScrollSaver(this.scrollable, true);
scrollSaver.save();
}
different = true;
postViews.innerHTML = str;
}
});
}
}
if(scrollSaver) {
scrollSaver.restore();
} }
}); });
}); });
@ -928,18 +937,6 @@ export default class ChatBubbles {
} }
} }
private saveOnUpdateScroll() {
if(!this.onUpdateScrollSaver) {
this.onUpdateScrollSaver = new ScrollSaver(this.scrollable, true);
setTimeout(() => {
this.onUpdateScrollSaver.restore();
this.onUpdateScrollSaver = undefined;
}, 0);
this.onUpdateScrollSaver.save();
}
}
private onBubblesMouseMove = (e: MouseEvent) => { private onBubblesMouseMove = (e: MouseEvent) => {
const content = findUpClassName(e.target, 'bubble-content'); const content = findUpClassName(e.target, 'bubble-content');
if(content && !this.chat.selection.isSelecting) { if(content && !this.chat.selection.isSelecting) {
@ -1048,6 +1045,8 @@ export default class ChatBubbles {
}; };
public setStickyDateManually() { public setStickyDateManually() {
// return;
const timestamps = Object.keys(this.dateMessages).map(k => +k).sort((a, b) => b - a); const timestamps = Object.keys(this.dateMessages).map(k => +k).sort((a, b) => b - a);
let lastVisible: HTMLElement; let lastVisible: HTMLElement;
@ -1716,7 +1715,7 @@ export default class ChatBubbles {
//lottieLoader.checkAnimations(false, 'chat'); //lottieLoader.checkAnimations(false, 'chat');
const distanceToEnd = this.scrollable.getDistanceToEnd(); const distanceToEnd = this.scrollable.getDistanceToEnd();
if(!IS_TOUCH_SUPPORTED && this.scrollable.lastScrollDirection !== 0 && distanceToEnd > 0) { if(/* !IS_TOUCH_SUPPORTED && */this.scrollable.lastScrollDirection !== 0 && distanceToEnd > 0) {
if(this.isScrollingTimeout) { if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout); clearTimeout(this.isScrollingTimeout);
} else if(!this.chatInner.classList.contains('is-scrolling')) { } else if(!this.chatInner.classList.contains('is-scrolling')) {
@ -1767,7 +1766,7 @@ export default class ChatBubbles {
this.scrollable.onScrolledBottom = () => this.loadMoreHistory(false); this.scrollable.onScrolledBottom = () => this.loadMoreHistory(false);
//this.scrollable.attachSentinels(undefined, 300); //this.scrollable.attachSentinels(undefined, 300);
if(IS_TOUCH_SUPPORTED) { if(IS_TOUCH_SUPPORTED && false) {
this.scrollable.container.addEventListener('touchmove', () => { this.scrollable.container.addEventListener('touchmove', () => {
if(this.isScrollingTimeout) { if(this.isScrollingTimeout) {
clearTimeout(this.isScrollingTimeout); clearTimeout(this.isScrollingTimeout);
@ -3782,7 +3781,7 @@ export default class ChatBubbles {
} }
private appendReactionsElementToBubble(bubble: HTMLElement, message: Message.message, changedResults?: ReactionCount[]) { private appendReactionsElementToBubble(bubble: HTMLElement, message: Message.message, changedResults?: ReactionCount[]) {
if(this.peerId.isUser()) { if(this.peerId.isUser()/* || true */) {
return; return;
} }

2
src/components/chat/pinnedMessage.ts

@ -343,7 +343,7 @@ export default class ChatPinnedMessage {
public setCorrectIndex(lastScrollDirection?: number) { public setCorrectIndex(lastScrollDirection?: number) {
if(this.isStatic) return; if(this.isStatic) return;
//return; // return;
if(this.locked || this.hidden/* || this.chat.setPeerPromise || this.chat.bubbles.messagesQueuePromise */) { if(this.locked || this.hidden/* || this.chat.setPeerPromise || this.chat.bubbles.messagesQueuePromise */) {
return; return;

16
src/components/wrappers.ts

@ -451,13 +451,23 @@ export function wrapVideo({doc, container, message, boxWidth, boxHeight, withTai
}); });
if(doc.type === 'video') { if(doc.type === 'video') {
video.addEventListener('timeupdate', () => { const onTimeUpdate = () => {
if(!video.videoWidth) {
return;
}
spanTime.innerText = toHHMMSS(video.duration - video.currentTime, false); spanTime.innerText = toHHMMSS(video.duration - video.currentTime, false);
}); };
const throttledTimeUpdate = throttleWithRaf(onTimeUpdate);
video.addEventListener('timeupdate', throttledTimeUpdate);
if(spanPlay) { if(spanPlay) {
video.addEventListener('timeupdate', () => { video.addEventListener('timeupdate', () => {
spanPlay.remove(); sequentialDom.mutateElement(spanPlay, () => {
spanPlay.remove();
});
}, {once: true}); }, {once: true});
} }
} }

2
src/lib/appManagers/appAvatarsManager.ts

@ -4,6 +4,7 @@
* https://github.com/morethanwords/tweb/blob/master/LICENSE * https://github.com/morethanwords/tweb/blob/master/LICENSE
*/ */
import { MOUNT_CLASS_TO } from "../../config/debug";
import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl";
import replaceContent from "../../helpers/dom/replaceContent"; import replaceContent from "../../helpers/dom/replaceContent";
import sequentialDom from "../../helpers/sequentialDom"; import sequentialDom from "../../helpers/sequentialDom";
@ -220,4 +221,5 @@ export class AppAvatarsManager {
} }
const appAvatarsManager = new AppAvatarsManager(); const appAvatarsManager = new AppAvatarsManager();
MOUNT_CLASS_TO && (MOUNT_CLASS_TO.appAvatarsManager = appAvatarsManager);
export default appAvatarsManager; export default appAvatarsManager;

136
src/lib/appManagers/appMessagesManager.ts

@ -15,8 +15,8 @@ import { CancellablePromise, deferredPromise } from "../../helpers/cancellablePr
import { formatDateAccordingToTodayNew, formatTime, tsNow } from "../../helpers/date"; import { formatDateAccordingToTodayNew, formatTime, tsNow } from "../../helpers/date";
import { createPosterForVideo } from "../../helpers/files"; import { createPosterForVideo } from "../../helpers/files";
import { randomLong } from "../../helpers/random"; import { randomLong } from "../../helpers/random";
import { Chat, ChatFull, Dialog as MTDialog, DialogPeer, DocumentAttribute, InputMedia, InputMessage, InputPeerNotifySettings, InputSingleMedia, Message, MessageAction, MessageEntity, MessageFwdHeader, MessageMedia, MessageReplies, MessageReplyHeader, MessagesDialogs, MessagesFilter, MessagesMessages, MethodDeclMap, NotifyPeer, PeerNotifySettings, PhotoSize, SendMessageAction, Update, Photo, Updates, ReplyMarkup, InputPeer, InputPhoto, InputDocument, InputGeoPoint, WebPage, GeoPoint, ReportReason, MessagesGetDialogs, InputChannel, InputDialogPeer, ReactionCount, MessagePeerReaction, MessagesSearchCounter, Peer } from "../../layer"; import { Chat, ChatFull, Dialog as MTDialog, DialogPeer, DocumentAttribute, InputMedia, InputMessage, InputPeerNotifySettings, InputSingleMedia, Message, MessageAction, MessageEntity, MessageFwdHeader, MessageMedia, MessageReplies, MessageReplyHeader, MessagesDialogs, MessagesFilter, MessagesMessages, MethodDeclMap, NotifyPeer, PeerNotifySettings, PhotoSize, SendMessageAction, Update, Photo, Updates, ReplyMarkup, InputPeer, InputPhoto, InputDocument, InputGeoPoint, WebPage, GeoPoint, ReportReason, MessagesGetDialogs, InputChannel, InputDialogPeer, ReactionCount, MessagePeerReaction, MessagesSearchCounter, Peer, MessageReactions } from "../../layer";
import { InvokeApiOptions } from "../../types"; import { ArgumentTypes, InvokeApiOptions } from "../../types";
import I18n, { FormatterArguments, i18n, join, langPack, LangPackKey, UNSUPPORTED_LANG_PACK_KEY, _i18n } from "../langPack"; import I18n, { FormatterArguments, i18n, join, langPack, LangPackKey, UNSUPPORTED_LANG_PACK_KEY, _i18n } from "../langPack";
import { logger, LogTypes } from "../logger"; import { logger, LogTypes } from "../logger";
import type { ApiFileManager } from '../mtproto/apiFileManager'; import type { ApiFileManager } from '../mtproto/apiFileManager';
@ -70,6 +70,7 @@ import deepEqual from "../../helpers/object/deepEqual";
import escapeRegExp from "../../helpers/string/escapeRegExp"; import escapeRegExp from "../../helpers/string/escapeRegExp";
import limitSymbols from "../../helpers/string/limitSymbols"; import limitSymbols from "../../helpers/string/limitSymbols";
import splitStringByLength from "../../helpers/string/splitStringByLength"; import splitStringByLength from "../../helpers/string/splitStringByLength";
import debounce from "../../helpers/schedulers/debounce";
//console.trace('include'); //console.trace('include');
// TODO: если удалить диалог находясь в папке, то он не удалится из папки и будет виден в настройках // TODO: если удалить диалог находясь в папке, то он не удалится из папки и будет виден в настройках
@ -128,6 +129,13 @@ type PendingAfterMsg = Partial<InvokeApiOptions & {
messageId: string messageId: string
}>; }>;
type MapValueType<A> = A extends Map<any, infer V> ? V : never;
export type BatchUpdates = {
'messages_reactions': AppMessagesManager['batchUpdateReactions'],
'messages_views': AppMessagesManager['batchUpdateViews']
};
export class AppMessagesManager { export class AppMessagesManager {
private messagesStorageByPeerId: {[peerId: string]: MessagesStorage}; private messagesStorageByPeerId: {[peerId: string]: MessagesStorage};
public groupedMessagesStorage: {[groupId: string]: MessagesStorage}; // will be used for albums public groupedMessagesStorage: {[groupId: string]: MessagesStorage}; // will be used for albums
@ -214,6 +222,14 @@ export class AppMessagesManager {
private unreadMentions: {[peerId: PeerId]: SlicedArray} = {}; private unreadMentions: {[peerId: PeerId]: SlicedArray} = {};
private goToNextMentionPromises: {[peerId: PeerId]: Promise<any>} = {}; private goToNextMentionPromises: {[peerId: PeerId]: Promise<any>} = {};
private batchUpdates: {
[k in keyof BatchUpdates]?: {
callback: BatchUpdates[k],
batch: ArgumentTypes<BatchUpdates[k]>[0]
}
} = {};
private batchUpdatesDebounced: () => Promise<void>;
constructor() { constructor() {
this.clear(); this.clear();
@ -345,6 +361,20 @@ export class AppMessagesManager {
this.maxSeenId = state.maxSeenMsgId; this.maxSeenId = state.maxSeenMsgId;
} }
}); });
this.batchUpdatesDebounced = debounce(() => {
for(const event in this.batchUpdates) {
const details = this.batchUpdates[event as keyof BatchUpdates];
delete this.batchUpdates[event as keyof BatchUpdates];
// @ts-ignore
const result = details.callback(details.batch);
if(result && (!(result instanceof Array) || result.length)) {
// @ts-ignore
rootScope.dispatchEvent(event as keyof BatchUpdates, result);
}
}
}, 33, false, true);
} }
public clear() { public clear() {
@ -4700,26 +4730,10 @@ export class AppMessagesManager {
} }
} }
const results = reactions?.results ?? [];
const previousResults = message.reactions?.results ?? [];
const changedResults = results.filter(reactionCount => {
const previousReactionCount = previousResults.find(_reactionCount => _reactionCount.reaction === reactionCount.reaction);
return (
message.pFlags.out && (
!previousReactionCount ||
reactionCount.count > previousReactionCount.count
)
) || (
reactionCount.pFlags.chosen && (
!previousReactionCount ||
!previousReactionCount.pFlags.chosen
)
);
});
message.reactions = reactions; message.reactions = reactions;
rootScope.dispatchEvent('message_reactions', {message, changedResults}); const key = message.peerId + '_' + message.mid;
this.pushBatchUpdate('messages_reactions', this.batchUpdateReactions, key, () => copy(message.reactions));
if(!update.local) { if(!update.local) {
this.setDialogToStateIfMessageIsTop(message); this.setDialogToStateIfMessageIsTop(message);
@ -5094,7 +5108,7 @@ export class AppMessagesManager {
const message: Message.message = this.getMessageByPeer(peerId, mid); const message: Message.message = this.getMessageByPeer(peerId, mid);
if(!message.deleted && message.views !== undefined && message.views < views) { if(!message.deleted && message.views !== undefined && message.views < views) {
message.views = views; message.views = views;
rootScope.dispatchEvent('message_views', {peerId, mid, views}); this.pushBatchUpdate('messages_views', this.batchUpdateViews, message.peerId + '_' + message.mid);
this.setDialogToStateIfMessageIsTop(message); this.setDialogToStateIfMessageIsTop(message);
} }
}; };
@ -6283,6 +6297,86 @@ export class AppMessagesManager {
public canForward(message: Message.message | Message.messageService) { public canForward(message: Message.message | Message.messageService) {
return !(message as Message.message).pFlags.noforwards && !appPeersManager.noForwards(message.peerId); return !(message as Message.message).pFlags.noforwards && !appPeersManager.noForwards(message.peerId);
} }
private pushBatchUpdate<E extends keyof BatchUpdates, C extends BatchUpdates[E]>(
event: E,
callback: C,
key: string,
getElementCallback?: () => MapValueType<ArgumentTypes<C>[0]>
) {
let details = this.batchUpdates[event];
if(!details) {
// @ts-ignore
details = this.batchUpdates[event] = {
callback,
batch: new Map()
};
}
if(!details.batch.has(key)) {
// @ts-ignore
details.batch.set(key, getElementCallback ? getElementCallback() : undefined);
this.batchUpdatesDebounced();
}
}
private getMessagesFromMap<T extends Map<any, any>>(map: T) {
const newMap: Map<Message.message, MapValueType<T>> = new Map();
for(const [key, value] of map) {
const [peerIdStr, mid] = key.split('_');
const message: Message.message | Message.messageEmpty = this.getMessageByPeer(peerIdStr.toPeerId(), +mid);
if(message._ === 'messageEmpty') {
continue;
}
newMap.set(message, value);
}
return newMap;
}
private batchUpdateViews = (batch: Map<string, undefined>) => {
const toDispatch: {peerId: PeerId, mid: number, views: number}[] = [];
const map = this.getMessagesFromMap(batch);
for(const [message] of map) {
toDispatch.push({
peerId: message.peerId,
mid: message.mid,
views: message.views
})
}
return toDispatch;
};
private batchUpdateReactions = (batch: Map<string, MessageReactions>) => {
const toDispatch: {message: Message.message, changedResults: ReactionCount.reactionCount[]}[] = [];
const map = this.getMessagesFromMap(batch);
for(const [message, reactions] of map) {
const results = reactions?.results ?? [];
const previousResults = message.reactions?.results ?? [];
const changedResults = results.filter(reactionCount => {
const previousReactionCount = previousResults.find(_reactionCount => _reactionCount.reaction === reactionCount.reaction);
return (
message.pFlags.out && (
!previousReactionCount ||
reactionCount.count > previousReactionCount.count
)
) || (
reactionCount.pFlags.chosen && (
!previousReactionCount ||
!previousReactionCount.pFlags.chosen
)
);
});
toDispatch.push({message, changedResults});
}
return toDispatch;
};
} }
const appMessagesManager = new AppMessagesManager(); const appMessagesManager = new AppMessagesManager();

2
src/lib/appManagers/appReactionsManager.ts

@ -320,7 +320,7 @@ export class AppReactionsManager {
if(onlyLocal) { if(onlyLocal) {
message.reactions = reactions; message.reactions = reactions;
rootScope.dispatchEvent('message_reactions', {message, changedResults: []}); rootScope.dispatchEvent('messages_reactions', [{message, changedResults: []}]);
return Promise.resolve(); return Promise.resolve();
} }

4
src/lib/rootScope.ts

@ -76,9 +76,9 @@ export type BroadcastEvents = {
//'history_request': void, //'history_request': void,
'message_edit': {storage: MessagesStorage, peerId: PeerId, mid: number}, 'message_edit': {storage: MessagesStorage, peerId: PeerId, mid: number},
'message_views': {peerId: PeerId, mid: number, views: number},
'message_sent': {storage: MessagesStorage, tempId: number, tempMessage: any, mid: number, message: MyMessage}, 'message_sent': {storage: MessagesStorage, tempId: number, tempMessage: any, mid: number, message: MyMessage},
'message_reactions': {message: Message.message, changedResults: ReactionCount[]}, 'messages_views': {peerId: PeerId, mid: number, views: number}[],
'messages_reactions': {message: Message.message, changedResults: ReactionCount[]}[],
'messages_pending': void, 'messages_pending': void,
'messages_read': void, 'messages_read': void,
'messages_downloaded': {peerId: PeerId, mids: number[]}, 'messages_downloaded': {peerId: PeerId, mids: number[]},

Loading…
Cancel
Save