Browse Source

Fix wrong scroll position when switching chats

Fix detecting sticky dates
master
Eduard Kuzmenko 3 years ago
parent
commit
51fed91103
  1. 395
      src/components/chat/bubbles.ts
  2. 56
      src/components/scrollable.ts
  3. 7
      src/components/stickyIntersector.ts
  4. 8
      src/helpers/fastSmoothScroll.ts
  5. 4
      src/helpers/schedulers.ts
  6. 54
      src/helpers/schedulers/debounce.ts
  7. 113
      src/helpers/scrollSaver.ts
  8. 11
      src/lib/appManagers/appImManager.ts
  9. 18
      src/scss/partials/_chatBubble.scss

395
src/components/chat/bubbles.ts

@ -68,7 +68,7 @@ import whichChild from "../../helpers/dom/whichChild";
import { cancelAnimationByKey } from "../../helpers/animation"; import { cancelAnimationByKey } from "../../helpers/animation";
import assumeType from "../../helpers/assumeType"; import assumeType from "../../helpers/assumeType";
import { EmoticonsDropdown } from "../emoticonsDropdown"; import { EmoticonsDropdown } from "../emoticonsDropdown";
import debounce from "../../helpers/schedulers/debounce"; import debounce, { DebounceReturnType } from "../../helpers/schedulers/debounce";
import { SEND_WHEN_ONLINE_TIMESTAMP } from "../../lib/mtproto/constants"; import { SEND_WHEN_ONLINE_TIMESTAMP } from "../../lib/mtproto/constants";
import windowSize from "../../helpers/windowSize"; import windowSize from "../../helpers/windowSize";
import { formatPhoneNumber } from "../../helpers/formatPhoneNumber"; import { formatPhoneNumber } from "../../helpers/formatPhoneNumber";
@ -118,6 +118,7 @@ let queueId = 0;
type GenerateLocalMessageType<IsService> = IsService extends true ? Message.messageService : Message.message; type GenerateLocalMessageType<IsService> = IsService extends true ? Message.messageService : Message.message;
const SPONSORED_MESSAGE_ID_OFFSET = 1; const SPONSORED_MESSAGE_ID_OFFSET = 1;
const STICKY_OFFSET = 3;
export default class ChatBubbles { export default class ChatBubbles {
public bubblesContainer: HTMLDivElement; public bubblesContainer: HTMLDivElement;
@ -205,7 +206,8 @@ export default class ChatBubbles {
private hoverBubble: HTMLElement; private hoverBubble: HTMLElement;
private hoverReaction: HTMLElement; private hoverReaction: HTMLElement;
private sliceViewportDebounced: () => Promise<void>; private sliceViewportDebounced: DebounceReturnType<ChatBubbles['sliceViewport']>;
resizeObserver: ResizeObserver;
// private reactions: Map<number, ReactionsElement>; // private reactions: Map<number, ReactionsElement>;
@ -649,18 +651,37 @@ export default class ChatBubbles {
}); });
} }
if(false) this.stickyIntersector = new StickyIntersector(this.scrollable.container, (stuck, target) => { /* if(false) */this.stickyIntersector = new StickyIntersector(this.scrollable.container, (stuck, target) => {
for(const timestamp in this.dateMessages) { for(const timestamp in this.dateMessages) {
const dateMessage = this.dateMessages[timestamp]; const dateMessage = this.dateMessages[timestamp];
if(dateMessage.container === target) { if(dateMessage.container === target) {
dateMessage.div.classList.toggle('is-sticky', stuck); const dateBubble = dateMessage.div;
// dateMessage.container.classList.add('has-sticky-dates');
// SetTransition(dateBubble, 'kek', stuck, this.previousStickyDate ? 300 : 0);
// if(this.previousStickyDate) {
// dateBubble.classList.add('kek');
// }
dateBubble.classList.toggle('is-sticky', stuck);
if(stuck) {
this.previousStickyDate = dateBubble;
}
break; break;
} }
} }
if(this.previousStickyDate) {
// fastRaf(() => {
// this.bubblesContainer.classList.add('has-sticky-dates');
// });
}
}); });
if(!IS_SAFARI) { if(!IS_SAFARI) {
// this.sliceViewportDebounced = debounce(this.sliceViewport.bind(this), 100, false, true); this.sliceViewportDebounced = debounce(this.sliceViewport.bind(this), 100, false, true);
} }
let middleware: ReturnType<ChatBubbles['getMiddleware']>; let middleware: ReturnType<ChatBubbles['getMiddleware']>;
@ -668,12 +689,20 @@ export default class ChatBubbles {
this.isHeavyAnimationInProgress = true; this.isHeavyAnimationInProgress = true;
this.lazyLoadQueue.lock(); this.lazyLoadQueue.lock();
middleware = this.getMiddleware(); middleware = this.getMiddleware();
// if(this.sliceViewportDebounced) {
// this.sliceViewportDebounced.clearTimeout();
// }
}, () => { }, () => {
this.isHeavyAnimationInProgress = false; this.isHeavyAnimationInProgress = false;
if(middleware && middleware()) { if(middleware && middleware()) {
this.lazyLoadQueue.unlock(); this.lazyLoadQueue.unlock();
this.lazyLoadQueue.refresh(); this.lazyLoadQueue.refresh();
// if(this.sliceViewportDebounced) {
// this.sliceViewportDebounced();
// }
} }
middleware = null; middleware = null;
@ -760,7 +789,7 @@ export default class ChatBubbles {
}); });
if(isScrolledDown) { if(isScrolledDown) {
this.scrollable.scrollTop = 99999; this.scrollable.setScrollTopSilently(99999);
} else { } else {
this.performHistoryResult([], true, false, undefined); this.performHistoryResult([], true, false, undefined);
} }
@ -839,104 +868,124 @@ export default class ChatBubbles {
this.appMessagesManager.incrementMessageViews(this.peerId, mids); this.appMessagesManager.incrementMessageViews(this.peerId, mids);
}, 1000, false, true); }, 1000, false, true);
}
if('ResizeObserver' in window) { private createResizeObserver() {
let wasHeight = this.scrollable.container.offsetHeight; if(!('ResizeObserver' in window) || this.resizeObserver) {
let resizing = false; return;
let skip = false; }
let scrolled = 0;
let part = 0;
let rAF = 0;
const onResizeEnd = () => {
const height = this.scrollable.container.offsetHeight;
const isScrolledDown = this.scrollable.isScrolledDown;
if(height !== wasHeight && (!skip || !isScrolledDown)) { // * fix opening keyboard while ESG is active, offsetHeight will change right between 'start' and this first frame
part += wasHeight - height;
}
/* if(DEBUG) {
this.log('resize end', scrolled, part, this.scrollable.scrollTop, height, wasHeight, this.scrollable.isScrolledDown);
} */
if(part) { const container = this.scrollable.container;
this.scrollable.scrollTop += Math.round(part); let wasHeight = container.offsetHeight;
} let resizing = false;
let skip = false;
let scrolled = 0;
let part = 0;
let rAF = 0;
let skipNext = true;
wasHeight = height; const onResizeEnd = () => {
scrolled = 0; const height = container.offsetHeight;
rAF = 0; const isScrolledDown = this.scrollable.isScrolledDown;
part = 0; if(height !== wasHeight && (!skip || !isScrolledDown)) { // * fix opening keyboard while ESG is active, offsetHeight will change right between 'start' and this first frame
resizing = false; part += wasHeight - height;
skip = false; }
};
const setEndRAF = (single: boolean) => { /* if(DEBUG) {
if(rAF) window.cancelAnimationFrame(rAF); this.log('resize end', scrolled, part, this.scrollable.scrollTop, height, wasHeight, this.scrollable.isScrolledDown);
rAF = window.requestAnimationFrame(single ? onResizeEnd : () => { } */
rAF = window.requestAnimationFrame(onResizeEnd);
//this.log('resize after RAF', part);
});
};
const processEntries = (entries: any) => { if(part) {
if(skip) { this.scrollable.scrollTop += Math.round(part);
setEndRAF(false); }
return;
}
const entry = entries[0]; wasHeight = height;
const height = entry.contentRect.height;/* Math.ceil(entry.contentRect.height); */ scrolled = 0;
rAF = 0;
part = 0;
resizing = false;
skip = false;
};
if(!wasHeight) { const setEndRAF = (single: boolean) => {
wasHeight = height; if(rAF) window.cancelAnimationFrame(rAF);
return; rAF = window.requestAnimationFrame(single ? onResizeEnd : () => {
} rAF = window.requestAnimationFrame(onResizeEnd);
//this.log('resize after RAF', part);
});
};
const realDiff = wasHeight - height; const processEntries: ResizeObserverCallback = (entries) => {
let diff = realDiff + part; if(skipNext) {
const _part = diff % 1; skipNext = false;
diff -= _part; return;
}
if(!resizing) { if(skip) {
resizing = true; setEndRAF(false);
return;
}
/* if(DEBUG) { const entry = entries[0];
this.log('resize start', realDiff, this.scrollable.scrollTop, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown); const height = entry.contentRect.height;/* Math.ceil(entry.contentRect.height); */
} */
if(realDiff < 0 && this.scrollable.isScrolledDown) { if(!wasHeight) {
//if(isSafari) { // * fix opening keyboard while ESG is active wasHeight = height;
part = -realDiff; return;
//} }
skip = true; const realDiff = wasHeight - height;
setEndRAF(false); let diff = realDiff + part;
return; const _part = diff % 1;
} diff -= _part;
}
scrolled += diff; if(!resizing) {
resizing = true;
/* if(DEBUG) { /* if(DEBUG) {
this.log('resize', wasHeight - height, diff, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown, height, wasHeight); this.log('resize start', realDiff, this.scrollable.scrollTop, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown);
} */ } */
if(diff) { if(realDiff < 0 && this.scrollable.isScrolledDown) {
const needScrollTop = this.scrollable.scrollTop + diff; //if(isSafari) { // * fix opening keyboard while ESG is active
this.scrollable.scrollTop = needScrollTop; part = -realDiff;
//}
skip = true;
setEndRAF(false);
return;
} }
}
setEndRAF(false); scrolled += diff;
part = _part; /* if(DEBUG) {
wasHeight = height; this.log('resize', wasHeight - height, diff, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown, height, wasHeight);
}; } */
// @ts-ignore if(diff) {
const resizeObserver = new ResizeObserver(processEntries); const needScrollTop = this.scrollable.scrollTop + diff;
resizeObserver.observe(this.bubblesContainer); this.scrollable.scrollTop = needScrollTop;
}
setEndRAF(false);
part = _part;
wasHeight = height;
};
const resizeObserver = this.resizeObserver = new ResizeObserver(processEntries);
resizeObserver.observe(container);
}
private destroyResizeObserver() {
const resizeObserver = this.resizeObserver;
if(!resizeObserver) {
return;
} }
resizeObserver.disconnect();
this.resizeObserver = undefined;
} }
private onBubblesMouseMove = (e: MouseEvent) => { private onBubblesMouseMove = (e: MouseEvent) => {
@ -1047,7 +1096,7 @@ export default class ChatBubbles {
}; };
public setStickyDateManually() { public setStickyDateManually() {
// return; 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;
@ -1713,7 +1762,13 @@ export default class ChatBubbles {
//return; //return;
// * В таком случае, кнопка не будет моргать если чат в самом низу, и правильно отработает случай написания нового сообщения и проскролла вниз // * В таком случае, кнопка не будет моргать если чат в самом низу, и правильно отработает случай написания нового сообщения и проскролла вниз
if(this.isHeavyAnimationInProgress && this.scrolledDown) return; if(this.isHeavyAnimationInProgress && this.scrolledDown) {
if(this.sliceViewportDebounced) {
this.sliceViewportDebounced.clearTimeout();
}
return;
}
//lottieLoader.checkAnimations(false, 'chat'); //lottieLoader.checkAnimations(false, 'chat');
const distanceToEnd = this.scrollable.getDistanceToEnd(); const distanceToEnd = this.scrollable.getDistanceToEnd();
@ -1821,7 +1876,7 @@ export default class ChatBubbles {
} }
} }
public deleteMessagesByIds(mids: number[], permanent = true) { public deleteMessagesByIds(mids: number[], permanent = true, ignoreOnScroll?: boolean) {
let deleted = false; let deleted = false;
mids.forEach(mid => { mids.forEach(mid => {
if(!(mid in this.bubbles)) return; if(!(mid in this.bubbles)) return;
@ -1868,7 +1923,10 @@ export default class ChatBubbles {
animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP); animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP);
this.deleteEmptyDateGroups(); this.deleteEmptyDateGroups();
this.onScroll();
if(!ignoreOnScroll) {
this.onScroll();
}
} }
public renderNewMessagesByIds(mids: number[], scrolledDown?: boolean) { public renderNewMessagesByIds(mids: number[], scrolledDown?: boolean) {
@ -1928,7 +1986,7 @@ export default class ChatBubbles {
this.scrollable.scrollTop += add; */ this.scrollable.scrollTop += add; */
setPaddingTo = this.chatInner; setPaddingTo = this.chatInner;
setPaddingTo.style.paddingTop = clientHeight + 'px'; setPaddingTo.style.paddingTop = clientHeight + 'px';
this.scrollable.scrollTop = scrollHeight; this.scrollable.setScrollTopSilently(scrollHeight);
this.isTopPaddingSet = true; this.isTopPaddingSet = true;
} }
} }
@ -1982,7 +2040,7 @@ export default class ChatBubbles {
let fallbackToElementStartWhenCentering: HTMLElement; let fallbackToElementStartWhenCentering: HTMLElement;
// * if it's a start, then scroll to start of the group // * if it's a start, then scroll to start of the group
if(bubble && position !== 'end' && whichChild(bubble) === (this.stickyIntersector ? 2 : 1)/* && this.chat.setPeerPromise */) { if(bubble && position !== 'end' && whichChild(bubble) === (this.stickyIntersector ? STICKY_OFFSET : 1)/* && this.chat.setPeerPromise */) {
const dateGroup = bubble.parentElement; const dateGroup = bubble.parentElement;
// if(whichChild(dateGroup) === 0) { // if(whichChild(dateGroup) === 0) {
fallbackToElementStartWhenCentering = dateGroup; fallbackToElementStartWhenCentering = dateGroup;
@ -2002,7 +2060,7 @@ export default class ChatBubbles {
} */ } */
const isChangingHeight = (this.chat.input.messageInput && this.chat.input.messageInput.classList.contains('is-changing-height')) || this.chat.container.classList.contains('is-toggling-helper'); const isChangingHeight = (this.chat.input.messageInput && this.chat.input.messageInput.classList.contains('is-changing-height')) || this.chat.container.classList.contains('is-toggling-helper');
return this.scrollable.scrollIntoViewNew({ const promise = this.scrollable.scrollIntoViewNew({
element, element,
position, position,
margin, margin,
@ -2024,6 +2082,13 @@ export default class ChatBubbles {
} : undefined, } : undefined,
fallbackToElementStartWhenCentering fallbackToElementStartWhenCentering
}); });
// fix flickering date when opening unread chat and focusing message
if(forceDirection === FocusDirection.Static) {
this.scrollable.lastScrollPosition = this.scrollable.scrollTop;
}
return promise;
} }
public scrollToEnd() { public scrollToEnd() {
@ -2080,58 +2145,66 @@ export default class ChatBubbles {
}, 2000); }, 2000);
} }
public getDateContainerByMessage(message: any, reverse: boolean) { private createDateBubble(timestamp: number, date: Date = new Date(timestamp * 1000)) {
const date = new Date(message.date * 1000); let dateElement: HTMLElement;
date.setHours(0, 0, 0);
const dateTimestamp = date.getTime();
if(!this.dateMessages[dateTimestamp]) {
let dateElement: HTMLElement;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const isScheduled = this.chat.type === 'scheduled'; const isScheduled = this.chat.type === 'scheduled';
if(today.getTime() === date.getTime()) { if(today.getTime() === date.getTime()) {
dateElement = i18n(isScheduled ? 'Chat.Date.ScheduledForToday' : 'Date.Today'); dateElement = i18n(isScheduled ? 'Chat.Date.ScheduledForToday' : 'Date.Today');
} else if(isScheduled && message.date === SEND_WHEN_ONLINE_TIMESTAMP) { } else if(isScheduled && timestamp === SEND_WHEN_ONLINE_TIMESTAMP) {
dateElement = i18n('MessageScheduledUntilOnline'); dateElement = i18n('MessageScheduledUntilOnline');
} else { } else {
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
day: 'numeric', day: 'numeric',
month: 'long' month: 'long'
}; };
if(date.getFullYear() !== today.getFullYear()) { if(date.getFullYear() !== today.getFullYear()) {
options.year = 'numeric'; options.year = 'numeric';
} }
dateElement = new I18n.IntlDateElement({ dateElement = new I18n.IntlDateElement({
date, date,
options options
}).element; }).element;
if(isScheduled) { if(isScheduled) {
dateElement = i18n('Chat.Date.ScheduledFor', [dateElement]); dateElement = i18n('Chat.Date.ScheduledFor', [dateElement]);
}
} }
}
const bubble = document.createElement('div'); const bubble = document.createElement('div');
bubble.className = 'bubble service is-date'; bubble.className = 'bubble service is-date';
const bubbleContent = document.createElement('div'); const bubbleContent = document.createElement('div');
bubbleContent.classList.add('bubble-content'); bubbleContent.classList.add('bubble-content');
const serviceMsg = document.createElement('div'); const serviceMsg = document.createElement('div');
serviceMsg.classList.add('service-msg'); serviceMsg.classList.add('service-msg');
serviceMsg.append(dateElement); serviceMsg.append(dateElement);
bubbleContent.append(serviceMsg); bubbleContent.append(serviceMsg);
bubble.append(bubbleContent); bubble.append(bubbleContent);
////////this.log('need to render date message', dateTimestamp, str);
return bubble;
}
public getDateContainerByMessage(message: any, reverse: boolean) {
const date = new Date(message.date * 1000);
date.setHours(0, 0, 0);
const dateTimestamp = date.getTime();
if(!this.dateMessages[dateTimestamp]) {
const bubble = this.createDateBubble(message.date, date);
// bubble.classList.add('is-sticky');
const fakeBubble = this.createDateBubble(message.date, date);
fakeBubble.classList.add('is-fake');
const container = document.createElement('section'); const container = document.createElement('section');
container.className = 'bubbles-date-group'; container.className = 'bubbles-date-group';
container.append(bubble); container.append(bubble, fakeBubble);
this.dateMessages[dateTimestamp] = { this.dateMessages[dateTimestamp] = {
div: bubble, div: bubble,
@ -2239,6 +2312,8 @@ export default class ChatBubbles {
this.viewsMids.clear(); this.viewsMids.clear();
} }
this.destroyResizeObserver();
this.middleware.clean(); this.middleware.clean();
this.onAnimateLadder = undefined; this.onAnimateLadder = undefined;
@ -2258,6 +2333,9 @@ export default class ChatBubbles {
clearTimeout(this.isScrollingTimeout); clearTimeout(this.isScrollingTimeout);
this.isScrollingTimeout = 0; this.isScrollingTimeout = 0;
} }
this.bubblesContainer.classList.remove('has-sticky-dates');
this.scrollable.cancelMeasure();
} }
public setPeer(peerId: PeerId, lastMsgId?: number, startParam?: string): {cached?: boolean, promise: Chat['setPeerPromise']} { public setPeer(peerId: PeerId, lastMsgId?: number, startParam?: string): {cached?: boolean, promise: Chat['setPeerPromise']} {
@ -2331,7 +2409,7 @@ export default class ChatBubbles {
this.chat.dispatchEvent('setPeer', lastMsgId, false); this.chat.dispatchEvent('setPeer', lastMsgId, false);
} else if(topMessage && !isJump) { } else if(topMessage && !isJump) {
//this.log('will scroll down', this.scroll.scrollTop, this.scroll.scrollHeight); //this.log('will scroll down', this.scroll.scrollTop, this.scroll.scrollHeight);
scrollable.scrollTop = scrollable.scrollHeight; scrollable.setScrollTopSilently(scrollable.scrollHeight);
this.chat.dispatchEvent('setPeer', lastMsgId, true); this.chat.dispatchEvent('setPeer', lastMsgId, true);
} }
@ -2423,6 +2501,8 @@ export default class ChatBubbles {
/* this.ladderDeferred && this.ladderDeferred.resolve(); /* this.ladderDeferred && this.ladderDeferred.resolve();
this.ladderDeferred = deferredPromise<void>(); */ this.ladderDeferred = deferredPromise<void>(); */
const middleware = this.getMiddleware();
animationIntersector.lockGroup(CHAT_ANIMATION_GROUP); animationIntersector.lockGroup(CHAT_ANIMATION_GROUP);
const setPeerPromise = promise.then(() => { const setPeerPromise = promise.then(() => {
////this.log('setPeer removing preloader'); ////this.log('setPeer removing preloader');
@ -2431,10 +2511,10 @@ export default class ChatBubbles {
if(!samePeer) { if(!samePeer) {
this.chat.finishPeerChange(isTarget, isJump, lastMsgId, startParam); // * костыль this.chat.finishPeerChange(isTarget, isJump, lastMsgId, startParam); // * костыль
} }
} else {
this.preloader.detach();
} }
this.preloader.detach();
if(this.resolveLadderAnimation) { if(this.resolveLadderAnimation) {
this.resolveLadderAnimation(); this.resolveLadderAnimation();
this.resolveLadderAnimation = undefined; this.resolveLadderAnimation = undefined;
@ -2455,7 +2535,7 @@ export default class ChatBubbles {
//if(dialog && lastMsgID && lastMsgID !== topMessage && (this.bubbles[lastMsgID] || this.firstUnreadBubble)) { //if(dialog && lastMsgID && lastMsgID !== topMessage && (this.bubbles[lastMsgID] || this.firstUnreadBubble)) {
if(savedPosition) { if(savedPosition) {
scrollable.scrollTop = scrollable.lastScrollPosition = savedPosition.top; scrollable.setScrollTopSilently(savedPosition.top);
/* const mountedByLastMsgId = this.getMountedBubble(lastMsgId); /* const mountedByLastMsgId = this.getMountedBubble(lastMsgId);
let bubble: HTMLElement = mountedByLastMsgId?.bubble; let bubble: HTMLElement = mountedByLastMsgId?.bubble;
if(!bubble?.parentElement) { if(!bubble?.parentElement) {
@ -2470,9 +2550,9 @@ export default class ChatBubbles {
} else if((topMessage && isJump) || isTarget) { } else if((topMessage && isJump) || isTarget) {
const fromUp = maxBubbleId > 0 && (maxBubbleId < lastMsgId || lastMsgId < 0); const fromUp = maxBubbleId > 0 && (maxBubbleId < lastMsgId || lastMsgId < 0);
if(!fromUp && samePeer) { if(!fromUp && samePeer) {
scrollable.scrollTop = scrollable.lastScrollPosition = 99999; scrollable.setScrollTopSilently(99999);
} else if(fromUp/* && (samePeer || forwardingUnread) */) { } else if(fromUp/* && (samePeer || forwardingUnread) */) {
scrollable.scrollTop = scrollable.lastScrollPosition = 0; scrollable.setScrollTopSilently(0);
} }
const mountedByLastMsgId = this.getMountedBubble(lastMsgId); const mountedByLastMsgId = this.getMountedBubble(lastMsgId);
@ -2489,7 +2569,7 @@ export default class ChatBubbles {
} }
} }
} else { } else {
scrollable.scrollTop = scrollable.lastScrollPosition = 99999; scrollable.setScrollTopSilently(99999);
} }
this.onScroll(); this.onScroll();
@ -2605,7 +2685,10 @@ export default class ChatBubbles {
//console.timeEnd('appImManager setPeer'); //console.timeEnd('appImManager setPeer');
}).catch(err => { }).catch(err => {
this.log.error('getHistory promise error:', err); this.log.error('getHistory promise error:', err);
this.preloader.detach(); if(!middleware()) {
this.preloader.detach();
}
throw err; throw err;
}); });
@ -2628,6 +2711,8 @@ export default class ChatBubbles {
this.chatInner.classList.toggle('is-chat', this.chat.isAnyGroup()); this.chatInner.classList.toggle('is-chat', this.chat.isAnyGroup());
this.chatInner.classList.toggle('is-channel', isChannel); this.chatInner.classList.toggle('is-channel', isChannel);
this.createResizeObserver();
} }
public renderMessagesQueue(message: any, bubble: HTMLElement, reverse: boolean, promises: Promise<any>[]) { public renderMessagesQueue(message: any, bubble: HTMLElement, reverse: boolean, promises: Promise<any>[]) {
@ -2706,7 +2791,7 @@ export default class ChatBubbles {
const dateMessage = this.getDateContainerByMessage(message, reverse); const dateMessage = this.getDateContainerByMessage(message, reverse);
if(this.chat.type === 'scheduled' || this.chat.type === 'pinned'/* || true */) { // ! TEMP COMMENTED if(this.chat.type === 'scheduled' || this.chat.type === 'pinned'/* || true */) { // ! TEMP COMMENTED
const offset = this.stickyIntersector ? 2 : 1; const offset = this.stickyIntersector ? STICKY_OFFSET : 1;
let children = Array.from(dateMessage.container.children).slice(offset) as HTMLElement[]; let children = Array.from(dateMessage.container.children).slice(offset) as HTMLElement[];
let i = 0, foundMidOnSameTimestamp = 0; let i = 0, foundMidOnSameTimestamp = 0;
for(; i < children.length; ++i) { for(; i < children.length; ++i) {
@ -2735,7 +2820,7 @@ export default class ChatBubbles {
positionElementByIndex(bubble, dateMessage.container, index); positionElementByIndex(bubble, dateMessage.container, index);
} else { } else {
if(reverse) { if(reverse) {
dateMessage.container.insertBefore(bubble, dateMessage.container.children[this.stickyIntersector ? 1 : 0].nextSibling); dateMessage.container.insertBefore(bubble, dateMessage.container.children[this.stickyIntersector ? STICKY_OFFSET - 1 : 0].nextSibling);
} else { } else {
dateMessage.container.append(bubble); dateMessage.container.append(bubble);
} }
@ -3880,14 +3965,18 @@ export default class ChatBubbles {
this.log('performHistoryResult: will render some messages:', history.length, this.isHeavyAnimationInProgress, this.messagesQueuePromise); this.log('performHistoryResult: will render some messages:', history.length, this.isHeavyAnimationInProgress, this.messagesQueuePromise);
} */ } */
let scrollSaver: ScrollSaver/* , viewportSlice: ReturnType<ChatBubbles['getViewportSlice']> */; let scrollSaver: ScrollSaver, hadScroll: boolean/* , viewportSlice: ReturnType<ChatBubbles['getViewportSlice']> */;
this.messagesQueueOnRender = () => { this.messagesQueueOnRender = () => {
scrollSaver = new ScrollSaver(this.scrollable, reverse); scrollSaver = new ScrollSaver(this.scrollable, reverse);
const viewportSlice = this.getViewportSlice(); if(this.getRenderedLength() && !this.chat.setPeerPromise) {
this.deleteViewportSlice(viewportSlice); const viewportSlice = this.getViewportSlice();
this.deleteViewportSlice(viewportSlice);
}
scrollSaver.save(); scrollSaver.save();
const saved = scrollSaver.getSaved();
hadScroll = saved.scrollHeight !== saved.clientHeight;
}; };
if(this.needReflowScroll) { if(this.needReflowScroll) {
@ -3936,6 +4025,20 @@ export default class ChatBubbles {
if(scrollSaver) { if(scrollSaver) {
scrollSaver.restore(history.length === 1 && !reverse ? false : true); scrollSaver.restore(history.length === 1 && !reverse ? false : true);
const state = scrollSaver.getSaved();
if(state.scrollHeight !== state.clientHeight) {
/* for(const timestamp in this.dateMessages) {
const dateMessage = this.dateMessages[timestamp];
dateMessage.div.classList.add('is-sticky');
} */
const middleware = this.getMiddleware();
setTimeout(() => {
if(!middleware()) return;
this.bubblesContainer.classList.add('has-sticky-dates');
}, 600);
}
} }
return true; return true;
@ -4378,6 +4481,7 @@ export default class ChatBubbles {
} }
public getViewportSlice() { public getViewportSlice() {
// this.log.trace('viewport slice');
return getViewportSlice({ return getViewportSlice({
overflowElement: this.scrollable.container, overflowElement: this.scrollable.container,
selector: '.bubbles-date-group .bubble:not(.is-date)', selector: '.bubbles-date-group .bubble:not(.is-date)',
@ -4393,11 +4497,12 @@ export default class ChatBubbles {
if(invisibleBottom.length) this.setLoaded('bottom', false); if(invisibleBottom.length) this.setLoaded('bottom', false);
const mids = invisible.map(({element}) => +element.dataset.mid); const mids = invisible.map(({element}) => +element.dataset.mid);
this.deleteMessagesByIds(mids, false); this.deleteMessagesByIds(mids, false, true);
} }
public sliceViewport() { public sliceViewport(ignoreHeavyAnimation?: boolean) {
if(IS_SAFARI) { // Safari cannot reset the scroll.
if(IS_SAFARI || (this.isHeavyAnimationInProgress && !ignoreHeavyAnimation)/* || true */) {
return; return;
} }
@ -4765,7 +4870,7 @@ export default class ChatBubbles {
} }
public deleteEmptyDateGroups() { public deleteEmptyDateGroups() {
const mustBeCount = 1 + +!!this.stickyIntersector; const mustBeCount = this.stickyIntersector ? STICKY_OFFSET : 1;
let deleted = false; let deleted = false;
for(const i in this.dateMessages) { for(const i in this.dateMessages) {
const dateMessage = this.dateMessages[i]; const dateMessage = this.dateMessages[i];

56
src/components/scrollable.ts

@ -72,7 +72,8 @@ export class ScrollableBase {
public scrollProperty: 'scrollTop' | 'scrollLeft'; public scrollProperty: 'scrollTop' | 'scrollLeft';
private removeHeavyAnimationListener: () => void; protected removeHeavyAnimationListener: () => void;
protected addedScrollListener: boolean;
constructor(public el: HTMLElement, logPrefix = '', public container: HTMLElement = document.createElement('div')) { constructor(public el: HTMLElement, logPrefix = '', public container: HTMLElement = document.createElement('div')) {
this.container.classList.add('scrollable'); this.container.classList.add('scrollable');
@ -87,20 +88,38 @@ export class ScrollableBase {
//this.onScroll(); //this.onScroll();
} }
public addScrollListener() {
if(this.addedScrollListener) {
return;
}
this.addedScrollListener = true;
this.container.addEventListener('scroll', this.onScroll, {passive: true, capture: true});
}
public removeScrollListener() {
if(!this.addedScrollListener) {
return;
}
this.addedScrollListener = false;
this.container.removeEventListener('scroll', this.onScroll, {capture: true});
}
public setListeners() { public setListeners() {
if(this.removeHeavyAnimationListener) { if(this.removeHeavyAnimationListener) {
return; return;
} }
window.addEventListener('resize', this.onScroll, {passive: true}); window.addEventListener('resize', this.onScroll, {passive: true});
this.container.addEventListener('scroll', this.onScroll, {passive: true, capture: true}); this.addScrollListener();
this.removeHeavyAnimationListener = useHeavyAnimationCheck(() => { this.removeHeavyAnimationListener = useHeavyAnimationCheck(() => {
this.isHeavyAnimationInProgress = true; this.isHeavyAnimationInProgress = true;
if(this.onScrollMeasure) { if(this.onScrollMeasure) {
this.cancelMeasure();
this.needCheckAfterAnimation = true; this.needCheckAfterAnimation = true;
window.cancelAnimationFrame(this.onScrollMeasure);
} }
}, () => { }, () => {
this.isHeavyAnimationInProgress = false; this.isHeavyAnimationInProgress = false;
@ -118,9 +137,10 @@ export class ScrollableBase {
} }
window.removeEventListener('resize', this.onScroll); window.removeEventListener('resize', this.onScroll);
this.container.removeEventListener('scroll', this.onScroll, {capture: true}); this.removeScrollListener();
this.removeHeavyAnimationListener(); this.removeHeavyAnimationListener();
this.removeHeavyAnimationListener = undefined;
} }
public append(element: HTMLElement) { public append(element: HTMLElement) {
@ -143,17 +163,15 @@ export class ScrollableBase {
//return; //return;
if(this.isHeavyAnimationInProgress) { if(this.isHeavyAnimationInProgress) {
if(this.onScrollMeasure) { this.cancelMeasure();
window.cancelAnimationFrame(this.onScrollMeasure);
}
this.needCheckAfterAnimation = true; this.needCheckAfterAnimation = true;
return; return;
} }
//if(this.onScrollMeasure || ((this.scrollLocked || (!this.onScrolledTop && !this.onScrolledBottom)) && !this.splitUp && !this.onAdditionalScroll)) return; //if(this.onScrollMeasure || ((this.scrollLocked || (!this.onScrolledTop && !this.onScrolledBottom)) && !this.splitUp && !this.onAdditionalScroll)) return;
if((!this.onScrolledTop && !this.onScrolledBottom) && !this.splitUp && !this.onAdditionalScroll) return; if((!this.onScrolledTop && !this.onScrolledBottom) && !this.splitUp && !this.onAdditionalScroll) return;
if(this.onScrollMeasure) window.cancelAnimationFrame(this.onScrollMeasure); if(this.onScrollMeasure) return;
// if(this.onScrollMeasure) window.cancelAnimationFrame(this.onScrollMeasure);
this.onScrollMeasure = window.requestAnimationFrame(() => { this.onScrollMeasure = window.requestAnimationFrame(() => {
this.onScrollMeasure = 0; this.onScrollMeasure = 0;
@ -170,6 +188,13 @@ export class ScrollableBase {
} }
}); });
}; };
public cancelMeasure() {
if(this.onScrollMeasure) {
window.cancelAnimationFrame(this.onScrollMeasure);
this.onScrollMeasure = 0;
}
}
} }
export type SliceSides = 'top' | 'bottom'; export type SliceSides = 'top' | 'bottom';
@ -253,6 +278,19 @@ export default class Scrollable extends ScrollableBase {
return this.container.scrollTop; return this.container.scrollTop;
} }
public setScrollTopSilently(value: number) {
this.lastScrollPosition = value;
if(this.removeHeavyAnimationListener) {
this.removeScrollListener();
this.container.addEventListener('scroll', (e) => {
cancelEvent(e);
this.addScrollListener();
}, {capture: true, passive: false, once: true});
}
this.scrollTop = value;
}
get scrollHeight() { get scrollHeight() {
return this.container.scrollHeight; return this.container.scrollHeight;
} }

7
src/components/stickyIntersector.ts

@ -41,9 +41,12 @@ export default class StickyIntersector {
private observeElements() { private observeElements() {
this.elementsObserver = new IntersectionObserver((entries) => { this.elementsObserver = new IntersectionObserver((entries) => {
let entry = entries.filter(entry => entry.boundingClientRect.top < 0).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; const entry = entries
.filter(entry => entry.boundingClientRect.top < entry.rootBounds.top)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if(!entry) return; if(!entry) return;
let container = entry.isIntersecting ? entry.target : entry.target.nextElementSibling;
const container = entry.isIntersecting ? entry.target : entry.target.nextElementSibling;
this.handler(true, container as HTMLElement); this.handler(true, container as HTMLElement);
}, {root: this.container}); }, {root: this.container});
} }

8
src/helpers/fastSmoothScroll.ts

@ -7,7 +7,7 @@
// * Jolly Cobra's fastSmoothScroll slightly patched // * Jolly Cobra's fastSmoothScroll slightly patched
import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck';
import { fastRaf } from './schedulers'; import { fastRaf, fastRafPromise } from './schedulers';
import { animateSingle, cancelAnimationByKey } from './animation'; import { animateSingle, cancelAnimationByKey } from './animation';
import rootScope from '../lib/rootScope'; import rootScope from '../lib/rootScope';
import isInDOM from './dom/isInDOM'; import isInDOM from './dom/isInDOM';
@ -67,11 +67,7 @@ export default function fastSmoothScroll(options: ScrollOptions) {
return Promise.resolve(); */ return Promise.resolve(); */
} }
const promise = new Promise<void>((resolve) => { const promise = fastRafPromise().then(() => scrollWithJs(options));
fastRaf(() => {
scrollWithJs(options).then(resolve);
});
});
return options.axis === 'y' ? dispatchHeavyAnimationEvent(promise) : promise; return options.axis === 'y' ? dispatchHeavyAnimationEvent(promise) : promise;
} }

4
src/helpers/schedulers.ts

@ -60,11 +60,11 @@ export function fastRafConventional(callback: NoneToVoidFunction) {
} }
} }
let rafPromise: Promise<DOMHighResTimeStamp>; let rafPromise: Promise<void>;
export function fastRafPromise() { export function fastRafPromise() {
if(rafPromise) return rafPromise; if(rafPromise) return rafPromise;
rafPromise = new Promise(requestAnimationFrame); rafPromise = new Promise<void>((resolve) => fastRaf(() => resolve()));
rafPromise.then(() => { rafPromise.then(() => {
rafPromise = undefined; rafPromise = undefined;
}); });

54
src/helpers/schedulers/debounce.ts

@ -1,18 +1,37 @@
// * Jolly Cobra's schedulers // * Jolly Cobra's schedulers
import ctx from "../../environment/ctx";
import { AnyFunction, Awaited } from "../../types"; import { AnyFunction, Awaited } from "../../types";
import noop from "../noop";
export type DebounceReturnType<F extends AnyFunction> = {
(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>>;
clearTimeout(): void;
};
export default function debounce<F extends AnyFunction>( export default function debounce<F extends AnyFunction>(
fn: F, fn: F,
ms: number, ms: number,
shouldRunFirst = true, shouldRunFirst = true,
shouldRunLast = true, shouldRunLast = true,
) { ): DebounceReturnType<F> {
let waitingTimeout: number; let waitingTimeout: number;
let waitingPromise: Promise<Awaited<ReturnType<F>>>, resolve: (result: any) => void, reject: () => void; let waitingPromise: Promise<Awaited<ReturnType<F>>>, resolve: (result: any) => void, reject: () => void;
let hadNewCall = false; let hadNewCall = false;
return (...args: Parameters<F>): typeof waitingPromise => { const invoke = (args: Parameters<F>) => {
const _resolve = resolve, _reject = reject;
try {
const result = fn.apply(null, args);
_resolve(result);
} catch(err) {
console.error('debounce error', err);
// @ts-ignore
_reject(err);
}
};
const debounce = (...args: Parameters<F>) => {
if(!waitingPromise) waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject)); if(!waitingPromise) waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject));
if(waitingTimeout) { if(waitingTimeout) {
@ -21,23 +40,36 @@ export default function debounce<F extends AnyFunction>(
reject(); reject();
waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject)); waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject));
} else if(shouldRunFirst) { } else if(shouldRunFirst) {
// @ts-ignore invoke(args);
resolve(fn(...args));
hadNewCall = false; hadNewCall = false;
} }
waitingTimeout = setTimeout(() => { const _waitingTimeout = ctx.setTimeout(() => {
// will run if should run last or first but with new call // will run if should run last or first but with new call
if(shouldRunLast && (!shouldRunFirst || hadNewCall)) { if(shouldRunLast && (!shouldRunFirst || hadNewCall)) {
// @ts-ignore invoke(args);
resolve(fn(...args));
} }
waitingTimeout = waitingPromise = resolve = reject = undefined; // if debounce was called during invoking
hadNewCall = false; if(waitingTimeout === _waitingTimeout) {
}, ms) as any; waitingTimeout = waitingPromise = resolve = reject = undefined;
hadNewCall = false;
}
}, ms);
waitingPromise.catch(() => {}); waitingTimeout = _waitingTimeout;
waitingPromise.catch(noop);
return waitingPromise; return waitingPromise;
}; };
debounce.clearTimeout = () => {
if(waitingTimeout) {
ctx.clearTimeout(waitingTimeout);
reject();
waitingTimeout = waitingPromise = resolve = reject = undefined;
hadNewCall = false;
}
};
return debounce;
} }

113
src/helpers/scrollSaver.ts

@ -9,8 +9,10 @@ import { IS_SAFARI } from "../environment/userAgent";
import reflowScrollableElement from "./dom/reflowScrollableElement"; import reflowScrollableElement from "./dom/reflowScrollableElement";
export default class ScrollSaver { export default class ScrollSaver {
private previousScrollHeight: number; private scrollHeight: number;
private previousScrollHeightMinusTop: number/* , previousScrollHeight: number */; private scrollHeightMinusTop: number;
private scrollTop: number;
private clientHeight: number;
/** /**
* *
@ -28,13 +30,23 @@ export default class ScrollSaver {
return this.scrollable.container; return this.scrollable.container;
} }
public getSaved() {
return {
scrollHeight: this.scrollHeight,
scrollTop: this.scrollTop,
clientHeight: this.clientHeight
};
}
public save() { public save() {
const {scrollTop, scrollHeight} = this.container; const {scrollTop, scrollHeight, clientHeight} = this.container;
//previousScrollHeight = scrollHeight; //previousScrollHeight = scrollHeight;
//previousScrollHeight = scrollHeight + padding; //previousScrollHeight = scrollHeight + padding;
this.previousScrollHeight = scrollHeight; this.scrollHeight = scrollHeight;
this.previousScrollHeightMinusTop = this.reverse ? scrollHeight - scrollTop : scrollTop; this.scrollTop = scrollTop;
this.clientHeight = clientHeight;
this.scrollHeightMinusTop = this.reverse ? scrollHeight - scrollTop : scrollTop;
//this.chatInner.style.paddingTop = padding + 'px'; //this.chatInner.style.paddingTop = padding + 'px';
/* if(reverse) { /* if(reverse) {
@ -49,50 +61,53 @@ export default class ScrollSaver {
} }
public restore(useReflow?: boolean) { public restore(useReflow?: boolean) {
const {container, previousScrollHeightMinusTop, scrollable} = this; const {container, scrollHeightMinusTop: previousScrollHeightMinusTop, scrollable} = this;
if(previousScrollHeightMinusTop !== undefined) { if(previousScrollHeightMinusTop === undefined) {
const scrollHeight = container.scrollHeight; throw new Error('scroll was not saved');
if(scrollHeight === this.previousScrollHeight) {
return;
}
/* const scrollHeight = container.scrollHeight;
const addedHeight = scrollHeight - previousScrollHeight;
this.chatInner.style.paddingTop = (10000 - addedHeight) + 'px'; */
/* const scrollHeight = scrollHeight;
const addedHeight = scrollHeight - previousScrollHeight;
this.chatInner.style.paddingTop = (padding - addedHeight) + 'px';
//const newScrollTop = reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
const newScrollTop = reverse ? scrollHeight - addedHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
this.log('performHistoryResult: will set scrollTop',
previousScrollHeightMinusTop, scrollHeight,
newScrollTop, container.container.clientHeight); */
//const newScrollTop = reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
const newScrollTop = this.reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
/* if(DEBUG) {
this.log('performHistoryResult: will set up scrollTop:', newScrollTop, this.isHeavyAnimationInProgress);
} */
// touchSupport for safari iOS
//isTouchSupported && isApple && (container.container.style.overflow = 'hidden');
container.scrollTop = newScrollTop;
//container.scrollTop = scrollHeight;
//isTouchSupported && isApple && (container.container.style.overflow = '');
scrollable.lastScrollPosition = newScrollTop;
// scrollable.lastScrollDirection = 0;
if(IS_SAFARI && useReflow/* && !isAppleMobile */) { // * fix blinking and jumping
reflowScrollableElement(container);
}
/* if(DEBUG) {
this.log('performHistoryResult: have set up scrollTop:', newScrollTop, container.scrollTop, container.scrollHeight, this.isHeavyAnimationInProgress);
} */
} }
const scrollHeight = container.scrollHeight;
if(scrollHeight === this.scrollHeight) {
return;
}
this.scrollHeight = scrollHeight;
/* const scrollHeight = container.scrollHeight;
const addedHeight = scrollHeight - previousScrollHeight;
this.chatInner.style.paddingTop = (10000 - addedHeight) + 'px'; */
/* const scrollHeight = scrollHeight;
const addedHeight = scrollHeight - previousScrollHeight;
this.chatInner.style.paddingTop = (padding - addedHeight) + 'px';
//const newScrollTop = reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
const newScrollTop = reverse ? scrollHeight - addedHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
this.log('performHistoryResult: will set scrollTop',
previousScrollHeightMinusTop, scrollHeight,
newScrollTop, container.container.clientHeight); */
//const newScrollTop = reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
const newScrollTop = this.reverse ? scrollHeight - previousScrollHeightMinusTop : previousScrollHeightMinusTop;
/* if(DEBUG) {
this.log('performHistoryResult: will set up scrollTop:', newScrollTop, this.isHeavyAnimationInProgress);
} */
// touchSupport for safari iOS
//isTouchSupported && isApple && (container.container.style.overflow = 'hidden');
this.scrollable.setScrollTopSilently(this.scrollTop = newScrollTop);
//container.scrollTop = scrollHeight;
//isTouchSupported && isApple && (container.container.style.overflow = '');
if(IS_SAFARI && useReflow/* && !isAppleMobile */) { // * fix blinking and jumping
reflowScrollableElement(container);
}
/* if(DEBUG) {
this.log('performHistoryResult: have set up scrollTop:', newScrollTop, container.scrollTop, container.scrollHeight, this.isHeavyAnimationInProgress);
} */
return;
} }
} }

11
src/lib/appManagers/appImManager.ts

@ -1135,15 +1135,12 @@ export class AppImManager {
//if(bubble) { //if(bubble) {
//const top = bubble.getBoundingClientRect().top; //const top = bubble.getBoundingClientRect().top;
const chatBubbles = chat.bubbles; const chatBubbles = chat.bubbles;
chatBubbles.sliceViewport();
const top = chatBubbles.scrollable.scrollTop;
const key = chat.peerId + (chat.threadId ? '_' + chat.threadId : ''); const key = chat.peerId + (chat.threadId ? '_' + chat.threadId : '');
const chatPositions = stateStorage.getFromCache('chatPositions'); const chatPositions = stateStorage.getFromCache('chatPositions');
if(!(chatBubbles.scrollable.getDistanceToEnd() <= 16 && chatBubbles.scrollable.loadedAll.bottom) && Object.keys(chatBubbles.bubbles).length) { if(!(chatBubbles.scrollable.getDistanceToEnd() <= 16 && chatBubbles.scrollable.loadedAll.bottom) && chatBubbles.getRenderedLength()) {
chatBubbles.sliceViewport(true);
const top = chatBubbles.scrollable.scrollTop;
const position = { const position = {
mids: getObjectKeysAndSort(chatBubbles.bubbles, 'desc'), mids: getObjectKeysAndSort(chatBubbles.bubbles, 'desc'),
top top

18
src/scss/partials/_chatBubble.scss

@ -399,6 +399,24 @@ $bubble-beside-button-width: 38px;
cursor: pointer; cursor: pointer;
pointer-events: all; pointer-events: all;
} }
.bubbles:not(.has-sticky-dates) & {
visibility: hidden;
}
.bubbles.has-sticky-dates &.is-fake {
display: none;
}
&.is-fake {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
opacity: 1 !important;
transition: none !important;
visibility: visible !important;
}
} }
&-beside-button { &-beside-button {

Loading…
Cancel
Save