mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-14 04:08:19 +00:00
UBERF-5472: Add pagination for channels/direct (#4706)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
parent
f70bdbb469
commit
9b0cfe55db
@ -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'}
|
||||
|
@ -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'
|
||||
|
||||
|
@ -94,6 +94,7 @@
|
||||
"Private": "Private",
|
||||
"NewDirectChat": "New direct chat",
|
||||
"AddMembers": "Add members",
|
||||
"PinnedCount": "{count} pinned"
|
||||
"PinnedCount": "{count} pinned",
|
||||
"LoadingHistory": "Loading history..."
|
||||
}
|
||||
}
|
@ -94,6 +94,7 @@
|
||||
"Private": "Закрытый",
|
||||
"NewDirectChat": "Новый личный чат",
|
||||
"AddMembers": "Добавить участников",
|
||||
"PinnedCount": "{count} закреплено"
|
||||
"PinnedCount": "{count} закреплено",
|
||||
"LoadingHistory": "Загрузка истории..."
|
||||
}
|
||||
}
|
410
plugins/chunter-resources/src/channelDataProvider.ts
Normal file
410
plugins/chunter-resources/src/channelDataProvider.ts
Normal file
@ -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<ActivityMessage>
|
||||
createdOn?: Timestamp
|
||||
createdBy?: Ref<Account>
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
from: Timestamp
|
||||
to: Timestamp
|
||||
data: ActivityMessage[]
|
||||
}
|
||||
|
||||
interface IChannelDataProvider {
|
||||
limit: number
|
||||
|
||||
isLoadingStore: Readable<boolean>
|
||||
isLoadingMoreStore: Readable<boolean>
|
||||
messagesStore: Readable<ActivityMessage[]>
|
||||
newTimestampStore: Readable<Timestamp | undefined>
|
||||
datesStore: Readable<Timestamp[]>
|
||||
|
||||
loadMore: (mode: LoadMode, loadAfter: Timestamp) => Promise<void>
|
||||
canLoadMore: (mode: LoadMode, loadAfter: Timestamp) => boolean
|
||||
jumpToDate: (date: Timestamp) => Promise<void>
|
||||
}
|
||||
|
||||
export class ChannelDataProvider implements IChannelDataProvider {
|
||||
public readonly limit = 30
|
||||
|
||||
private readonly metadataQuery = createQuery(true)
|
||||
private readonly tailQuery = createQuery(true)
|
||||
|
||||
private chatId: Ref<Doc> | undefined = undefined
|
||||
private readonly lastViewedTimestamp: Timestamp | undefined = undefined
|
||||
private readonly msgClass: Ref<Class<ActivityMessage>>
|
||||
private selectedMsgId: Ref<ActivityMessage> | undefined = undefined
|
||||
private tailStart: Timestamp | undefined = undefined
|
||||
|
||||
private readonly metadataStore = writable<MessageMetadata[]>([])
|
||||
private readonly tailStore = writable<ActivityMessage[]>([])
|
||||
private readonly chunksStore = writable<Chunk[]>([])
|
||||
|
||||
private readonly isInitialLoadingStore = writable(false)
|
||||
private readonly isInitialLoadedStore = writable(false)
|
||||
private readonly isTailLoading = writable(false)
|
||||
|
||||
public datesStore = writable<Timestamp[]>([])
|
||||
public newTimestampStore = writable<Timestamp | undefined>(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<Doc>,
|
||||
_class: Ref<Class<ActivityMessage>>,
|
||||
lastViewedTimestamp?: Timestamp,
|
||||
selectedMsgId?: Ref<ActivityMessage>,
|
||||
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<ActivityMessage>, loadAll = false): Promise<void> {
|
||||
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<ActivityMessage[]>): 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<void> {
|
||||
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<ActivityMessage>, 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<void> {
|
||||
const msg = get(this.metadataStore).find(({ createdOn }) => createdOn === date)
|
||||
|
||||
if (msg === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearMessages()
|
||||
await this.loadInitialMessages(msg._id)
|
||||
}
|
||||
}
|
@ -13,46 +13,63 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Doc, Ref } from '@hcengineering/core'
|
||||
import { Class, Doc, Ref, Timestamp } from '@hcengineering/core'
|
||||
import { DocNotifyContext } from '@hcengineering/notification'
|
||||
import { location as locationStore } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { ActivityMessage, ActivityMessagesFilter, DisplayActivityMessage } from '@hcengineering/activity'
|
||||
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { getMessageFromLoc } from '@hcengineering/activity-resources'
|
||||
import { location as locationStore } from '@hcengineering/ui'
|
||||
|
||||
import chunter from '../plugin'
|
||||
import ChannelScrollView from './ChannelScrollView.svelte'
|
||||
import { ChannelDataProvider } from '../channelDataProvider'
|
||||
|
||||
export let context: DocNotifyContext
|
||||
export let object: Doc | undefined
|
||||
export let filters: Ref<ActivityMessagesFilter>[] = []
|
||||
export let messages: DisplayActivityMessage[] = []
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
let dataProvider: ChannelDataProvider | undefined
|
||||
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
|
||||
|
||||
const unsubscribe = locationStore.subscribe((newLocation) => {
|
||||
locationStore.subscribe((newLocation) => {
|
||||
selectedMessageId = getMessageFromLoc(newLocation)
|
||||
})
|
||||
|
||||
onDestroy(unsubscribe)
|
||||
|
||||
$: isDocChannel = !hierarchy.isDerived(context.attachedToClass, chunter.class.ChunterSpace)
|
||||
$: _class = isDocChannel ? activity.class.ActivityMessage : chunter.class.ChatMessage
|
||||
$: collection = isDocChannel ? 'comments' : 'messages'
|
||||
|
||||
$: updateDataProvider(context.attachedTo, _class, context.lastViewedTimestamp, selectedMessageId)
|
||||
|
||||
function updateDataProvider (
|
||||
attachedTo: Ref<Doc>,
|
||||
_class: Ref<Class<ActivityMessage>>,
|
||||
lastViewedTimestamp?: Timestamp,
|
||||
selectedMessageId?: Ref<ActivityMessage>
|
||||
) {
|
||||
if (dataProvider === undefined) {
|
||||
// For now loading all messages for documents with activity. Need to correct handle aggregation with pagination.
|
||||
// Perhaps we should load all activity messages once, and keep loading in chunks only for ChatMessages then merge them correctly with activity messages
|
||||
const loadAll = isDocChannel
|
||||
dataProvider = new ChannelDataProvider(attachedTo, _class, lastViewedTimestamp, selectedMessageId, loadAll)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChannelScrollView
|
||||
{messages}
|
||||
objectId={context.attachedTo}
|
||||
objectClass={context.attachedToClass}
|
||||
{object}
|
||||
skipLabels={!isDocChannel}
|
||||
selectedFilters={filters}
|
||||
startFromBottom
|
||||
{selectedMessageId}
|
||||
{collection}
|
||||
lastViewedTimestamp={context.lastViewedTimestamp}
|
||||
/>
|
||||
{#if dataProvider}
|
||||
<ChannelScrollView
|
||||
objectId={context.attachedTo}
|
||||
objectClass={context.attachedToClass}
|
||||
{object}
|
||||
skipLabels={!isDocChannel}
|
||||
selectedFilters={filters}
|
||||
startFromBottom
|
||||
{selectedMessageId}
|
||||
{collection}
|
||||
provider={dataProvider}
|
||||
loadMoreAllowed={!isDocChannel}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -13,27 +13,30 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Class, Doc, generateId, getCurrentAccount, isOtherDay, Ref, Timestamp } from '@hcengineering/core'
|
||||
import { Class, Doc, getDay, Ref, Timestamp } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import activity, {
|
||||
ActivityExtension,
|
||||
ActivityMessage,
|
||||
ActivityMessagesFilter,
|
||||
DisplayActivityMessage,
|
||||
type DisplayDocUpdateMessage
|
||||
DisplayActivityMessage
|
||||
} from '@hcengineering/activity'
|
||||
import { Scroller, ScrollParams } from '@hcengineering/ui'
|
||||
import { Loading, Scroller, ScrollParams } from '@hcengineering/ui'
|
||||
import {
|
||||
ActivityExtension as ActivityExtensionComponent,
|
||||
ActivityMessagePresenter
|
||||
} from '@hcengineering/activity-resources'
|
||||
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
|
||||
import { get } from 'svelte/store'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
import ActivityMessagesSeparator from './ChannelMessagesSeparator.svelte'
|
||||
import { filterChatMessages, getClosestDate, readChannelMessages } from '../utils'
|
||||
import HistoryLoading from './LoadingHistory.svelte'
|
||||
import { ChannelDataProvider } from '../channelDataProvider'
|
||||
import JumpToDateSelector from './JumpToDateSelector.svelte'
|
||||
import { filterChatMessages, getClosestDateSelectorDate } from '../utils'
|
||||
|
||||
export let messages: DisplayActivityMessage[] = []
|
||||
export let provider: ChannelDataProvider
|
||||
export let object: Doc | undefined
|
||||
export let objectClass: Ref<Class<Doc>>
|
||||
export let objectId: Ref<Doc>
|
||||
@ -45,30 +48,44 @@
|
||||
export let collection: string | undefined = undefined
|
||||
export let showEmbedded = false
|
||||
export let skipLabels = false
|
||||
export let lastViewedTimestamp: Timestamp | undefined = undefined
|
||||
export let loadMoreAllowed = true
|
||||
|
||||
const dateSelectorHeight = 30
|
||||
const headerHeight = 50
|
||||
const minMsgHeightRem = 4.375
|
||||
|
||||
const client = getClient()
|
||||
const inboxClient = InboxNotificationsClientImpl.getClient()
|
||||
const contextByDocStore = inboxClient.docNotifyContextByDoc
|
||||
const filters = client.getModel().findAllSync(activity.class.ActivityMessagesFilter, {})
|
||||
|
||||
const messagesStore = provider.messagesStore
|
||||
const isLoadingStore = provider.isLoadingStore
|
||||
const isLoadingMoreStore = provider.isLoadingMoreStore
|
||||
const newTimestampStore = provider.newTimestampStore
|
||||
const datesStore = provider.datesStore
|
||||
|
||||
let messages: ActivityMessage[] = []
|
||||
let displayMessages: DisplayActivityMessage[] = []
|
||||
let extensions: ActivityExtension[] = []
|
||||
|
||||
let scroller: Scroller | undefined = undefined
|
||||
let separatorElement: HTMLDivElement | undefined = undefined
|
||||
let separatorPosition: number | undefined = undefined
|
||||
let selectedDate: Timestamp | undefined = undefined
|
||||
let scrollContentBox: HTMLDivElement | undefined = undefined
|
||||
|
||||
let autoscroll = false
|
||||
let scrollContentBox: HTMLDivElement | undefined = undefined
|
||||
let shouldWaitAndRead = false
|
||||
|
||||
let shouldScrollToNew = false
|
||||
let shouldWaitAndRead = false
|
||||
let isScrollInitialized = false
|
||||
|
||||
let selectedDate: Timestamp | undefined = undefined
|
||||
let dateToJump: Timestamp | undefined = undefined
|
||||
|
||||
let messagesCount = 0
|
||||
|
||||
$: messages = $messagesStore
|
||||
$: isLoading = $isLoadingStore
|
||||
|
||||
$: extensions = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: objectClass })
|
||||
|
||||
$: notifyContext = $contextByDocStore.get(objectId)
|
||||
@ -78,20 +95,18 @@
|
||||
})
|
||||
|
||||
function scrollToBottom (afterScrollFn?: () => void) {
|
||||
if (scrollElement !== undefined) {
|
||||
scrollElement?.scrollTo(0, scrollElement.scrollHeight)
|
||||
if (scroller !== undefined && scrollElement !== undefined) {
|
||||
scroller.scrollBy(scrollElement.scrollHeight)
|
||||
afterScrollFn?.()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToSeparator () {
|
||||
setTimeout(() => {
|
||||
if (separatorElement) {
|
||||
separatorElement.scrollIntoView()
|
||||
updateShouldScrollToNew()
|
||||
readViewportMessages()
|
||||
}
|
||||
}, 100)
|
||||
if (separatorElement) {
|
||||
separatorElement.scrollIntoView()
|
||||
updateShouldScrollToNew()
|
||||
readViewportMessages()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToMessage () {
|
||||
@ -107,7 +122,7 @@
|
||||
const msgElement = messagesElements?.[selectedMessageId as any]
|
||||
|
||||
if (!msgElement) {
|
||||
if (messages.some(({ _id }) => _id === selectedMessageId)) {
|
||||
if (displayMessages.some(({ _id }) => _id === selectedMessageId)) {
|
||||
setTimeout(scrollToMessage, 50)
|
||||
}
|
||||
return
|
||||
@ -117,38 +132,50 @@
|
||||
readViewportMessages()
|
||||
}
|
||||
|
||||
function jumpToDate (e: CustomEvent) {
|
||||
function isDateRendered (date: Timestamp) {
|
||||
const day = getDay(date)
|
||||
|
||||
return document.getElementById(day.toString()) != null
|
||||
}
|
||||
|
||||
async function jumpToDate (e: CustomEvent) {
|
||||
const date = e.detail.date
|
||||
|
||||
if (!date || !scrollElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const closestDate = getClosestDateSelectorDate(date, scrollElement)
|
||||
const closestDate = getClosestDate(date, get(provider.datesStore))
|
||||
|
||||
if (!closestDate) {
|
||||
if (closestDate === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (closestDate >= date) {
|
||||
if (isDateRendered(closestDate)) {
|
||||
scrollToDate(closestDate)
|
||||
} else {
|
||||
void provider.jumpToDate(closestDate)
|
||||
dateToJump = closestDate
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToDate (date: Timestamp) {
|
||||
autoscroll = false
|
||||
dateToJump = undefined
|
||||
shouldWaitAndRead = false
|
||||
|
||||
const element = date ? document.getElementById(date.toString()) : undefined
|
||||
const day = getDay(date)
|
||||
const element = document.getElementById(day.toString())
|
||||
|
||||
let offset = element?.offsetTop
|
||||
|
||||
if (!offset || !scrollElement) {
|
||||
if (!offset || !scroller) {
|
||||
return
|
||||
}
|
||||
|
||||
offset = offset - headerHeight - dateSelectorHeight / 2
|
||||
|
||||
scrollElement.scrollTo({ left: 0, top: offset })
|
||||
scroller?.scroll(offset)
|
||||
}
|
||||
|
||||
function updateShouldScrollToNew () {
|
||||
@ -160,14 +187,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLoadMoreUp () {
|
||||
if (!scrollElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
return scrollElement.scrollTop === 0
|
||||
}
|
||||
|
||||
function shouldLoadMoreDown () {
|
||||
if (!scrollElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollElement
|
||||
|
||||
return scrollTop + clientHeight === scrollHeight
|
||||
}
|
||||
|
||||
let scrollToRestore = 0
|
||||
|
||||
function loadMore () {
|
||||
if (!loadMoreAllowed || $isLoadingMoreStore || !scrollElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const minMsgHeightPx = minMsgHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
const maxMsgPerScreen = Math.ceil(scrollElement.clientHeight / minMsgHeightPx)
|
||||
const limit = Math.max(maxMsgPerScreen, provider.limit)
|
||||
|
||||
if (shouldLoadMoreUp() && scrollElement && provider.canLoadMore('backward', messages[0]?.createdOn)) {
|
||||
shouldScrollToNew = false
|
||||
scrollToRestore = scrollElement.scrollHeight
|
||||
void provider.loadMore('backward', messages[0]?.createdOn, limit)
|
||||
} else if (shouldLoadMoreDown() && provider.canLoadMore('forward', messages[messages.length - 1]?.createdOn)) {
|
||||
shouldScrollToNew = false
|
||||
void provider.loadMore('forward', messages[messages.length - 1]?.createdOn, limit)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll ({ autoScrolling }: ScrollParams) {
|
||||
if (autoScrolling) {
|
||||
return
|
||||
}
|
||||
|
||||
shouldWaitAndRead = false
|
||||
autoscroll = false
|
||||
|
||||
updateShouldScrollToNew()
|
||||
loadMore()
|
||||
updateSelectedDate()
|
||||
readViewportMessages()
|
||||
}
|
||||
@ -222,123 +290,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
readMessage(messagesToRead)
|
||||
void readChannelMessages(messagesToRead, notifyContext)
|
||||
}
|
||||
|
||||
function readMessage (messages: DisplayActivityMessage[]) {
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
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())
|
||||
inboxClient.readMessages(ops, allIds).then(() => {
|
||||
void ops.commit()
|
||||
})
|
||||
|
||||
if (notifyContext === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastTimestamp = messages[messages.length - 1].createdOn ?? 0
|
||||
|
||||
if ((notifyContext.lastViewedTimestamp ?? 0) < lastTimestamp) {
|
||||
client.update(notifyContext, { lastViewedTimestamp: lastTimestamp })
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedDate () {
|
||||
async function updateSelectedDate () {
|
||||
if (scrollContentBox === undefined || scrollElement === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const clientRect = scrollElement.getBoundingClientRect()
|
||||
const dateSelectors = scrollContentBox.getElementsByClassName('dateSelector')
|
||||
const containerRect = scrollElement.getBoundingClientRect()
|
||||
|
||||
const firstVisibleDateElement = Array.from(dateSelectors)
|
||||
.reverse()
|
||||
.find((child) => {
|
||||
if (child?.nodeType === Node.ELEMENT_NODE) {
|
||||
const rect = child?.getBoundingClientRect()
|
||||
if (rect.top - dateSelectorHeight / 2 <= clientRect.top + dateSelectorHeight) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
const messagesElements = scrollContentBox?.getElementsByClassName('activityMessage')
|
||||
|
||||
if (!firstVisibleDateElement) {
|
||||
if (messagesElements === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedDate = parseInt(firstVisibleDateElement.id)
|
||||
}
|
||||
const reversedMessages = [...displayMessages].reverse()
|
||||
const reversedDates = [...get(datesStore)].reverse()
|
||||
|
||||
function getNewPosition (displayMessages: ActivityMessage[], lastViewedTimestamp?: Timestamp): number | undefined {
|
||||
if (displayMessages.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
for (const message of reversedMessages) {
|
||||
const msgElement = messagesElements?.[message._id as any]
|
||||
|
||||
if (separatorPosition !== undefined) {
|
||||
return separatorPosition
|
||||
}
|
||||
|
||||
if (lastViewedTimestamp === undefined) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (lastViewedTimestamp === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const me = getCurrentAccount()._id
|
||||
|
||||
return displayMessages.findIndex((message) => {
|
||||
if (message.createdBy === me) {
|
||||
return false
|
||||
if (!msgElement) {
|
||||
continue
|
||||
}
|
||||
|
||||
const createdOn = message.createdOn ?? 0
|
||||
const createdOn = message.createdOn
|
||||
|
||||
return lastViewedTimestamp < createdOn
|
||||
})
|
||||
if (createdOn === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (messageInView(msgElement, containerRect)) {
|
||||
selectedDate = reversedDates.find((date) => date <= createdOn)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: separatorPosition = getNewPosition(displayMessages, lastViewedTimestamp)
|
||||
$: initializeScroll(separatorElement, separatorPosition)
|
||||
$: void initializeScroll($isLoadingStore, separatorElement, $newTimestampStore)
|
||||
|
||||
function initializeScroll (separatorElement?: HTMLDivElement, separatorPosition?: number) {
|
||||
if (separatorPosition === undefined) {
|
||||
let isInitialScrolling = true
|
||||
async function initializeScroll (isLoading: boolean, separatorElement?: HTMLDivElement, newTimestamp?: Timestamp) {
|
||||
if (isLoading || isScrollInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (messages.some(({ _id }) => _id === selectedMessageId)) {
|
||||
if (selectedMessageId !== undefined && messages.some(({ _id }) => _id === selectedMessageId)) {
|
||||
isScrollInitialized = true
|
||||
await wait()
|
||||
scrollToMessage()
|
||||
} else if (separatorPosition === -1) {
|
||||
isInitialScrolling = false
|
||||
} else if (newTimestamp === undefined) {
|
||||
isScrollInitialized = true
|
||||
shouldWaitAndRead = true
|
||||
waitLastMessageRenderAndRead()
|
||||
autoscroll = true
|
||||
waitLastMessageRenderAndRead(() => {
|
||||
isInitialScrolling = false
|
||||
})
|
||||
} else if (separatorElement) {
|
||||
isScrollInitialized = true
|
||||
await wait()
|
||||
scrollToSeparator()
|
||||
isInitialScrolling = false
|
||||
}
|
||||
}
|
||||
|
||||
function waitLastMessageRenderAndRead () {
|
||||
function waitLastMessageRenderAndRead (onComplete?: () => void) {
|
||||
if (isLastMessageViewed()) {
|
||||
readViewportMessages()
|
||||
shouldScrollToNew = true
|
||||
shouldWaitAndRead = false
|
||||
} else if (shouldWaitAndRead) {
|
||||
setTimeout(waitLastMessageRenderAndRead, 50)
|
||||
onComplete?.()
|
||||
} else if (shouldWaitAndRead && messages.length > 0) {
|
||||
setTimeout(() => {
|
||||
waitLastMessageRenderAndRead(onComplete)
|
||||
}, 50)
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
@ -367,70 +398,133 @@
|
||||
scrollUntilSeeLastMessage()
|
||||
}
|
||||
|
||||
function updateMessagesCount (newCount: number) {
|
||||
async function wait () {
|
||||
// One tick is not enough for messages to be rendered,
|
||||
// I think this is due to the fact that we are using a Component, which takes some time to load,
|
||||
// because after one tick I see spinners from Component
|
||||
await tick() // wait until the DOM is updated
|
||||
await tick() // wait until the DOM is updated
|
||||
}
|
||||
|
||||
async function restoreScroll () {
|
||||
if (!scrollElement || !scroller) {
|
||||
scrollToRestore = 0
|
||||
return
|
||||
}
|
||||
|
||||
await wait()
|
||||
|
||||
const delta = scrollElement.scrollHeight - scrollToRestore
|
||||
|
||||
scroller.scrollBy(delta)
|
||||
|
||||
scrollToRestore = 0
|
||||
dateToJump = 0
|
||||
autoscroll = false
|
||||
shouldWaitAndRead = false
|
||||
}
|
||||
|
||||
async function handleMessagesUpdated (newCount: number) {
|
||||
if (newCount === messagesCount) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newCount > messagesCount) {
|
||||
if (scrollToRestore > 0) {
|
||||
void restoreScroll()
|
||||
} else if (dateToJump !== undefined) {
|
||||
await wait()
|
||||
scrollToDate(dateToJump)
|
||||
} else if (newCount > messagesCount) {
|
||||
scrollToNewMessages()
|
||||
}
|
||||
|
||||
messagesCount = newCount
|
||||
}
|
||||
|
||||
$: updateMessagesCount(displayMessages.length)
|
||||
$: handleMessagesUpdated(displayMessages.length)
|
||||
function handleResize () {
|
||||
if (!isInitialScrolling && isScrollInitialized) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-col h-full">
|
||||
{#if startFromBottom}
|
||||
<div class="grower" />
|
||||
{/if}
|
||||
{#if selectedDate}
|
||||
<div class="ml-2 pr-2">
|
||||
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={jumpToDate} />
|
||||
{#if isLoading}
|
||||
<Loading />
|
||||
{:else}
|
||||
<div class="flex-col h-full relative">
|
||||
{#if startFromBottom}
|
||||
<div class="grower" />
|
||||
{/if}
|
||||
{#if selectedDate}
|
||||
<div class="ml-2 pr-2">
|
||||
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={jumpToDate} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isInitialScrolling}
|
||||
<div class="overlay">
|
||||
<Loading />
|
||||
</div>
|
||||
{/if}
|
||||
<Scroller
|
||||
{autoscroll}
|
||||
bottomStart={startFromBottom}
|
||||
bind:this={scroller}
|
||||
bind:divScroll={scrollElement}
|
||||
bind:divBox={scrollContentBox}
|
||||
noStretch={false}
|
||||
onScroll={handleScroll}
|
||||
onResize={handleResize}
|
||||
>
|
||||
{#if loadMoreAllowed && provider.canLoadMore('backward', messages[0]?.createdOn)}
|
||||
<HistoryLoading isLoading={$isLoadingMoreStore} />
|
||||
{/if}
|
||||
<slot name="header" />
|
||||
|
||||
{#each displayMessages as message (message._id)}
|
||||
{@const isSelected = message._id === selectedMessageId}
|
||||
|
||||
{#if message.createdOn === $newTimestampStore}
|
||||
<ActivityMessagesSeparator bind:element={separatorElement} label={activity.string.New} />
|
||||
{/if}
|
||||
|
||||
{#if withDates && message.createdOn && $datesStore.includes(message.createdOn)}
|
||||
<JumpToDateSelector selectedDate={message.createdOn} on:jumpToDate={jumpToDate} />
|
||||
{/if}
|
||||
|
||||
<div class="msg">
|
||||
<ActivityMessagePresenter
|
||||
value={message}
|
||||
skipLabel={skipLabels}
|
||||
{showEmbedded}
|
||||
hoverStyles="filledHover"
|
||||
isHighlighted={isSelected}
|
||||
shouldScroll={isSelected}
|
||||
withShowMore={false}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if loadMoreAllowed && provider.canLoadMore('forward', messages[messages.length - 1]?.createdOn)}
|
||||
<HistoryLoading isLoading={$isLoadingMoreStore} />
|
||||
{/if}
|
||||
</Scroller>
|
||||
</div>
|
||||
{#if object}
|
||||
<div class="ref-input">
|
||||
<ActivityExtensionComponent kind="input" {extensions} props={{ object, boundary: scrollElement, collection }} />
|
||||
</div>
|
||||
{/if}
|
||||
<Scroller
|
||||
{autoscroll}
|
||||
bottomStart={startFromBottom}
|
||||
bind:divScroll={scrollElement}
|
||||
bind:divBox={scrollContentBox}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<slot name="header" />
|
||||
{#each displayMessages as message, index}
|
||||
{@const isSelected = message._id === selectedMessageId}
|
||||
{@const prevMessage = displayMessages[index - 1]}
|
||||
{#if index === separatorPosition}
|
||||
<ActivityMessagesSeparator bind:element={separatorElement} label={activity.string.New} />
|
||||
{/if}
|
||||
|
||||
{#if withDates && (index === 0 || isOtherDay(message.createdOn ?? 0, prevMessage?.createdOn ?? 0))}
|
||||
<JumpToDateSelector selectedDate={message.createdOn} on:jumpToDate={jumpToDate} />
|
||||
{/if}
|
||||
|
||||
<div style:margin="0 1.5rem">
|
||||
<ActivityMessagePresenter
|
||||
value={message}
|
||||
skipLabel={skipLabels}
|
||||
{showEmbedded}
|
||||
hoverStyles="filledHover"
|
||||
isHighlighted={isSelected}
|
||||
shouldScroll={isSelected}
|
||||
withShowMore={false}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</Scroller>
|
||||
</div>
|
||||
{#if object}
|
||||
<div class="ref-input">
|
||||
<ActivityExtensionComponent kind="input" {extensions} props={{ object, boundary: scrollElement, collection }} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.msg {
|
||||
margin: 0 1.5rem;
|
||||
min-height: 4.375rem;
|
||||
height: auto;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.grower {
|
||||
flex-grow: 10;
|
||||
flex-shrink: 5;
|
||||
@ -439,4 +533,15 @@
|
||||
.ref-input {
|
||||
margin: 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--theme-panel-color);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
@ -13,14 +13,12 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Class, Doc, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { defineSeparators, Loading, location as locationStore, panelSeparators, Separator } from '@hcengineering/ui'
|
||||
import { Doc, Ref } from '@hcengineering/core'
|
||||
import { defineSeparators, location as locationStore, panelSeparators, Separator } from '@hcengineering/ui'
|
||||
import { DocNotifyContext } from '@hcengineering/notification'
|
||||
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { combineActivityMessages } from '@hcengineering/activity-resources'
|
||||
import { ActivityMessagesFilter } from '@hcengineering/activity'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Channel } from '@hcengineering/chunter'
|
||||
import attachment from '@hcengineering/attachment'
|
||||
|
||||
import ChannelComponent from './Channel.svelte'
|
||||
import ChannelHeader from './ChannelHeader.svelte'
|
||||
@ -35,12 +33,9 @@
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
const messagesQuery = createQuery()
|
||||
|
||||
let activityMessages: ActivityMessage[] = []
|
||||
let isThreadOpened = false
|
||||
let isAsideShown = false
|
||||
let isLoading = true
|
||||
|
||||
let filters: Ref<ActivityMessagesFilter>[] = []
|
||||
|
||||
@ -52,37 +47,6 @@
|
||||
$: withAside =
|
||||
!embedded && !isThreadOpened && !hierarchy.isDerived(context.attachedToClass, chunter.class.DirectMessage)
|
||||
|
||||
$: updateMessagesQuery(isDocChat ? activity.class.ActivityMessage : chunter.class.ChatMessage, context.attachedTo)
|
||||
|
||||
function updateMessagesQuery (_class: Ref<Class<ActivityMessage>>, attachedTo: Ref<Doc>) {
|
||||
isLoading = true
|
||||
const res = messagesQuery.query(
|
||||
_class,
|
||||
{ attachedTo },
|
||||
(res) => {
|
||||
if (_class === chunter.class.ChatMessage) {
|
||||
activityMessages = res
|
||||
isLoading = false
|
||||
} else {
|
||||
combineActivityMessages(res).then((messages) => {
|
||||
activityMessages = messages
|
||||
isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
sort: { createdOn: SortingOrder.Ascending },
|
||||
lookup: {
|
||||
_id: { attachments: attachment.class.Attachment }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!res) {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function toChannel (object?: Doc) {
|
||||
return object as Channel | undefined
|
||||
}
|
||||
@ -110,31 +74,29 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="popupPanel-body" class:asideShown={withAside && isAsideShown && !isLoading}>
|
||||
{#if isLoading}
|
||||
<Loading />
|
||||
{:else}
|
||||
<div class="popupPanel-body__main">
|
||||
<ChannelComponent {context} {object} {filters} messages={activityMessages} />
|
||||
</div>
|
||||
<div class="popupPanel-body" class:asideShown={withAside && isAsideShown}>
|
||||
<div class="popupPanel-body__main">
|
||||
{#key context._id}
|
||||
<ChannelComponent {context} {object} {filters} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if withAside && isAsideShown && !isLoading}
|
||||
<Separator name="aside" float={false} index={0} />
|
||||
<div class="popupPanel-body__aside" class:float={false} class:shown={withAside && isAsideShown}>
|
||||
<Separator name="aside" float index={0} />
|
||||
<div class="antiPanel-wrap__content">
|
||||
{#if hierarchy.isDerived(context.attachedToClass, chunter.class.Channel)}
|
||||
<ChannelAside
|
||||
_id={toChannelRef(context.attachedTo)}
|
||||
_class={context.attachedToClass}
|
||||
object={toChannel(object)}
|
||||
/>
|
||||
{:else}
|
||||
<DocAside _id={context.attachedTo} _class={context.attachedToClass} {object} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if withAside && isAsideShown}
|
||||
<Separator name="aside" float={false} index={0} />
|
||||
<div class="popupPanel-body__aside" class:float={false} class:shown={withAside && isAsideShown}>
|
||||
<Separator name="aside" float index={0} />
|
||||
<div class="antiPanel-wrap__content">
|
||||
{#if hierarchy.isDerived(context.attachedToClass, chunter.class.Channel)}
|
||||
<ChannelAside
|
||||
_id={toChannelRef(context.attachedTo)}
|
||||
_class={context.attachedToClass}
|
||||
object={toChannel(object)}
|
||||
/>
|
||||
{:else}
|
||||
<DocAside _id={context.attachedTo} _class={context.attachedToClass} {object} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -30,6 +30,7 @@
|
||||
|
||||
<div id={fixed ? '' : time?.toString()} class="flex-center clear-mins dateSelector">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={div}
|
||||
class="border-radius-4 over-underline dateSelectorButton clear-mins"
|
||||
|
@ -0,0 +1,40 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Label } from '@hcengineering/ui'
|
||||
|
||||
import chunter from '../plugin'
|
||||
|
||||
export let isLoading = false
|
||||
</script>
|
||||
|
||||
<div class="loader">
|
||||
{#if isLoading}
|
||||
<Label label={chunter.string.LoadingHistory} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--global-secondary-TextColor);
|
||||
font-weight: 500;
|
||||
height: 5rem;
|
||||
min-height: 5rem;
|
||||
}
|
||||
</style>
|
@ -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<ActivityMessage>
|
||||
export let selectedMessageId: Ref<ActivityMessage> | 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 @@
|
||||
|
||||
<div class="popupPanel-body">
|
||||
<div class="container">
|
||||
{#if message}
|
||||
{#if message && dataProvider !== undefined}
|
||||
<ChannelScrollView
|
||||
{selectedMessageId}
|
||||
{messages}
|
||||
withDates={false}
|
||||
skipLabels
|
||||
object={message}
|
||||
objectId={message._id}
|
||||
objectClass={message._class}
|
||||
provider={dataProvider}
|
||||
loadMoreAllowed={false}
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="mt-3 mr-6 ml-6">
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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<Account> | Arra
|
||||
await client.update(channel, { $pull: { members: value } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChannelMessages (
|
||||
messages: DisplayActivityMessage[],
|
||||
context: DocNotifyContext | undefined
|
||||
): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,9 @@ export function isIssueId (shortLink: string): boolean {
|
||||
|
||||
export async function issueIdentifierProvider (client: TxOperations, ref: Ref<Doc>): Promise<string> {
|
||||
const object = await client.findOne(tracker.class.Issue, { _id: ref as Ref<Issue> })
|
||||
if (object === undefined) throw new Error(`Issue project not found, _id: ${ref}`)
|
||||
if (object === undefined) {
|
||||
return ''
|
||||
}
|
||||
return object.identifier
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user