Smooth chat history loading (#6162)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-07-29 10:50:10 +04:00 committed by GitHub
parent d1ef98cf87
commit ff610b4d82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 203 additions and 50 deletions

View File

@ -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;
}

View File

@ -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> {

View File

@ -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>