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>