From 51fed911034dfed905a95ed9935a42c16553d783 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Sat, 2 Apr 2022 16:38:20 +0300 Subject: [PATCH] Fix wrong scroll position when switching chats Fix detecting sticky dates --- src/components/chat/bubbles.ts | 407 +++++++++++++++++----------- src/components/scrollable.ts | 56 +++- src/components/stickyIntersector.ts | 7 +- src/helpers/fastSmoothScroll.ts | 8 +- src/helpers/schedulers.ts | 4 +- src/helpers/schedulers/debounce.ts | 54 +++- src/helpers/scrollSaver.ts | 113 ++++---- src/lib/appManagers/appImManager.ts | 11 +- src/scss/partials/_chatBubble.scss | 18 ++ 9 files changed, 441 insertions(+), 237 deletions(-) diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 55956567..a6f8d3a2 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -68,7 +68,7 @@ import whichChild from "../../helpers/dom/whichChild"; import { cancelAnimationByKey } from "../../helpers/animation"; import assumeType from "../../helpers/assumeType"; 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 windowSize from "../../helpers/windowSize"; import { formatPhoneNumber } from "../../helpers/formatPhoneNumber"; @@ -118,6 +118,7 @@ let queueId = 0; type GenerateLocalMessageType = IsService extends true ? Message.messageService : Message.message; const SPONSORED_MESSAGE_ID_OFFSET = 1; +const STICKY_OFFSET = 3; export default class ChatBubbles { public bubblesContainer: HTMLDivElement; @@ -205,7 +206,8 @@ export default class ChatBubbles { private hoverBubble: HTMLElement; private hoverReaction: HTMLElement; - private sliceViewportDebounced: () => Promise; + private sliceViewportDebounced: DebounceReturnType; + resizeObserver: ResizeObserver; // private reactions: Map; @@ -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) { const dateMessage = this.dateMessages[timestamp]; 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; } } + + if(this.previousStickyDate) { + // fastRaf(() => { + // this.bubblesContainer.classList.add('has-sticky-dates'); + // }); + } }); 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; @@ -668,12 +689,20 @@ export default class ChatBubbles { this.isHeavyAnimationInProgress = true; this.lazyLoadQueue.lock(); middleware = this.getMiddleware(); + + // if(this.sliceViewportDebounced) { + // this.sliceViewportDebounced.clearTimeout(); + // } }, () => { this.isHeavyAnimationInProgress = false; if(middleware && middleware()) { this.lazyLoadQueue.unlock(); this.lazyLoadQueue.refresh(); + + // if(this.sliceViewportDebounced) { + // this.sliceViewportDebounced(); + // } } middleware = null; @@ -760,7 +789,7 @@ export default class ChatBubbles { }); if(isScrolledDown) { - this.scrollable.scrollTop = 99999; + this.scrollable.setScrollTopSilently(99999); } else { this.performHistoryResult([], true, false, undefined); } @@ -839,104 +868,124 @@ export default class ChatBubbles { this.appMessagesManager.incrementMessageViews(this.peerId, mids); }, 1000, false, true); + } - if('ResizeObserver' in window) { - let wasHeight = this.scrollable.container.offsetHeight; - let resizing = false; - 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; - } + private createResizeObserver() { + if(!('ResizeObserver' in window) || this.resizeObserver) { + return; + } - /* if(DEBUG) { - this.log('resize end', scrolled, part, this.scrollable.scrollTop, height, wasHeight, this.scrollable.isScrolledDown); - } */ + const container = this.scrollable.container; + let wasHeight = container.offsetHeight; + let resizing = false; + let skip = false; + let scrolled = 0; + let part = 0; + let rAF = 0; + let skipNext = true; - if(part) { - this.scrollable.scrollTop += Math.round(part); - } + const onResizeEnd = () => { + const height = 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; + } - wasHeight = height; - scrolled = 0; - rAF = 0; - part = 0; - resizing = false; - skip = false; - }; + /* if(DEBUG) { + this.log('resize end', scrolled, part, this.scrollable.scrollTop, height, wasHeight, this.scrollable.isScrolledDown); + } */ - const setEndRAF = (single: boolean) => { - if(rAF) window.cancelAnimationFrame(rAF); - rAF = window.requestAnimationFrame(single ? onResizeEnd : () => { - rAF = window.requestAnimationFrame(onResizeEnd); - //this.log('resize after RAF', part); - }); - }; + if(part) { + this.scrollable.scrollTop += Math.round(part); + } - const processEntries = (entries: any) => { - if(skip) { - setEndRAF(false); - return; - } + wasHeight = height; + scrolled = 0; + rAF = 0; + part = 0; + resizing = false; + skip = false; + }; - const entry = entries[0]; - const height = entry.contentRect.height;/* Math.ceil(entry.contentRect.height); */ - - if(!wasHeight) { - wasHeight = height; - return; - } + const setEndRAF = (single: boolean) => { + if(rAF) window.cancelAnimationFrame(rAF); + rAF = window.requestAnimationFrame(single ? onResizeEnd : () => { + rAF = window.requestAnimationFrame(onResizeEnd); + //this.log('resize after RAF', part); + }); + }; - const realDiff = wasHeight - height; - let diff = realDiff + part; - const _part = diff % 1; - diff -= _part; - - if(!resizing) { - resizing = true; + const processEntries: ResizeObserverCallback = (entries) => { + if(skipNext) { + skipNext = false; + return; + } - /* if(DEBUG) { - this.log('resize start', realDiff, this.scrollable.scrollTop, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown); - } */ + if(skip) { + setEndRAF(false); + return; + } - if(realDiff < 0 && this.scrollable.isScrolledDown) { - //if(isSafari) { // * fix opening keyboard while ESG is active - part = -realDiff; - //} + const entry = entries[0]; + const height = entry.contentRect.height;/* Math.ceil(entry.contentRect.height); */ + + if(!wasHeight) { + wasHeight = height; + return; + } - skip = true; - setEndRAF(false); - return; - } - } + const realDiff = wasHeight - height; + let diff = realDiff + part; + const _part = diff % 1; + diff -= _part; - scrolled += diff; + if(!resizing) { + resizing = true; /* 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) { - const needScrollTop = this.scrollable.scrollTop + diff; - this.scrollable.scrollTop = needScrollTop; + if(realDiff < 0 && this.scrollable.isScrolledDown) { + //if(isSafari) { // * fix opening keyboard while ESG is active + part = -realDiff; + //} + + skip = true; + setEndRAF(false); + return; } - - setEndRAF(false); + } - part = _part; - wasHeight = height; - }; + scrolled += diff; - // @ts-ignore - const resizeObserver = new ResizeObserver(processEntries); - resizeObserver.observe(this.bubblesContainer); + /* if(DEBUG) { + this.log('resize', wasHeight - height, diff, this.scrollable.container.offsetHeight, this.scrollable.isScrolledDown, height, wasHeight); + } */ + + if(diff) { + const needScrollTop = this.scrollable.scrollTop + diff; + 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) => { @@ -1047,7 +1096,7 @@ export default class ChatBubbles { }; public setStickyDateManually() { - // return; + return; const timestamps = Object.keys(this.dateMessages).map(k => +k).sort((a, b) => b - a); let lastVisible: HTMLElement; @@ -1713,7 +1762,13 @@ export default class ChatBubbles { //return; // * В таком случае, кнопка не будет моргать если чат в самом низу, и правильно отработает случай написания нового сообщения и проскролла вниз - if(this.isHeavyAnimationInProgress && this.scrolledDown) return; + if(this.isHeavyAnimationInProgress && this.scrolledDown) { + if(this.sliceViewportDebounced) { + this.sliceViewportDebounced.clearTimeout(); + } + + return; + } //lottieLoader.checkAnimations(false, 'chat'); 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; mids.forEach(mid => { if(!(mid in this.bubbles)) return; @@ -1868,7 +1923,10 @@ export default class ChatBubbles { animationIntersector.checkAnimations(false, CHAT_ANIMATION_GROUP); this.deleteEmptyDateGroups(); - this.onScroll(); + + if(!ignoreOnScroll) { + this.onScroll(); + } } public renderNewMessagesByIds(mids: number[], scrolledDown?: boolean) { @@ -1928,7 +1986,7 @@ export default class ChatBubbles { this.scrollable.scrollTop += add; */ setPaddingTo = this.chatInner; setPaddingTo.style.paddingTop = clientHeight + 'px'; - this.scrollable.scrollTop = scrollHeight; + this.scrollable.setScrollTopSilently(scrollHeight); this.isTopPaddingSet = true; } } @@ -1982,7 +2040,7 @@ export default class ChatBubbles { let fallbackToElementStartWhenCentering: HTMLElement; // * 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; // if(whichChild(dateGroup) === 0) { 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'); - return this.scrollable.scrollIntoViewNew({ + const promise = this.scrollable.scrollIntoViewNew({ element, position, margin, @@ -2024,6 +2082,13 @@ export default class ChatBubbles { } : undefined, 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() { @@ -2080,58 +2145,66 @@ export default class ChatBubbles { }, 2000); } - 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]) { - let dateElement: HTMLElement; + private createDateBubble(timestamp: number, date: Date = new Date(timestamp * 1000)) { + let dateElement: HTMLElement; - const today = new Date(); - today.setHours(0, 0, 0, 0); + const today = new Date(); + today.setHours(0, 0, 0, 0); - const isScheduled = this.chat.type === 'scheduled'; - - if(today.getTime() === date.getTime()) { - dateElement = i18n(isScheduled ? 'Chat.Date.ScheduledForToday' : 'Date.Today'); - } else if(isScheduled && message.date === SEND_WHEN_ONLINE_TIMESTAMP) { - dateElement = i18n('MessageScheduledUntilOnline'); - } else { - const options: Intl.DateTimeFormatOptions = { - day: 'numeric', - month: 'long' - }; + const isScheduled = this.chat.type === 'scheduled'; + + if(today.getTime() === date.getTime()) { + dateElement = i18n(isScheduled ? 'Chat.Date.ScheduledForToday' : 'Date.Today'); + } else if(isScheduled && timestamp === SEND_WHEN_ONLINE_TIMESTAMP) { + dateElement = i18n('MessageScheduledUntilOnline'); + } else { + const options: Intl.DateTimeFormatOptions = { + day: 'numeric', + month: 'long' + }; - if(date.getFullYear() !== today.getFullYear()) { - options.year = 'numeric'; - } + if(date.getFullYear() !== today.getFullYear()) { + options.year = 'numeric'; + } - dateElement = new I18n.IntlDateElement({ - date, - options - }).element; + dateElement = new I18n.IntlDateElement({ + date, + options + }).element; - if(isScheduled) { - dateElement = i18n('Chat.Date.ScheduledFor', [dateElement]); - } + if(isScheduled) { + dateElement = i18n('Chat.Date.ScheduledFor', [dateElement]); } - - const bubble = document.createElement('div'); - bubble.className = 'bubble service is-date'; - const bubbleContent = document.createElement('div'); - bubbleContent.classList.add('bubble-content'); - const serviceMsg = document.createElement('div'); - serviceMsg.classList.add('service-msg'); + } + + const bubble = document.createElement('div'); + bubble.className = 'bubble service is-date'; + const bubbleContent = document.createElement('div'); + bubbleContent.classList.add('bubble-content'); + const serviceMsg = document.createElement('div'); + serviceMsg.classList.add('service-msg'); + + serviceMsg.append(dateElement); + + bubbleContent.append(serviceMsg); + bubble.append(bubbleContent); - serviceMsg.append(dateElement); + return bubble; + } - bubbleContent.append(serviceMsg); - bubble.append(bubbleContent); - ////////this.log('need to render date message', dateTimestamp, str); + 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'); container.className = 'bubbles-date-group'; - container.append(bubble); + container.append(bubble, fakeBubble); this.dateMessages[dateTimestamp] = { div: bubble, @@ -2238,6 +2311,8 @@ export default class ChatBubbles { this.viewsObserver.disconnect(); this.viewsMids.clear(); } + + this.destroyResizeObserver(); this.middleware.clean(); @@ -2258,6 +2333,9 @@ export default class ChatBubbles { clearTimeout(this.isScrollingTimeout); 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']} { @@ -2331,7 +2409,7 @@ export default class ChatBubbles { this.chat.dispatchEvent('setPeer', lastMsgId, false); } else if(topMessage && !isJump) { //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); } @@ -2422,6 +2500,8 @@ export default class ChatBubbles { //console.timeEnd('appImManager setPeer pre promise'); /* this.ladderDeferred && this.ladderDeferred.resolve(); this.ladderDeferred = deferredPromise(); */ + + const middleware = this.getMiddleware(); animationIntersector.lockGroup(CHAT_ANIMATION_GROUP); const setPeerPromise = promise.then(() => { @@ -2431,10 +2511,10 @@ export default class ChatBubbles { if(!samePeer) { this.chat.finishPeerChange(isTarget, isJump, lastMsgId, startParam); // * костыль } - } else { - this.preloader.detach(); } + this.preloader.detach(); + if(this.resolveLadderAnimation) { this.resolveLadderAnimation(); this.resolveLadderAnimation = undefined; @@ -2455,7 +2535,7 @@ export default class ChatBubbles { //if(dialog && lastMsgID && lastMsgID !== topMessage && (this.bubbles[lastMsgID] || this.firstUnreadBubble)) { if(savedPosition) { - scrollable.scrollTop = scrollable.lastScrollPosition = savedPosition.top; + scrollable.setScrollTopSilently(savedPosition.top); /* const mountedByLastMsgId = this.getMountedBubble(lastMsgId); let bubble: HTMLElement = mountedByLastMsgId?.bubble; if(!bubble?.parentElement) { @@ -2470,9 +2550,9 @@ export default class ChatBubbles { } else if((topMessage && isJump) || isTarget) { const fromUp = maxBubbleId > 0 && (maxBubbleId < lastMsgId || lastMsgId < 0); if(!fromUp && samePeer) { - scrollable.scrollTop = scrollable.lastScrollPosition = 99999; + scrollable.setScrollTopSilently(99999); } else if(fromUp/* && (samePeer || forwardingUnread) */) { - scrollable.scrollTop = scrollable.lastScrollPosition = 0; + scrollable.setScrollTopSilently(0); } const mountedByLastMsgId = this.getMountedBubble(lastMsgId); @@ -2489,7 +2569,7 @@ export default class ChatBubbles { } } } else { - scrollable.scrollTop = scrollable.lastScrollPosition = 99999; + scrollable.setScrollTopSilently(99999); } this.onScroll(); @@ -2605,7 +2685,10 @@ export default class ChatBubbles { //console.timeEnd('appImManager setPeer'); }).catch(err => { this.log.error('getHistory promise error:', err); - this.preloader.detach(); + if(!middleware()) { + this.preloader.detach(); + } + throw err; }); @@ -2628,6 +2711,8 @@ export default class ChatBubbles { this.chatInner.classList.toggle('is-chat', this.chat.isAnyGroup()); this.chatInner.classList.toggle('is-channel', isChannel); + + this.createResizeObserver(); } public renderMessagesQueue(message: any, bubble: HTMLElement, reverse: boolean, promises: Promise[]) { @@ -2706,7 +2791,7 @@ export default class ChatBubbles { const dateMessage = this.getDateContainerByMessage(message, reverse); 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 i = 0, foundMidOnSameTimestamp = 0; for(; i < children.length; ++i) { @@ -2735,7 +2820,7 @@ export default class ChatBubbles { positionElementByIndex(bubble, dateMessage.container, index); } else { 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 { 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); } */ - let scrollSaver: ScrollSaver/* , viewportSlice: ReturnType */; + let scrollSaver: ScrollSaver, hadScroll: boolean/* , viewportSlice: ReturnType */; this.messagesQueueOnRender = () => { scrollSaver = new ScrollSaver(this.scrollable, reverse); - - const viewportSlice = this.getViewportSlice(); - this.deleteViewportSlice(viewportSlice); - + + if(this.getRenderedLength() && !this.chat.setPeerPromise) { + const viewportSlice = this.getViewportSlice(); + this.deleteViewportSlice(viewportSlice); + } + scrollSaver.save(); + const saved = scrollSaver.getSaved(); + hadScroll = saved.scrollHeight !== saved.clientHeight; }; if(this.needReflowScroll) { @@ -3936,6 +4025,20 @@ export default class ChatBubbles { if(scrollSaver) { 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; @@ -4378,6 +4481,7 @@ export default class ChatBubbles { } public getViewportSlice() { + // this.log.trace('viewport slice'); return getViewportSlice({ overflowElement: this.scrollable.container, selector: '.bubbles-date-group .bubble:not(.is-date)', @@ -4393,11 +4497,12 @@ export default class ChatBubbles { if(invisibleBottom.length) this.setLoaded('bottom', false); const mids = invisible.map(({element}) => +element.dataset.mid); - this.deleteMessagesByIds(mids, false); + this.deleteMessagesByIds(mids, false, true); } - public sliceViewport() { - if(IS_SAFARI) { + public sliceViewport(ignoreHeavyAnimation?: boolean) { + // Safari cannot reset the scroll. + if(IS_SAFARI || (this.isHeavyAnimationInProgress && !ignoreHeavyAnimation)/* || true */) { return; } @@ -4765,7 +4870,7 @@ export default class ChatBubbles { } public deleteEmptyDateGroups() { - const mustBeCount = 1 + +!!this.stickyIntersector; + const mustBeCount = this.stickyIntersector ? STICKY_OFFSET : 1; let deleted = false; for(const i in this.dateMessages) { const dateMessage = this.dateMessages[i]; diff --git a/src/components/scrollable.ts b/src/components/scrollable.ts index 551cfbd7..6694cf65 100644 --- a/src/components/scrollable.ts +++ b/src/components/scrollable.ts @@ -72,7 +72,8 @@ export class ScrollableBase { public scrollProperty: 'scrollTop' | 'scrollLeft'; - private removeHeavyAnimationListener: () => void; + protected removeHeavyAnimationListener: () => void; + protected addedScrollListener: boolean; constructor(public el: HTMLElement, logPrefix = '', public container: HTMLElement = document.createElement('div')) { this.container.classList.add('scrollable'); @@ -87,20 +88,38 @@ export class ScrollableBase { //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() { if(this.removeHeavyAnimationListener) { return; } window.addEventListener('resize', this.onScroll, {passive: true}); - this.container.addEventListener('scroll', this.onScroll, {passive: true, capture: true}); + this.addScrollListener(); this.removeHeavyAnimationListener = useHeavyAnimationCheck(() => { this.isHeavyAnimationInProgress = true; if(this.onScrollMeasure) { + this.cancelMeasure(); this.needCheckAfterAnimation = true; - window.cancelAnimationFrame(this.onScrollMeasure); } }, () => { this.isHeavyAnimationInProgress = false; @@ -118,9 +137,10 @@ export class ScrollableBase { } window.removeEventListener('resize', this.onScroll); - this.container.removeEventListener('scroll', this.onScroll, {capture: true}); + this.removeScrollListener(); this.removeHeavyAnimationListener(); + this.removeHeavyAnimationListener = undefined; } public append(element: HTMLElement) { @@ -143,17 +163,15 @@ export class ScrollableBase { //return; if(this.isHeavyAnimationInProgress) { - if(this.onScrollMeasure) { - window.cancelAnimationFrame(this.onScrollMeasure); - } - + this.cancelMeasure(); this.needCheckAfterAnimation = true; 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.onScrollMeasure) window.cancelAnimationFrame(this.onScrollMeasure); + if(this.onScrollMeasure) return; + // if(this.onScrollMeasure) window.cancelAnimationFrame(this.onScrollMeasure); this.onScrollMeasure = window.requestAnimationFrame(() => { 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'; @@ -252,6 +277,19 @@ export default class Scrollable extends ScrollableBase { //this.log.trace('get 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() { return this.container.scrollHeight; diff --git a/src/components/stickyIntersector.ts b/src/components/stickyIntersector.ts index ad94ea26..aefa16ce 100644 --- a/src/components/stickyIntersector.ts +++ b/src/components/stickyIntersector.ts @@ -41,9 +41,12 @@ export default class StickyIntersector { private observeElements() { 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; - let container = entry.isIntersecting ? entry.target : entry.target.nextElementSibling; + + const container = entry.isIntersecting ? entry.target : entry.target.nextElementSibling; this.handler(true, container as HTMLElement); }, {root: this.container}); } diff --git a/src/helpers/fastSmoothScroll.ts b/src/helpers/fastSmoothScroll.ts index 3eb5082a..629c30eb 100644 --- a/src/helpers/fastSmoothScroll.ts +++ b/src/helpers/fastSmoothScroll.ts @@ -7,7 +7,7 @@ // * Jolly Cobra's fastSmoothScroll slightly patched import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck'; -import { fastRaf } from './schedulers'; +import { fastRaf, fastRafPromise } from './schedulers'; import { animateSingle, cancelAnimationByKey } from './animation'; import rootScope from '../lib/rootScope'; import isInDOM from './dom/isInDOM'; @@ -67,11 +67,7 @@ export default function fastSmoothScroll(options: ScrollOptions) { return Promise.resolve(); */ } - const promise = new Promise((resolve) => { - fastRaf(() => { - scrollWithJs(options).then(resolve); - }); - }); + const promise = fastRafPromise().then(() => scrollWithJs(options)); return options.axis === 'y' ? dispatchHeavyAnimationEvent(promise) : promise; } diff --git a/src/helpers/schedulers.ts b/src/helpers/schedulers.ts index 8d5e2c9f..bab65d35 100644 --- a/src/helpers/schedulers.ts +++ b/src/helpers/schedulers.ts @@ -60,11 +60,11 @@ export function fastRafConventional(callback: NoneToVoidFunction) { } } -let rafPromise: Promise; +let rafPromise: Promise; export function fastRafPromise() { if(rafPromise) return rafPromise; - rafPromise = new Promise(requestAnimationFrame); + rafPromise = new Promise((resolve) => fastRaf(() => resolve())); rafPromise.then(() => { rafPromise = undefined; }); diff --git a/src/helpers/schedulers/debounce.ts b/src/helpers/schedulers/debounce.ts index 7428727e..c812b21f 100644 --- a/src/helpers/schedulers/debounce.ts +++ b/src/helpers/schedulers/debounce.ts @@ -1,18 +1,37 @@ // * Jolly Cobra's schedulers +import ctx from "../../environment/ctx"; import { AnyFunction, Awaited } from "../../types"; +import noop from "../noop"; + +export type DebounceReturnType = { + (...args: Parameters): Promise>>; + clearTimeout(): void; +}; export default function debounce( fn: F, ms: number, shouldRunFirst = true, shouldRunLast = true, -) { +): DebounceReturnType { let waitingTimeout: number; let waitingPromise: Promise>>, resolve: (result: any) => void, reject: () => void; let hadNewCall = false; - return (...args: Parameters): typeof waitingPromise => { + const invoke = (args: Parameters) => { + 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) => { if(!waitingPromise) waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject)); if(waitingTimeout) { @@ -21,23 +40,36 @@ export default function debounce( reject(); waitingPromise = new Promise((_resolve, _reject) => (resolve = _resolve, reject = _reject)); } else if(shouldRunFirst) { - // @ts-ignore - resolve(fn(...args)); + invoke(args); hadNewCall = false; } - waitingTimeout = setTimeout(() => { + const _waitingTimeout = ctx.setTimeout(() => { // will run if should run last or first but with new call if(shouldRunLast && (!shouldRunFirst || hadNewCall)) { - // @ts-ignore - resolve(fn(...args)); + invoke(args); } + + // if debounce was called during invoking + if(waitingTimeout === _waitingTimeout) { + waitingTimeout = waitingPromise = resolve = reject = undefined; + hadNewCall = false; + } + }, ms); + + waitingTimeout = _waitingTimeout; + waitingPromise.catch(noop); + return waitingPromise; + }; + debounce.clearTimeout = () => { + if(waitingTimeout) { + ctx.clearTimeout(waitingTimeout); + reject(); waitingTimeout = waitingPromise = resolve = reject = undefined; hadNewCall = false; - }, ms) as any; - - waitingPromise.catch(() => {}); - return waitingPromise; + } }; + + return debounce; } diff --git a/src/helpers/scrollSaver.ts b/src/helpers/scrollSaver.ts index f7d500ad..6dbee082 100644 --- a/src/helpers/scrollSaver.ts +++ b/src/helpers/scrollSaver.ts @@ -9,8 +9,10 @@ import { IS_SAFARI } from "../environment/userAgent"; import reflowScrollableElement from "./dom/reflowScrollableElement"; export default class ScrollSaver { - private previousScrollHeight: number; - private previousScrollHeightMinusTop: number/* , previousScrollHeight: number */; + private scrollHeight: number; + private scrollHeightMinusTop: number; + private scrollTop: number; + private clientHeight: number; /** * @@ -28,13 +30,23 @@ export default class ScrollSaver { return this.scrollable.container; } + public getSaved() { + return { + scrollHeight: this.scrollHeight, + scrollTop: this.scrollTop, + clientHeight: this.clientHeight + }; + } + public save() { - const {scrollTop, scrollHeight} = this.container; + const {scrollTop, scrollHeight, clientHeight} = this.container; //previousScrollHeight = scrollHeight; //previousScrollHeight = scrollHeight + padding; - this.previousScrollHeight = scrollHeight; - this.previousScrollHeightMinusTop = this.reverse ? scrollHeight - scrollTop : scrollTop; + this.scrollHeight = scrollHeight; + this.scrollTop = scrollTop; + this.clientHeight = clientHeight; + this.scrollHeightMinusTop = this.reverse ? scrollHeight - scrollTop : scrollTop; //this.chatInner.style.paddingTop = padding + 'px'; /* if(reverse) { @@ -49,50 +61,53 @@ export default class ScrollSaver { } public restore(useReflow?: boolean) { - const {container, previousScrollHeightMinusTop, scrollable} = this; - if(previousScrollHeightMinusTop !== undefined) { - const scrollHeight = container.scrollHeight; - 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 {container, scrollHeightMinusTop: previousScrollHeightMinusTop, scrollable} = this; + if(previousScrollHeightMinusTop === undefined) { + throw new Error('scroll was not saved'); + } + + 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; } } diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 175c0e1e..90188cea 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -1135,15 +1135,12 @@ export class AppImManager { //if(bubble) { //const top = bubble.getBoundingClientRect().top; const chatBubbles = chat.bubbles; - - chatBubbles.sliceViewport(); - - const top = chatBubbles.scrollable.scrollTop; - const key = chat.peerId + (chat.threadId ? '_' + chat.threadId : ''); - 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 = { mids: getObjectKeysAndSort(chatBubbles.bubbles, 'desc'), top diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index ea4af19b..6e369813 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -399,6 +399,24 @@ $bubble-beside-button-width: 38px; cursor: pointer; 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 {