From 9b0cfe55dbe4d167bc835310ee26ed040389685a Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 19 Feb 2024 19:45:12 +0400 Subject: [PATCH] UBERF-5472: Add pagination for channels/direct (#4706) Signed-off-by: Kristina Fefelova --- packages/ui/src/components/Scroller.svelte | 2 + .../ActivityMessageTemplate.svelte | 2 +- plugins/chunter-assets/lang/en.json | 3 +- plugins/chunter-assets/lang/ru.json | 3 +- .../src/channelDataProvider.ts | 410 +++++++++++++++++ .../src/components/Channel.svelte | 57 ++- .../src/components/ChannelScrollView.svelte | 423 +++++++++++------- .../src/components/ChannelView.svelte | 88 ++-- .../src/components/JumpToDateSelector.svelte | 1 + .../src/components/LoadingHistory.svelte | 40 ++ .../src/components/threads/ThreadView.svelte | 16 +- plugins/chunter-resources/src/plugin.ts | 3 +- plugins/chunter-resources/src/utils.ts | 62 ++- plugins/tracker-resources/src/issues.ts | 4 +- 14 files changed, 848 insertions(+), 266 deletions(-) create mode 100644 plugins/chunter-resources/src/channelDataProvider.ts create mode 100644 plugins/chunter-resources/src/components/LoadingHistory.svelte diff --git a/packages/ui/src/components/Scroller.svelte b/packages/ui/src/components/Scroller.svelte index b5d18b0890..c028fac990 100644 --- a/packages/ui/src/components/Scroller.svelte +++ b/packages/ui/src/components/Scroller.svelte @@ -44,6 +44,7 @@ export let stickedScrollBars: boolean = false export let thinScrollBars: boolean = false export let onScroll: ((params: ScrollParams) => void) | undefined = undefined + export let onResize: (() => void) | undefined = undefined export function scroll (top: number, left?: number, behavior: 'auto' | 'smooth' = 'auto') { if (divScroll) { @@ -538,6 +539,7 @@ bind:this={divScroll} use:resizeObserver={(element) => { divHeight = element.clientHeight + onResize?.() }} class="scroll relative flex-shrink" style:overflow-x={horizontal ? 'auto' : 'hidden'} diff --git a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte index 31bb12888d..50eb3f2c33 100644 --- a/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte +++ b/plugins/activity-resources/src/components/activity-message/ActivityMessageTemplate.svelte @@ -21,7 +21,7 @@ import { Person } from '@hcengineering/contact' import { Avatar, EmployeePresenter, SystemAvatar } from '@hcengineering/contact-resources' import core, { getDisplayTime } from '@hcengineering/core' - import { createQuery, getClient } from '@hcengineering/presentation' + import { getClient } from '@hcengineering/presentation' import { Action, Label } from '@hcengineering/ui' import { getActions } from '@hcengineering/view-resources' diff --git a/plugins/chunter-assets/lang/en.json b/plugins/chunter-assets/lang/en.json index c8f39700d3..e310995df9 100644 --- a/plugins/chunter-assets/lang/en.json +++ b/plugins/chunter-assets/lang/en.json @@ -94,6 +94,7 @@ "Private": "Private", "NewDirectChat": "New direct chat", "AddMembers": "Add members", - "PinnedCount": "{count} pinned" + "PinnedCount": "{count} pinned", + "LoadingHistory": "Loading history..." } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/ru.json b/plugins/chunter-assets/lang/ru.json index b37d892ce2..b0d39c3a89 100644 --- a/plugins/chunter-assets/lang/ru.json +++ b/plugins/chunter-assets/lang/ru.json @@ -94,6 +94,7 @@ "Private": "Закрытый", "NewDirectChat": "Новый личный чат", "AddMembers": "Добавить участников", - "PinnedCount": "{count} закреплено" + "PinnedCount": "{count} закреплено", + "LoadingHistory": "Загрузка истории..." } } \ No newline at end of file diff --git a/plugins/chunter-resources/src/channelDataProvider.ts b/plugins/chunter-resources/src/channelDataProvider.ts new file mode 100644 index 0000000000..002f82138a --- /dev/null +++ b/plugins/chunter-resources/src/channelDataProvider.ts @@ -0,0 +1,410 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { createQuery, getClient } from '@hcengineering/presentation' +import { + type Account, + type Class, + type Doc, + getCurrentAccount, + isOtherDay, + type Ref, + SortingOrder, + type Timestamp +} from '@hcengineering/core' + +import { derived, get, type Readable, writable } from 'svelte/store' +import { onDestroy } from 'svelte' +import { type ActivityMessage } from '@hcengineering/activity' +import attachment from '@hcengineering/attachment' +import { combineActivityMessages } from '@hcengineering/activity-resources' + +import chunter from './plugin' + +export type LoadMode = 'forward' | 'backward' + +interface MessageMetadata { + _id: Ref + createdOn?: Timestamp + createdBy?: Ref +} + +interface Chunk { + from: Timestamp + to: Timestamp + data: ActivityMessage[] +} + +interface IChannelDataProvider { + limit: number + + isLoadingStore: Readable + isLoadingMoreStore: Readable + messagesStore: Readable + newTimestampStore: Readable + datesStore: Readable + + loadMore: (mode: LoadMode, loadAfter: Timestamp) => Promise + canLoadMore: (mode: LoadMode, loadAfter: Timestamp) => boolean + jumpToDate: (date: Timestamp) => Promise +} + +export class ChannelDataProvider implements IChannelDataProvider { + public readonly limit = 30 + + private readonly metadataQuery = createQuery(true) + private readonly tailQuery = createQuery(true) + + private chatId: Ref | undefined = undefined + private readonly lastViewedTimestamp: Timestamp | undefined = undefined + private readonly msgClass: Ref> + private selectedMsgId: Ref | undefined = undefined + private tailStart: Timestamp | undefined = undefined + + private readonly metadataStore = writable([]) + private readonly tailStore = writable([]) + private readonly chunksStore = writable([]) + + private readonly isInitialLoadingStore = writable(false) + private readonly isInitialLoadedStore = writable(false) + private readonly isTailLoading = writable(false) + + public datesStore = writable([]) + public newTimestampStore = writable(undefined) + + public isLoadingMoreStore = writable(false) + + public isLoadingStore = derived( + [this.isInitialLoadedStore, this.isTailLoading], + ([initialLoaded, tailLoading]) => !initialLoaded || tailLoading + ) + + public messagesStore = derived([this.chunksStore, this.tailStore], ([chunks, tail]) => { + return [...chunks.map(({ data }) => data).flat(), ...tail] + }) + + constructor ( + chatId: Ref, + _class: Ref>, + lastViewedTimestamp?: Timestamp, + selectedMsgId?: Ref, + loadAll = false + ) { + this.chatId = chatId + this.lastViewedTimestamp = lastViewedTimestamp + this.msgClass = _class + this.selectedMsgId = selectedMsgId + this.loadData(loadAll) + + onDestroy(() => { + this.destroy() + }) + } + + public destroy (): void { + this.clearData() + this.metadataQuery.unsubscribe() + this.tailQuery.unsubscribe() + } + + public canLoadMore (mode: LoadMode, timestamp?: Timestamp): boolean { + if (timestamp === undefined) { + return false + } + + const metadata = get(this.metadataStore) + + if (mode === 'forward') { + const last = metadata[metadata.length - 1]?.createdOn ?? 0 + return last > timestamp + } else { + const first = metadata[0]?.createdOn ?? 0 + return first < timestamp + } + } + + 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 + } + + private loadData (loadAll = false): void { + if (this.chatId === undefined) { + return + } + + this.metadataQuery.query( + this.msgClass, + { attachedTo: this.chatId }, + (res) => { + this.updatesDates(res) + this.metadataStore.set(res) + void this.loadInitialMessages(undefined, loadAll) + }, + { + projection: { _id: 1, createdOn: 1, createdBy: 1, attachedTo: 1 }, + sort: { createdOn: SortingOrder.Ascending } + } + ) + } + + private async loadInitialMessages (selectedMsg?: Ref, loadAll = false): Promise { + const isLoading = get(this.isInitialLoadingStore) + const isLoaded = get(this.isInitialLoadedStore) + + if (isLoading || isLoaded) { + return + } + + this.isInitialLoadingStore.set(true) + + const metadata = get(this.metadataStore) + const firstNewMsgIndex = this.getFirstNewMsgIndex(this.lastViewedTimestamp) + + if (get(this.newTimestampStore) === undefined) { + this.newTimestampStore.set(firstNewMsgIndex !== undefined ? metadata[firstNewMsgIndex]?.createdOn : undefined) + } + + const startPosition = this.getStartPosition(selectedMsg ?? this.selectedMsgId, firstNewMsgIndex) + + const count = metadata.length + const isLoadingLatest = startPosition === undefined || startPosition === -1 + + if (loadAll) { + this.loadTail(undefined, combineActivityMessages) + } else if (isLoadingLatest) { + const startIndex = Math.max(0, count - this.limit) + this.isTailLoading.set(true) + const tailStart = metadata[startIndex]?.createdOn + this.loadTail(tailStart) + } else if (count - startPosition <= this.limit) { + this.isTailLoading.set(true) + const tailStart = metadata[startPosition]?.createdOn + this.loadTail(tailStart) + await this.loadMore('backward', tailStart) + } else { + const start = metadata[startPosition]?.createdOn + + if (startPosition === 0) { + await this.loadMore('forward', metadata[startPosition]?.createdOn, this.limit, true) + } else { + await this.loadMore('backward', start, this.limit / 2) + await this.loadMore('forward', metadata[startPosition - 1]?.createdOn, this.limit / 2) + } + } + + this.isInitialLoadingStore.set(false) + this.isInitialLoadedStore.set(true) + } + + private loadTail (start?: Timestamp, afterLoad?: (msgs: ActivityMessage[]) => Promise): void { + if (this.chatId === undefined) { + this.isTailLoading.set(false) + return + } + + if (this.tailStart === undefined) { + this.tailStart = start + } + + this.tailQuery.query( + this.msgClass, + { + attachedTo: this.chatId, + ...(this.tailStart !== undefined ? { createdOn: { $gte: this.tailStart } } : {}) + }, + async (res) => { + if (afterLoad !== undefined) { + const result = await afterLoad(res.reverse()) + this.tailStore.set(result) + } else { + this.tailStore.set(res.reverse()) + } + + this.isTailLoading.set(false) + }, + { + sort: { createdOn: SortingOrder.Descending }, + lookup: { + _id: { attachments: attachment.class.Attachment } + } + } + ) + } + + public async loadMore (mode: LoadMode, loadAfter?: Timestamp, limit?: number, loadEqual = false): Promise { + if (this.chatId === undefined || loadAfter === undefined) { + return + } + + if (!this.canLoadMore(mode, loadAfter) || get(this.isLoadingMoreStore)) { + return + } + + this.isLoadingMoreStore.set(true) + + const isBackward = mode === 'backward' + const isForward = mode === 'forward' + + 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) + this.isLoadingMoreStore.set(false) + return + } + } + + const client = getClient() + const messages = await client.findAll( + chunter.class.ChatMessage, + { + attachedTo: this.chatId, + createdOn: isBackward + ? loadEqual + ? { $lte: loadAfter } + : { $lt: loadAfter } + : loadEqual + ? { $gte: loadAfter } + : { $gt: loadAfter } + }, + { + limit: limit ?? this.limit, + sort: { createdOn: isBackward ? SortingOrder.Descending : SortingOrder.Ascending }, + lookup: { + _id: { attachments: attachment.class.Attachment } + } + } + ) + + 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 = { + from: from.createdOn ?? from.modifiedOn, + to: to.createdOn ?? to.modifiedOn, + data: isBackward ? messages.reverse() : messages + } + + const chunks = get(this.chunksStore) + + this.chunksStore.set(isBackward ? [chunk, ...chunks] : [...chunks, chunk]) + this.isLoadingMoreStore.set(false) + } + + private getStartPosition (selectedMsgId?: Ref, firsNewMsgIndex?: number): number | undefined { + const metadata = get(this.metadataStore) + + const selectedIndex = + selectedMsgId !== undefined ? metadata.findIndex(({ _id }) => _id === selectedMsgId) : undefined + + if (selectedIndex !== undefined && selectedIndex >= 0) { + return selectedIndex + } + + return firsNewMsgIndex + } + + private getFirstNewMsgIndex (lastViewedTimestamp?: Timestamp): number | undefined { + const metadata = get(this.metadataStore) + + if (metadata.length === 0) { + return undefined + } + + if (lastViewedTimestamp === undefined) { + return -1 + } + + const me = getCurrentAccount()._id + + return metadata.findIndex((message) => { + if (message.createdBy === me) { + return false + } + + const createdOn = message.createdOn ?? 0 + + return lastViewedTimestamp < createdOn + }) + } + + private updatesDates (metadata: MessageMetadata[]): void { + const dates: Timestamp[] = [] + + for (const [index, data] of metadata.entries()) { + const date = data.createdOn + + if (date === undefined) { + continue + } + + if (index === 0) { + dates.push(date) + } + + const nextDate = metadata[index + 1]?.createdOn + + if (nextDate === undefined) { + continue + } + + if (isOtherDay(date, nextDate)) { + dates.push(nextDate) + } + } + + this.datesStore.set(dates) + } + + private clearMessages (): void { + this.tailStore.set([]) + this.chunksStore.set([]) + this.isInitialLoadedStore.set(false) + this.tailQuery.unsubscribe() + this.tailStart = undefined + } + + public async jumpToDate (date: Timestamp): Promise { + const msg = get(this.metadataStore).find(({ createdOn }) => createdOn === date) + + if (msg === undefined) { + return + } + + this.clearMessages() + await this.loadInitialMessages(msg._id) + } +} diff --git a/plugins/chunter-resources/src/components/Channel.svelte b/plugins/chunter-resources/src/components/Channel.svelte index e717cad878..505567cea3 100644 --- a/plugins/chunter-resources/src/components/Channel.svelte +++ b/plugins/chunter-resources/src/components/Channel.svelte @@ -13,46 +13,63 @@ // limitations under the License. --> - +{#if dataProvider} + +{/if} diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index 4c440482ba..640e24369b 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -13,27 +13,30 @@ // limitations under the License. --> -
- {#if startFromBottom} -
- {/if} - {#if selectedDate} -
- +{#if isLoading} + +{:else} +
+ {#if startFromBottom} +
+ {/if} + {#if selectedDate} +
+ +
+ {/if} + {#if isInitialScrolling} +
+ +
+ {/if} + + {#if loadMoreAllowed && provider.canLoadMore('backward', messages[0]?.createdOn)} + + {/if} + + + {#each displayMessages as message (message._id)} + {@const isSelected = message._id === selectedMessageId} + + {#if message.createdOn === $newTimestampStore} + + {/if} + + {#if withDates && message.createdOn && $datesStore.includes(message.createdOn)} + + {/if} + +
+ +
+ {/each} + + {#if loadMoreAllowed && provider.canLoadMore('forward', messages[messages.length - 1]?.createdOn)} + + {/if} +
+
+ {#if object} +
+
{/if} - - - {#each displayMessages as message, index} - {@const isSelected = message._id === selectedMessageId} - {@const prevMessage = displayMessages[index - 1]} - {#if index === separatorPosition} - - {/if} - - {#if withDates && (index === 0 || isOtherDay(message.createdOn ?? 0, prevMessage?.createdOn ?? 0))} - - {/if} - -
- -
- {/each} -
-
-{#if object} -
- -
{/if} diff --git a/plugins/chunter-resources/src/components/ChannelView.svelte b/plugins/chunter-resources/src/components/ChannelView.svelte index 2fff2eddbe..8083294e92 100644 --- a/plugins/chunter-resources/src/components/ChannelView.svelte +++ b/plugins/chunter-resources/src/components/ChannelView.svelte @@ -13,14 +13,12 @@ // limitations under the License. --> + +
+ {#if isLoading} +
+ + diff --git a/plugins/chunter-resources/src/components/threads/ThreadView.svelte b/plugins/chunter-resources/src/components/threads/ThreadView.svelte index 6e304e97f3..56aae3bc14 100644 --- a/plugins/chunter-resources/src/components/threads/ThreadView.svelte +++ b/plugins/chunter-resources/src/components/threads/ThreadView.svelte @@ -25,6 +25,7 @@ import ThreadParentMessage from './ThreadParentPresenter.svelte' import { getChannelIcon, getChannelName } from '../../utils' import ChannelScrollView from '../ChannelScrollView.svelte' + import { ChannelDataProvider } from '../../channelDataProvider' export let _id: Ref export let selectedMessageId: Ref | undefined = undefined @@ -37,13 +38,12 @@ const messageQuery = createQuery() const channelQuery = createQuery() - const messagesQuery = createQuery() let channel: Doc | undefined = undefined let message: DisplayActivityMessage | undefined = undefined - let messages: DisplayActivityMessage[] = [] let channelName: string | undefined = undefined + let dataProvider: ChannelDataProvider | undefined = undefined locationStore.subscribe((newLocation) => { selectedMessageId = getMessageFromLoc(newLocation) @@ -58,10 +58,9 @@ channel = res[0] }) - $: message && - messagesQuery.query(chunter.class.ThreadMessage, { attachedTo: message._id }, (res) => { - messages = res - }) + $: if (message !== undefined && dataProvider === undefined) { + dataProvider = new ChannelDataProvider(message._id, chunter.class.ThreadMessage, undefined, selectedMessageId, true) + } $: message && getChannelName(message.attachedTo, message.attachedToClass, channel).then((res) => { @@ -114,15 +113,16 @@
- {#if message} + {#if message && dataProvider !== undefined}
diff --git a/plugins/chunter-resources/src/plugin.ts b/plugins/chunter-resources/src/plugin.ts index eab2f37b3a..4803f8edf2 100644 --- a/plugins/chunter-resources/src/plugin.ts +++ b/plugins/chunter-resources/src/plugin.ts @@ -104,6 +104,7 @@ export default mergeIds(chunterId, chunter, { On: '' as IntlString, Mentioned: '' as IntlString, SentMessage: '' as IntlString, - PinnedCount: '' as IntlString + PinnedCount: '' as IntlString, + LoadingHistory: '' as IntlString } }) diff --git a/plugins/chunter-resources/src/utils.ts b/plugins/chunter-resources/src/utils.ts index f775ab97da..a1f4cc2ee2 100644 --- a/plugins/chunter-resources/src/utils.ts +++ b/plugins/chunter-resources/src/utils.ts @@ -31,7 +31,8 @@ import { type Space, type Class, type Timestamp, - type Account + type Account, + generateId } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' import { @@ -48,6 +49,7 @@ import activity, { type ActivityMessage, type ActivityMessagesFilter, type DisplayActivityMessage, + type DisplayDocUpdateMessage, type DocUpdateMessage } from '@hcengineering/activity' import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources' @@ -337,20 +339,19 @@ export function getUnreadThreadsCount (): number { return new Set(threadIds).size } -export function getClosestDateSelectorDate (date: Timestamp, scrollElement: HTMLDivElement): Timestamp | undefined { - const dateSelectors = scrollElement.getElementsByClassName('dateSelector') - - if (dateSelectors === undefined || dateSelectors.length === 0) { +export function getClosestDate (selectedDate: Timestamp, dates: Timestamp[]): Timestamp | undefined { + if (dates.length === 0) { return } - let closestDate: Timestamp | undefined = parseInt(dateSelectors[dateSelectors.length - 1].id) + let closestDate: Timestamp | undefined = dates[dates.length - 1] + const reversedDates = [...dates].reverse() - for (const elem of Array.from(dateSelectors).reverse()) { - const curDate = parseInt(elem.id) - if (curDate < date) break - else if (curDate - date < closestDate - date) { - closestDate = curDate + for (const date of reversedDates) { + if (date < selectedDate) { + break + } else if (date - selectedDate < closestDate - selectedDate) { + closestDate = date } } @@ -453,3 +454,42 @@ export async function leaveChannel (channel: Channel, value: Ref | Arra await client.update(channel, { $pull: { members: value } }) } } + +export async function readChannelMessages ( + messages: DisplayActivityMessage[], + context: DocNotifyContext | undefined +): Promise { + if (messages.length === 0) { + return + } + + const inboxClient = InboxNotificationsClientImpl.getClient() + const client = getClient() + + const allIds = messages + .map((message) => { + const combined = + message._class === activity.class.DocUpdateMessage + ? (message as DisplayDocUpdateMessage)?.combinedMessagesIds + : undefined + + return [message._id, ...(combined ?? [])] + }) + .flat() + + const ops = getClient().apply(generateId()) + + void inboxClient.readMessages(ops, allIds).then(() => { + void ops.commit() + }) + + if (context === undefined) { + return + } + + const lastTimestamp = messages[messages.length - 1].createdOn ?? 0 + + if ((context.lastViewedTimestamp ?? 0) < lastTimestamp) { + void client.update(context, { lastViewedTimestamp: lastTimestamp }) + } +} diff --git a/plugins/tracker-resources/src/issues.ts b/plugins/tracker-resources/src/issues.ts index 49669b2ea8..56067099e6 100644 --- a/plugins/tracker-resources/src/issues.ts +++ b/plugins/tracker-resources/src/issues.ts @@ -16,7 +16,9 @@ export function isIssueId (shortLink: string): boolean { export async function issueIdentifierProvider (client: TxOperations, ref: Ref): Promise { const object = await client.findOne(tracker.class.Issue, { _id: ref as Ref }) - if (object === undefined) throw new Error(`Issue project not found, _id: ${ref}`) + if (object === undefined) { + return '' + } return object.identifier }