From ff610b4d82e640b91300223e3c92b683b65a4a82 Mon Sep 17 00:00:00 2001 From: Kristina <kristin.fefelova@gmail.com> Date: Mon, 29 Jul 2024 10:50:10 +0400 Subject: [PATCH] Smooth chat history loading (#6162) Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com> --- packages/ui/src/components/Scroller.svelte | 5 + .../src/channelDataProvider.ts | 217 ++++++++++++++---- .../src/components/ChannelScrollView.svelte | 31 ++- 3 files changed, 203 insertions(+), 50 deletions(-) diff --git a/packages/ui/src/components/Scroller.svelte b/packages/ui/src/components/Scroller.svelte index ed08e67538..05372851e9 100644 --- a/packages/ui/src/components/Scroller.svelte +++ b/packages/ui/src/components/Scroller.svelte @@ -43,6 +43,7 @@ export let checkForHeaders: boolean = false export let stickedScrollBars: boolean = false export let thinScrollBars: boolean = false + export let disableOverscroll = false export let onScroll: ((params: ScrollParams) => void) | undefined = undefined export let onResize: (() => void) | undefined = undefined @@ -542,6 +543,7 @@ onResize?.() }} class="scroll relative flex-shrink" + class:disableOverscroll style:overflow-x={horizontal ? 'auto' : 'hidden'} on:scroll={() => { if (onScroll) { @@ -838,6 +840,9 @@ height: 100%; overflow-y: auto; + &.disableOverscroll { + overscroll-behavior: none; + } &::-webkit-scrollbar:vertical { width: 0; } diff --git a/plugins/chunter-resources/src/channelDataProvider.ts b/plugins/chunter-resources/src/channelDataProvider.ts index 2c5a749f7f..23a6601ba2 100644 --- a/plugins/chunter-resources/src/channelDataProvider.ts +++ b/plugins/chunter-resources/src/channelDataProvider.ts @@ -60,7 +60,6 @@ interface IChannelDataProvider { datesStore: Readable<Timestamp[]> metadataStore: Readable<MessageMetadata[]> - loadMore: (mode: LoadMode, loadAfter: Timestamp) => Promise<void> canLoadMore: (mode: LoadMode, loadAfter: Timestamp) => boolean jumpToDate: (date: Timestamp) => Promise<void> } @@ -95,10 +94,27 @@ export class ChannelDataProvider implements IChannelDataProvider { ([initialLoaded, tailLoading]) => !initialLoaded || tailLoading ) + private readonly backwardNextStore = writable<Chunk | undefined>(undefined) + private readonly forwardNextStore = writable<Chunk | undefined>(undefined) + + private backwardNextPromise: Promise<void> | undefined = undefined + private forwardNextPromise: Promise<void> | undefined = undefined + + private readonly isBackwardLoading = writable(false) + private readonly isForwardLoading = writable(false) + + private nextChunkAdding = false + public messagesStore = derived([this.chunksStore, this.tailStore], ([chunks, tail]) => { return [...chunks.map(({ data }) => data).flat(), ...tail] }) + public canLoadNextForwardStore = derived([this.messagesStore, this.forwardNextStore], ([messages, forwardNext]) => { + if (forwardNext !== undefined) return false + + return this.canLoadMore('forward', messages[messages.length - 1]?.createdOn) + }) + constructor ( chatId: Ref<Doc>, _class: Ref<Class<ActivityMessage>>, @@ -139,20 +155,15 @@ export class ChannelDataProvider implements IChannelDataProvider { private clearData (): void { this.metadataStore.set([]) - this.tailStore.set([]) - this.chunksStore.set([]) - this.isInitialLoadingStore.set(false) - this.isInitialLoadedStore.set(false) this.isTailLoading.set(false) - this.datesStore.set([]) this.newTimestampStore.set(undefined) this.isLoadingMoreStore.set(false) - - this.tailStart = undefined this.chatId = undefined this.selectedMsgId = undefined + + this.clearMessages() } private async loadData (loadAll = false): Promise<void> { @@ -211,9 +222,13 @@ export class ChannelDataProvider implements IChannelDataProvider { this.isTailLoading.set(true) const tailStart = metadata[startIndex]?.createdOn this.loadTail(tailStart) + this.backwardNextPromise = this.loadNext('backward', metadata[startIndex]?.createdOn, this.limit) } else { const newStart = Math.max(startPosition - this.limit / 2, 0) await this.loadMore('forward', metadata[newStart]?.createdOn, this.limit) + if (newStart > 0) { + this.backwardNextPromise = this.loadNext('backward', metadata[newStart]?.createdOn, this.limit) + } } this.isInitialLoadingStore.set(false) @@ -260,41 +275,28 @@ export class ChannelDataProvider implements IChannelDataProvider { ) } - public async loadMore (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise<void> { - if (this.chatId === undefined || loadAfter === undefined) { - return - } + isNextLoading (mode: LoadMode): boolean { + return mode === 'forward' ? get(this.isForwardLoading) : get(this.isBackwardLoading) + } - if (!this.canLoadMore(mode, loadAfter) || get(this.isLoadingMoreStore)) { - return - } + isNextLoaded (mode: LoadMode): boolean { + return mode === 'forward' ? get(this.forwardNextStore) !== undefined : get(this.backwardNextStore) !== undefined + } - this.isLoadingMoreStore.set(true) + setNextLoading (mode: LoadMode, value: boolean): void { + mode === 'forward' ? this.isForwardLoading.set(value) : this.isBackwardLoading.set(value) + } - const isBackward = mode === 'backward' - const isForward = mode === 'forward' + getTailStartIndex (metadata: MessageMetadata[], loadAfter: Timestamp): number { + const index = metadata.slice(-this.limit - 1).findIndex(({ createdOn }) => createdOn === loadAfter) - const chunks = get(this.chunksStore) - const tail = get(this.tailStore) - const lastChunk: Chunk | undefined = isBackward ? chunks[0] : chunks[chunks.length - 1] - const skipIds = (lastChunk?.data ?? []) - .concat(tail) - .filter(({ createdOn }) => createdOn === loadAfter) - .map(({ _id }) => _id) as Array<Ref<ChatMessage>> - - if (isForward) { - const metadata = get(this.metadataStore) - const metaIndex = metadata.findIndex(({ createdOn }) => createdOn === loadAfter) - const shouldLoadTail = metaIndex >= 0 && metaIndex + this.limit >= metadata.length - - if (shouldLoadTail) { - this.loadTail(metadata[metaIndex + 1]?.createdOn, undefined, { _id: { $nin: skipIds } }) - this.isLoadingMoreStore.set(false) - return - } - } + return index !== -1 ? metadata.length - index : -1 + } + async loadChunk (isBackward: boolean, loadAfter: Timestamp, limit?: number): Promise<Chunk | undefined> { const client = getClient() + const skipIds = this.getChunkSkipIds(loadAfter) + const messages = await client.findAll( chunter.class.ChatMessage, { @@ -312,20 +314,149 @@ export class ChannelDataProvider implements IChannelDataProvider { ) if (messages.length === 0) { - this.isLoadingMoreStore.set(false) return } const from = isBackward ? messages[0] : messages[messages.length - 1] const to = isBackward ? messages[messages.length - 1] : messages[0] - const chunk: Chunk = { + return { from: from.createdOn ?? from.modifiedOn, to: to.createdOn ?? to.modifiedOn, data: isBackward ? messages.reverse() : messages } + } + + getChunkSkipIds (after: Timestamp, loadTail = false): Array<Ref<ChatMessage>> { + const chunks = get(this.chunksStore) + const metadata = get(this.metadataStore) + const tail = get(this.tailStore) + const tailData = tail.length > 0 ? get(this.tailStore) : metadata.slice(-this.limit) + + return chunks + .filter(({ to, from }) => from >= after || to <= after) + .map(({ data }) => data as MessageMetadata[]) + .flat() + .concat(loadTail ? [] : tailData) + .filter(({ createdOn }) => createdOn === after) + .map(({ _id }) => _id) as Array<Ref<ChatMessage>> + } + + async loadNext (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise<void> { + if (this.chatId === undefined || loadAfter === undefined) { + return + } + + if (this.isNextLoading(mode) || this.isNextLoaded(mode)) { + return + } + + if (!this.canLoadMore(mode, loadAfter)) { + return + } + + this.setNextLoading(mode, true) + + const isBackward = mode === 'backward' + const isForward = mode === 'forward' + + const metadata = get(this.metadataStore) + + if (isForward && this.getTailStartIndex(metadata, loadAfter) !== -1) { + this.setNextLoading(mode, false) + return + } + + const chunk = await this.loadChunk(isBackward, loadAfter, limit) + + if (chunk !== undefined && isBackward) { + this.backwardNextStore.set(chunk) + } + if (chunk !== undefined && isForward) { + this.forwardNextStore.set(chunk) + } + + this.setNextLoading(mode, false) + } + + public async addNextChunk (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise<void> { + if (loadAfter === undefined || this.nextChunkAdding) { + return + } + + this.nextChunkAdding = true + + if (this.forwardNextPromise instanceof Promise && mode === 'forward') { + await this.forwardNextPromise + this.forwardNextPromise = undefined + } + + if (this.backwardNextPromise instanceof Promise && mode === 'backward') { + await this.backwardNextPromise + this.backwardNextPromise = undefined + } + + if (this.isNextLoaded(mode)) { + const next = mode === 'forward' ? get(this.forwardNextStore) : get(this.backwardNextStore) + if (next !== undefined) { + if (mode === 'forward') { + this.forwardNextStore.set(undefined) + this.chunksStore.set([...get(this.chunksStore), next]) + this.forwardNextPromise = this.loadNext('forward', next.from, limit) + } else { + this.backwardNextStore.set(undefined) + this.chunksStore.set([next, ...get(this.chunksStore)]) + this.backwardNextPromise = this.loadNext('backward', next.to, limit) + } + } + } else { + await this.loadMore(mode, loadAfter, limit) + } + + this.nextChunkAdding = false + } + + private async loadMore (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise<void> { + if (get(this.isLoadingMoreStore) || loadAfter === undefined) { + return + } + + if (!this.canLoadMore(mode, loadAfter)) { + return + } + + this.isLoadingMoreStore.set(true) + + const isBackward = mode === 'backward' + const isForward = mode === 'forward' + + const chunks = get(this.chunksStore) + const metadata = get(this.metadataStore) + + if (isForward) { + const index = this.getTailStartIndex(metadata, loadAfter) + const tailAfter = metadata[index]?.createdOn + + if (tailAfter !== undefined) { + const skipIds = chunks[chunks.length - 1]?.data.map(({ _id }) => _id) ?? [] + this.loadTail(tailAfter, undefined, { _id: { $nin: skipIds } }) + this.isLoadingMoreStore.set(false) + return + } + } + + const chunk = await this.loadChunk(isBackward, loadAfter, limit) + + if (chunk !== undefined) { + this.chunksStore.set(isBackward ? [chunk, ...chunks] : [...chunks, chunk]) + + if (isBackward) { + this.forwardNextPromise = this.loadNext('backward', chunk.to, limit) + } else { + this.forwardNextPromise = this.loadNext('forward', chunk.from, limit) + } + } - this.chunksStore.set(isBackward ? [chunk, ...chunks] : [...chunks, chunk]) this.isLoadingMoreStore.set(false) } @@ -426,6 +557,12 @@ export class ChannelDataProvider implements IChannelDataProvider { this.isInitialLoadedStore.set(false) this.tailQuery.unsubscribe() this.tailStart = undefined + this.backwardNextPromise = undefined + this.forwardNextPromise = undefined + this.forwardNextStore.set(undefined) + this.backwardNextStore.set(undefined) + this.isBackwardLoading.set(false) + this.isForwardLoading.set(false) } public async jumpToDate (date: Timestamp): Promise<void> { diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index 7b4de7f091..e9f09049f2 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -67,6 +67,7 @@ const dateSelectorHeight = 30 const headerHeight = 52 const minMsgHeightRem = 2 + const loadMoreThreshold = 40 const client = getClient() const hierarchy = client.getHierarchy() @@ -244,7 +245,7 @@ return false } - return scrollElement.scrollTop === 0 + return scrollElement.scrollTop <= loadMoreThreshold } function shouldLoadMoreDown (): boolean { @@ -254,10 +255,11 @@ const { scrollHeight, scrollTop, clientHeight } = scrollElement - return scrollHeight - Math.ceil(scrollTop + clientHeight) <= 0 + return scrollHeight - Math.ceil(scrollTop + clientHeight) <= loadMoreThreshold } let scrollToRestore = 0 + let backwardRequested = false function loadMore (): void { if (!loadMoreAllowed || $isLoadingMoreStore || !scrollElement || isInitialScrolling) { @@ -268,18 +270,24 @@ const maxMsgPerScreen = Math.ceil(scrollElement.clientHeight / minMsgHeightPx) const limit = Math.max(maxMsgPerScreen, provider.limit) - if (shouldLoadMoreUp() && scrollElement && provider.canLoadMore('backward', messages[0]?.createdOn)) { + if (!shouldLoadMoreUp()) { + backwardRequested = false + } + + if (shouldLoadMoreUp() && !backwardRequested) { shouldScrollToNew = false - scrollToRestore = scrollElement.scrollHeight - void provider.loadMore('backward', messages[0]?.createdOn, limit) - } else if (shouldLoadMoreDown() && provider.canLoadMore('forward', messages[messages.length - 1]?.createdOn)) { + scrollToRestore = scrollElement?.scrollHeight ?? 0 + provider.addNextChunk('backward', messages[0]?.createdOn, limit) + backwardRequested = true + } else if (shouldLoadMoreDown()) { + scrollToRestore = 0 shouldScrollToNew = false - void provider.loadMore('forward', messages[messages.length - 1]?.createdOn, limit) isScrollAtBottom = false + provider.addNextChunk('forward', messages[messages.length - 1]?.createdOn, limit) } } - function handleScroll ({ autoScrolling }: ScrollParams): void { + async function handleScroll ({ autoScrolling }: ScrollParams): Promise<void> { saveScrollPosition() updateDownButtonVisibility($metadataStore, displayMessages, scrollElement) if (autoScrolling) { @@ -668,6 +676,8 @@ scrollToBottom() } } + + const canLoadNextForwardStore = provider.canLoadNextForwardStore </script> {#if isLoading} @@ -694,10 +704,11 @@ bind:divScroll={scrollElement} bind:divBox={scrollContentBox} noStretch={false} + disableOverscroll onScroll={handleScroll} onResize={handleResize} > - {#if loadMoreAllowed && provider.canLoadMore('backward', messages[0]?.createdOn)} + {#if loadMoreAllowed} <HistoryLoading isLoading={$isLoadingMoreStore} /> {/if} <slot name="header" /> @@ -736,7 +747,7 @@ /> {/each} - {#if loadMoreAllowed && provider.canLoadMore('forward', messages[messages.length - 1]?.createdOn)} + {#if loadMoreAllowed && $canLoadNextForwardStore} <HistoryLoading isLoading={$isLoadingMoreStore} /> {/if} </Scroller>