mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 03:14:40 +03:00
Use reverse scroll in chat (#6736)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
parent
a698889f61
commit
509952de4e
@ -54,9 +54,11 @@ import view, { type AttributeCategory, type AttributeEditor } from '@hcengineeri
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { get, writable, type Writable } from 'svelte/store'
|
||||
|
||||
import { type KeyedAttribute } from '..'
|
||||
import { OptimizeQueryMiddleware, PresentationPipelineImpl, type PresentationPipeline } from './pipeline'
|
||||
import plugin from './plugin'
|
||||
|
||||
export { reduceCalls } from '@hcengineering/core'
|
||||
|
||||
let liveQuery: LQ
|
||||
|
@ -31,6 +31,7 @@
|
||||
export let fade: FadeOptions = defaultSP
|
||||
export let noFade: boolean = true
|
||||
export let invertScroll: boolean = false
|
||||
export let scrollDirection: 'vertical' | 'vertical-reverse' = 'vertical'
|
||||
export let contentDirection: 'vertical' | 'vertical-reverse' | 'horizontal' = 'vertical'
|
||||
export let horizontal: boolean = contentDirection === 'horizontal'
|
||||
export let align: 'start' | 'center' | 'end' | 'stretch' = 'stretch'
|
||||
@ -39,7 +40,7 @@
|
||||
export let buttons: 'normal' | 'union' | false = false
|
||||
export let shrink: boolean = false
|
||||
export let divScroll: HTMLElement | undefined | null = undefined
|
||||
export let divBox: HTMLElement | undefined = undefined
|
||||
export let divBox: HTMLElement | undefined | null = undefined
|
||||
export let checkForHeaders: boolean = false
|
||||
export let stickedScrollBars: boolean = false
|
||||
export let thinScrollBars: boolean = false
|
||||
@ -50,7 +51,7 @@
|
||||
export function scroll (top: number, left?: number, behavior: 'auto' | 'smooth' = 'auto') {
|
||||
if (divScroll) {
|
||||
if (top !== 0) divScroll.scroll({ top, left: 0, behavior })
|
||||
if (left !== 0 || left !== undefined) divScroll.scroll({ top: 0, left, behavior })
|
||||
if (left !== 0 && left !== undefined) divScroll.scroll({ top: 0, left, behavior })
|
||||
}
|
||||
}
|
||||
export function scrollBy (top: number, left?: number, behavior: 'auto' | 'smooth' = 'auto') {
|
||||
@ -102,11 +103,19 @@
|
||||
const scrollH = divScroll.scrollHeight
|
||||
const proc = scrollH / trackH
|
||||
|
||||
const newHeight = (divScroll.clientHeight - 4) / proc + 'px'
|
||||
const newHeight = (divScroll.clientHeight - 4) / proc
|
||||
const newHeightPx = newHeight + 'px'
|
||||
if (divBar.style.height !== 'newHeight') {
|
||||
divBar.style.height = newHeight
|
||||
divBar.style.height = newHeightPx
|
||||
}
|
||||
|
||||
let newTop = '0px'
|
||||
|
||||
if (scrollDirection === 'vertical-reverse') {
|
||||
newTop = divScroll.clientHeight + divScroll.scrollTop / proc - newHeight - shiftTop - 2 + 'px'
|
||||
} else {
|
||||
newTop = divScroll.scrollTop / proc + shiftTop + 2 + 'px'
|
||||
}
|
||||
const newTop = divScroll.scrollTop / proc + shiftTop + 2 + 'px'
|
||||
if (divBar.style.top !== newTop) {
|
||||
divBar.style.top = newTop
|
||||
}
|
||||
@ -542,7 +551,8 @@
|
||||
divHeight = element.clientHeight
|
||||
onResize?.()
|
||||
}}
|
||||
class="scroll relative flex-shrink"
|
||||
class="scroll relative flex-shrink flex-col"
|
||||
style:flex-direction={scrollDirection === 'vertical-reverse' ? 'column-reverse' : 'column'}
|
||||
class:disableOverscroll
|
||||
style:overflow-x={horizontal ? 'auto' : 'hidden'}
|
||||
on:scroll={() => {
|
||||
|
@ -1,3 +1,17 @@
|
||||
//
|
||||
// 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 activity, { type ActivityMessage, type SavedMessage } from '@hcengineering/activity'
|
||||
import core, { type Ref, SortingOrder, type WithLookup } from '@hcengineering/core'
|
||||
import { writable } from 'svelte/store'
|
||||
|
@ -279,6 +279,7 @@
|
||||
hideLink={true}
|
||||
type={canGroup ? 'short' : 'default'}
|
||||
isHighlighted={selectedMessageId === message._id}
|
||||
withShowMore
|
||||
/>
|
||||
{:else}
|
||||
<Lazy>
|
||||
@ -288,6 +289,7 @@
|
||||
hideLink={true}
|
||||
type={canGroup ? 'short' : 'default'}
|
||||
isHighlighted={selectedMessageId === message._id}
|
||||
withShowMore
|
||||
/>
|
||||
</Lazy>
|
||||
{/if}
|
||||
|
@ -33,7 +33,7 @@
|
||||
export let actions: Action[] = []
|
||||
export let hoverable = true
|
||||
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
|
||||
export let withShowMore: boolean = true
|
||||
export let withShowMore: boolean = false
|
||||
export let attachmentImageSize: 'x-large' | undefined = undefined
|
||||
export let type: ActivityMessageViewType = 'default'
|
||||
export let videoPreload = true
|
||||
|
@ -27,7 +27,6 @@
|
||||
export let readonly: boolean = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
const me = getCurrentAccount()
|
||||
|
||||
let reactionsAccounts = new Map<string, Ref<Account>[]>()
|
||||
|
@ -55,7 +55,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if object && reactions.length > 0}
|
||||
{#if object && (reactions.length > 0 || (object?.reactions ?? 0) > 0)}
|
||||
<div class="footer flex-col p-inline contrast mt-2 min-h-6">
|
||||
<Reactions {reactions} {object} {readonly} on:click={handleClick} />
|
||||
</div>
|
||||
|
@ -330,12 +330,11 @@ export class ChannelDataProvider implements IChannelDataProvider {
|
||||
const client = getClient()
|
||||
const skipIds = this.getChunkSkipIds(loadAfter)
|
||||
|
||||
const messages = await client.findAll(
|
||||
let messages: ActivityMessage[] = await client.findAll(
|
||||
this.msgClass,
|
||||
{
|
||||
attachedTo: this.chatId,
|
||||
space: this.space,
|
||||
_id: { $nin: skipIds },
|
||||
createdOn: equal
|
||||
? isBackward
|
||||
? { $lte: loadAfter }
|
||||
@ -351,6 +350,8 @@ export class ChannelDataProvider implements IChannelDataProvider {
|
||||
}
|
||||
)
|
||||
|
||||
messages = messages.filter(({ _id }) => !skipIds.includes(_id))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
@ -0,0 +1,56 @@
|
||||
<!--
|
||||
// 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 { Loading, Scroller } from '@hcengineering/ui'
|
||||
|
||||
export let scroller: Scroller | undefined | null = undefined
|
||||
export let scrollDiv: HTMLDivElement | undefined | null = undefined
|
||||
export let contentDiv: HTMLDivElement | undefined | null = undefined
|
||||
export let loadingOverlay: boolean = false
|
||||
export let onScroll: () => void = () => {}
|
||||
export let onResize: () => void = () => {}
|
||||
</script>
|
||||
|
||||
{#if loadingOverlay}
|
||||
<div class="overlay">
|
||||
<Loading />
|
||||
</div>
|
||||
{/if}
|
||||
<Scroller
|
||||
bind:this={scroller}
|
||||
bind:divScroll={scrollDiv}
|
||||
bind:divBox={contentDiv}
|
||||
scrollDirection="vertical-reverse"
|
||||
noStretch
|
||||
bottomStart
|
||||
disableOverscroll
|
||||
{onScroll}
|
||||
{onResize}
|
||||
>
|
||||
<slot />
|
||||
</Scroller>
|
||||
|
||||
<style lang="scss">
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: var(--theme-panel-color);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
@ -19,11 +19,11 @@
|
||||
import { getClient, isSpace } from '@hcengineering/presentation'
|
||||
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
|
||||
import { location as locationStore } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import chunter from '../plugin'
|
||||
import ChannelScrollView from './ChannelScrollView.svelte'
|
||||
import { ChannelDataProvider } from '../channelDataProvider'
|
||||
import { onDestroy } from 'svelte'
|
||||
import ReverseChannelScrollView from './ReverseChannelScrollView.svelte'
|
||||
|
||||
export let object: Doc
|
||||
export let context: DocNotifyContext | undefined
|
||||
@ -101,17 +101,13 @@
|
||||
</script>
|
||||
|
||||
{#if dataProvider}
|
||||
<ChannelScrollView
|
||||
{object}
|
||||
<ReverseChannelScrollView
|
||||
channel={object}
|
||||
skipLabels={!isDocChannel}
|
||||
selectedFilters={filters}
|
||||
startFromBottom
|
||||
bind:selectedMessageId
|
||||
{object}
|
||||
{collection}
|
||||
provider={dataProvider}
|
||||
{isAsideOpened}
|
||||
loadMoreAllowed={!isDocChannel}
|
||||
{freeze}
|
||||
loadMoreAllowed={!isDocChannel}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -74,7 +74,7 @@
|
||||
intlLabel={chunter.string.Channel}
|
||||
{description}
|
||||
titleKind={isPerson ? 'default' : 'breadcrumbs'}
|
||||
withFilters={!hierarchy.isDerived(_class, chunter.class.ChunterSpace)}
|
||||
withFilters={false}
|
||||
{allowClose}
|
||||
{canOpen}
|
||||
{withAside}
|
||||
|
@ -92,7 +92,7 @@
|
||||
<style lang="scss">
|
||||
.ref-input {
|
||||
flex-shrink: 0;
|
||||
margin: 1.25rem 1rem 0;
|
||||
margin: 0 1rem;
|
||||
max-height: 18.75rem;
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1rem;
|
||||
margin: 0 1rem 1rem;
|
||||
padding: 0.5rem 0;
|
||||
color: var(--global-primary-TextColor);
|
||||
background: var(--global-ui-BorderColor);
|
||||
|
@ -19,7 +19,7 @@
|
||||
export let label: IntlString
|
||||
export let params: any = undefined
|
||||
|
||||
export let element: HTMLDivElement | undefined = undefined
|
||||
export let element: HTMLDivElement | undefined | null = undefined
|
||||
</script>
|
||||
|
||||
<div class="w-full text-sm flex-center whitespace-nowrap" bind:this={element}>
|
||||
|
@ -43,6 +43,7 @@
|
||||
import JumpToDateSelector from './JumpToDateSelector.svelte'
|
||||
import HistoryLoading from './LoadingHistory.svelte'
|
||||
import ChannelInput from './ChannelInput.svelte'
|
||||
import { messageInView } from '../scroll'
|
||||
|
||||
export let provider: ChannelDataProvider
|
||||
export let object: Doc
|
||||
@ -376,12 +377,6 @@
|
||||
return messageInView(msgElement, containerRect)
|
||||
}
|
||||
|
||||
function messageInView (msgElement: Element, containerRect: DOMRect): boolean {
|
||||
const messageRect = msgElement.getBoundingClientRect()
|
||||
|
||||
return messageRect.top >= containerRect.top && messageRect.top <= containerRect.bottom && messageRect.bottom >= 0
|
||||
}
|
||||
|
||||
const messagesToReadAccumulator: Set<DisplayActivityMessage> = new Set<DisplayActivityMessage>()
|
||||
let messagesToReadAccumulatorTimer: any
|
||||
|
||||
|
@ -71,9 +71,11 @@
|
||||
$: if (tab.data.thread === undefined) {
|
||||
renderChannel = true
|
||||
}
|
||||
|
||||
$: visible = height !== '0px' && width !== '0px'
|
||||
</script>
|
||||
|
||||
{#if object && renderChannel}
|
||||
{#if object && renderChannel && visible}
|
||||
<div class="channel" class:invisible={threadId !== undefined} style:height style:width>
|
||||
<ChannelHeader
|
||||
_id={object._id}
|
||||
@ -92,7 +94,7 @@
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
{#if threadId}
|
||||
{#if threadId && visible}
|
||||
<div class="thread" style:height style:width>
|
||||
<ThreadView
|
||||
_id={threadId}
|
||||
|
@ -19,6 +19,8 @@
|
||||
|
||||
export let selectedDate: Timestamp | undefined
|
||||
export let fixed: boolean = false
|
||||
export let idPrefix: string = ''
|
||||
export let visible: boolean = true
|
||||
|
||||
let div: HTMLDivElement | undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
@ -28,30 +30,32 @@
|
||||
$: isCurrentYear = time ? new Date(time).getFullYear() === new Date().getFullYear() : undefined
|
||||
</script>
|
||||
|
||||
<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 dateSelectorButton clear-mins"
|
||||
on:click={() => {
|
||||
showPopup(DateRangePopup, {}, div, (v) => {
|
||||
if (v) {
|
||||
v.setHours(0, 0, 0, 0)
|
||||
dispatch('jumpToDate', { date: v.getTime() })
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{#if time}
|
||||
{new Date(time).toLocaleDateString('default', {
|
||||
weekday: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: isCurrentYear ? undefined : 'numeric'
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
<div id={fixed ? '' : `${idPrefix}${time?.toString()}`} class="flex-center clear-mins dateSelector">
|
||||
{#if visible}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={div}
|
||||
class="border-radius-4 dateSelectorButton clear-mins"
|
||||
on:click={() => {
|
||||
showPopup(DateRangePopup, {}, div, (v) => {
|
||||
if (v) {
|
||||
v.setHours(0, 0, 0, 0)
|
||||
dispatch('jumpToDate', { date: v.getTime() })
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{#if time}
|
||||
{new Date(time).toLocaleDateString('default', {
|
||||
weekday: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: isCurrentYear ? undefined : 'numeric'
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -0,0 +1,669 @@
|
||||
<!--
|
||||
// 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 core, {
|
||||
Doc,
|
||||
generateId,
|
||||
getCurrentAccount,
|
||||
Ref,
|
||||
Space,
|
||||
Timestamp,
|
||||
Tx,
|
||||
TxCollectionCUD,
|
||||
TxProcessor
|
||||
} from '@hcengineering/core'
|
||||
import activity, { ActivityMessage } from '@hcengineering/activity'
|
||||
import { ModernButton, Scroller } from '@hcengineering/ui'
|
||||
import { addTxListener, getClient, removeTxListener } from '@hcengineering/presentation'
|
||||
import { ActivityMessagePresenter, canGroupMessages, messageInFocus } from '@hcengineering/activity-resources'
|
||||
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
|
||||
import { afterUpdate, onDestroy, onMount, tick } from 'svelte'
|
||||
import { DocNotifyContext } from '@hcengineering/notification'
|
||||
|
||||
import HistoryLoading from './LoadingHistory.svelte'
|
||||
import { chatReadMessagesStore, recheckNotifications } from '../utils'
|
||||
import { getScrollToDateOffset, getSelectedDate, jumpToDate, readViewportMessages } from '../scroll'
|
||||
import chunter from '../plugin'
|
||||
import BlankView from './BlankView.svelte'
|
||||
import ActivityMessagesSeparator from './ChannelMessagesSeparator.svelte'
|
||||
import JumpToDateSelector from './JumpToDateSelector.svelte'
|
||||
import BaseChatScroller from './BaseChatScroller.svelte'
|
||||
import { ChannelDataProvider, MessageMetadata } from '../channelDataProvider'
|
||||
import ChannelInput from './ChannelInput.svelte'
|
||||
|
||||
export let provider: ChannelDataProvider
|
||||
export let object: Doc
|
||||
export let channel: Doc
|
||||
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
|
||||
export let fixedInput = true
|
||||
export let collection: string = 'messages'
|
||||
export let fullHeight = true
|
||||
export let freeze = false
|
||||
export let loadMoreAllowed = true
|
||||
|
||||
const minMsgHeightRem = 2
|
||||
const loadMoreThreshold = 200
|
||||
const newSeparatorOffset = 150
|
||||
|
||||
const me = getCurrentAccount()
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
const inboxClient = InboxNotificationsClientImpl.getClient()
|
||||
const contextByDocStore = inboxClient.contextByDoc
|
||||
const notificationsByContextStore = inboxClient.inboxNotificationsByContext
|
||||
|
||||
// Stores
|
||||
const metadataStore = provider.metadataStore
|
||||
const messagesStore = provider.messagesStore
|
||||
const isLoadingStore = provider.isLoadingStore
|
||||
const isTailLoadedStore = provider.isTailLoaded
|
||||
const newTimestampStore = provider.newTimestampStore
|
||||
const datesStore = provider.datesStore
|
||||
const canLoadNextForwardStore = provider.canLoadNextForwardStore
|
||||
const isLoadingMoreStore = provider.isLoadingMoreStore
|
||||
|
||||
const doc = object
|
||||
const uuid = generateId()
|
||||
|
||||
let messages: ActivityMessage[] = []
|
||||
let messagesCount = 0
|
||||
|
||||
// Elements
|
||||
let scroller: Scroller | undefined | null = undefined
|
||||
let scrollDiv: HTMLDivElement | undefined | null = undefined
|
||||
let contentDiv: HTMLDivElement | undefined | null = undefined
|
||||
let separatorDiv: HTMLDivElement | undefined | null = undefined
|
||||
|
||||
// Dates
|
||||
let selectedDate: Timestamp | undefined = undefined
|
||||
let dateToJump: Timestamp | undefined = undefined
|
||||
|
||||
// Scrolling
|
||||
let isScrollInitialized = false
|
||||
let shouldScrollToNew = false
|
||||
let isScrollAtBottom = false
|
||||
|
||||
let isLatestMessageButtonVisible = false
|
||||
|
||||
// Pagination
|
||||
let backwardRequested = false
|
||||
let restoreScrollTop = 0
|
||||
let restoreScrollHeight = 0
|
||||
|
||||
let isPageHidden = false
|
||||
let lastMsgBeforeFreeze: Ref<ActivityMessage> | undefined = undefined
|
||||
|
||||
$: messages = $messagesStore
|
||||
$: notifyContext = $contextByDocStore.get(doc._id)
|
||||
$: isThread = hierarchy.isDerived(doc._class, activity.class.ActivityMessage)
|
||||
$: isChunterSpace = hierarchy.isDerived(doc._class, chunter.class.ChunterSpace)
|
||||
$: readonly = hierarchy.isDerived(channel._class, core.class.Space) ? (channel as Space).archived : false
|
||||
|
||||
$: separatorIndex =
|
||||
$newTimestampStore !== undefined
|
||||
? messages.findIndex((message) => (message.createdOn ?? 0) >= ($newTimestampStore ?? 0))
|
||||
: -1
|
||||
|
||||
$: if (!freeze && !isPageHidden && isScrollInitialized) {
|
||||
read()
|
||||
}
|
||||
|
||||
const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => {
|
||||
if (notifyContext !== undefined && !isFreeze()) {
|
||||
recheckNotifications(notifyContext)
|
||||
read()
|
||||
}
|
||||
})
|
||||
|
||||
$: void initializeScroll($isLoadingStore, separatorDiv, separatorIndex)
|
||||
$: adjustScrollPosition(selectedMessageId)
|
||||
$: void handleMessagesUpdated(messages.length)
|
||||
|
||||
function adjustScrollPosition (selectedMessageId?: Ref<ActivityMessage>): void {
|
||||
if ($isLoadingStore || !isScrollInitialized) {
|
||||
return
|
||||
}
|
||||
const msgData = $metadataStore.find(({ _id }) => _id === selectedMessageId)
|
||||
if (msgData !== undefined) {
|
||||
const isReload = provider.jumpToMessage(msgData)
|
||||
if (isReload) {
|
||||
reinitializeScroll()
|
||||
} else {
|
||||
scrollToMessage()
|
||||
}
|
||||
} else if (selectedMessageId === undefined) {
|
||||
provider.jumpToEnd()
|
||||
reinitializeScroll()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange (): void {
|
||||
if (document.hidden) {
|
||||
isPageHidden = true
|
||||
lastMsgBeforeFreeze = shouldScrollToNew ? messages[messages.length - 1]?._id : undefined
|
||||
} else {
|
||||
if (isPageHidden) {
|
||||
isPageHidden = false
|
||||
void provider.updateNewTimestamp(notifyContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFreeze (): boolean {
|
||||
return freeze || isPageHidden
|
||||
}
|
||||
|
||||
function scrollToBottom (): void {
|
||||
if (scroller != null && scrollDiv != null && !isFreeze()) {
|
||||
scrollDiv.scroll({ top: 0, behavior: 'instant' })
|
||||
updateSelectedDate()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToSeparator (): void {
|
||||
if (separatorDiv == null || scrollDiv == null || contentDiv == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagesElements = contentDiv?.getElementsByClassName('activityMessage')
|
||||
const messagesHeight = messages
|
||||
.slice(separatorIndex)
|
||||
.reduce((res, msg) => res + (messagesElements?.[msg._id as any]?.clientHeight ?? 0), 0)
|
||||
|
||||
separatorDiv.scrollIntoView()
|
||||
|
||||
if (messagesHeight >= scrollDiv.clientHeight) {
|
||||
scroller?.scrollBy(-newSeparatorOffset)
|
||||
}
|
||||
|
||||
updateShouldScrollToNew()
|
||||
read()
|
||||
}
|
||||
|
||||
function scrollToMessage (): void {
|
||||
if (selectedMessageId === undefined) return
|
||||
if (scrollDiv == null || contentDiv == null) {
|
||||
setTimeout(scrollToMessage, 50)
|
||||
return
|
||||
}
|
||||
|
||||
const messagesElements = contentDiv?.getElementsByClassName('activityMessage')
|
||||
const msgElement = messagesElements?.[selectedMessageId as any]
|
||||
|
||||
if (msgElement == null) {
|
||||
if (messages.some(({ _id }) => _id === selectedMessageId)) {
|
||||
setTimeout(scrollToMessage, 50)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
msgElement.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
read()
|
||||
}
|
||||
|
||||
function scrollToStartOfNew (): void {
|
||||
if (scrollDiv == null || lastMsgBeforeFreeze === undefined) return
|
||||
|
||||
const lastIndex = messages.findIndex(({ _id }) => _id === lastMsgBeforeFreeze)
|
||||
if (lastIndex === -1) return
|
||||
const firstNewMessage = messages.find(({ createdBy }, index) => index > lastIndex && createdBy !== me._id)
|
||||
|
||||
if (firstNewMessage === undefined) {
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const messagesElements = contentDiv?.getElementsByClassName('activityMessage')
|
||||
const msgElement = messagesElements?.[firstNewMessage._id as any]
|
||||
|
||||
if (msgElement == null) return
|
||||
|
||||
const messageRect = msgElement.getBoundingClientRect()
|
||||
const topOffset = messageRect.top - newSeparatorOffset
|
||||
|
||||
if (topOffset < 0) {
|
||||
scroller?.scrollBy(topOffset)
|
||||
} else if (topOffset > 0) {
|
||||
scroller?.scrollBy(topOffset)
|
||||
}
|
||||
}
|
||||
|
||||
function updateShouldScrollToNew (): void {
|
||||
if (scrollDiv != null && contentDiv != null) {
|
||||
const { scrollTop } = scrollDiv
|
||||
const offset = 100
|
||||
|
||||
shouldScrollToNew = Math.abs(scrollTop) < offset
|
||||
}
|
||||
}
|
||||
|
||||
async function wait (): Promise<void> {
|
||||
// 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 initializeScroll (
|
||||
isLoading: boolean,
|
||||
separatorElement?: HTMLDivElement | null,
|
||||
separatorIndex?: number
|
||||
): Promise<void> {
|
||||
if (isLoading || isScrollInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedMessageExists =
|
||||
selectedMessageId !== undefined && messages.some(({ _id }) => _id === selectedMessageId)
|
||||
if (selectedMessageExists) {
|
||||
await wait()
|
||||
scrollToMessage()
|
||||
isScrollInitialized = true
|
||||
} else if (separatorIndex === -1) {
|
||||
isScrollInitialized = true
|
||||
shouldScrollToNew = true
|
||||
isScrollAtBottom = true
|
||||
} else if (separatorElement != null) {
|
||||
await wait()
|
||||
scrollToSeparator()
|
||||
isScrollInitialized = true
|
||||
}
|
||||
|
||||
if (isScrollInitialized) {
|
||||
await wait()
|
||||
updateSelectedDate()
|
||||
updateScrollData()
|
||||
updateDownButtonVisibility($metadataStore, messages, scrollDiv)
|
||||
}
|
||||
}
|
||||
|
||||
function reinitializeScroll (): void {
|
||||
isScrollInitialized = false
|
||||
void initializeScroll($isLoadingStore, separatorDiv, separatorIndex)
|
||||
}
|
||||
|
||||
function handleJumpToDate (e: CustomEvent<{ date?: Timestamp }>): void {
|
||||
const result = jumpToDate(e, provider, uuid, scrollDiv)
|
||||
|
||||
dateToJump = result.dateToJump
|
||||
|
||||
if (result.scrollOffset !== undefined && result.scrollOffset !== 0 && scroller != null) {
|
||||
scroller?.scroll(result.scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToDate (date: Timestamp): void {
|
||||
const offset = getScrollToDateOffset(date, uuid)
|
||||
|
||||
if (offset !== undefined && offset !== 0 && scroller != null) {
|
||||
scroller?.scroll(offset)
|
||||
dateToJump = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedDate (): void {
|
||||
if (isThread) return
|
||||
selectedDate = getSelectedDate(provider, uuid, scrollDiv, contentDiv)
|
||||
}
|
||||
|
||||
function read (): void {
|
||||
if (isFreeze() || notifyContext === undefined || !isScrollInitialized) return
|
||||
readViewportMessages(messages, notifyContext, scrollDiv, contentDiv)
|
||||
}
|
||||
|
||||
function updateScrollData (): void {
|
||||
if (scrollDiv == null) return
|
||||
const { scrollTop } = scrollDiv
|
||||
|
||||
isScrollAtBottom = Math.abs(scrollTop) < 50
|
||||
}
|
||||
|
||||
function canGroupChatMessages (message: ActivityMessage, prevMessage?: ActivityMessage): boolean {
|
||||
let prevMetadata: MessageMetadata | undefined = undefined
|
||||
|
||||
if (prevMessage === undefined) {
|
||||
const metadata = $metadataStore
|
||||
prevMetadata = metadata.find((_, index) => metadata[index + 1]?._id === message._id)
|
||||
}
|
||||
|
||||
return canGroupMessages(message, prevMessage ?? prevMetadata)
|
||||
}
|
||||
|
||||
$: updateDownButtonVisibility($metadataStore, messages, scrollDiv)
|
||||
|
||||
function updateDownButtonVisibility (
|
||||
metadata: MessageMetadata[],
|
||||
messages: ActivityMessage[],
|
||||
scrollDiv?: HTMLDivElement | null
|
||||
): void {
|
||||
if (metadata.length === 0 || messages.length === 0) {
|
||||
isLatestMessageButtonVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!$isTailLoadedStore) {
|
||||
isLatestMessageButtonVisible = true
|
||||
} else if (scrollDiv != null) {
|
||||
const { scrollTop } = scrollDiv
|
||||
|
||||
isLatestMessageButtonVisible = Math.abs(scrollTop) > 200
|
||||
} else {
|
||||
isLatestMessageButtonVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScrollToLatestMessage (): Promise<void> {
|
||||
selectedMessageId = undefined
|
||||
messageInFocus.set(undefined)
|
||||
|
||||
const metadata = $metadataStore
|
||||
const lastMetadata = metadata[metadata.length - 1]
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
if (lastMetadata._id !== lastMessage._id) {
|
||||
separatorIndex = -1
|
||||
provider.jumpToEnd(true)
|
||||
reinitializeScroll()
|
||||
} else {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const op = client.apply(undefined, 'chunter.scrollDown')
|
||||
await inboxClient.readDoc(op, doc._id)
|
||||
await op.commit()
|
||||
}
|
||||
|
||||
let forceRead = false
|
||||
$: void forceReadContext(isScrollAtBottom, notifyContext)
|
||||
|
||||
async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise<void> {
|
||||
if (context === undefined || !isScrollAtBottom || forceRead || isFreeze()) return
|
||||
const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context
|
||||
|
||||
if (lastViewedTimestamp >= lastUpdateTimestamp) return
|
||||
|
||||
const notifications = $notificationsByContextStore.get(context._id) ?? []
|
||||
const unViewed = notifications.filter(({ isViewed }) => !isViewed)
|
||||
|
||||
if (unViewed.length === 0) {
|
||||
forceRead = true
|
||||
const op = client.apply(undefined, 'chunter.forceReadContext')
|
||||
await inboxClient.readDoc(op, object._id)
|
||||
await op.commit()
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLoadMoreUp (): boolean {
|
||||
if (scrollDiv == null) return false
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollDiv
|
||||
|
||||
return scrollHeight + Math.ceil(scrollTop - clientHeight) <= loadMoreThreshold
|
||||
}
|
||||
|
||||
function shouldLoadMoreDown (): boolean {
|
||||
if (scrollDiv == null) return false
|
||||
|
||||
return Math.abs(scrollDiv.scrollTop) <= loadMoreThreshold
|
||||
}
|
||||
|
||||
function loadMore (): void {
|
||||
if (!loadMoreAllowed || $isLoadingMoreStore || scrollDiv == null || !isScrollInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const minMsgHeightPx = minMsgHeightRem * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
const maxMsgPerScreen = Math.ceil(scrollDiv.clientHeight / minMsgHeightPx)
|
||||
const limit = Math.max(maxMsgPerScreen, provider.limit)
|
||||
const isLoadMoreUp = shouldLoadMoreUp()
|
||||
const isLoadMoreDown = shouldLoadMoreDown()
|
||||
|
||||
if (!isLoadMoreUp && backwardRequested) {
|
||||
backwardRequested = false
|
||||
}
|
||||
|
||||
if (isLoadMoreUp && !backwardRequested) {
|
||||
shouldScrollToNew = false
|
||||
restoreScrollTop = scrollDiv?.scrollTop ?? 0
|
||||
restoreScrollHeight = 0
|
||||
void provider.addNextChunk('backward', messages[0]?.createdOn, limit)
|
||||
backwardRequested = true
|
||||
} else if (isLoadMoreUp && backwardRequested) {
|
||||
restoreScrollTop = scrollDiv?.scrollTop ?? 0
|
||||
} else if (isLoadMoreDown && !$isTailLoadedStore) {
|
||||
restoreScrollTop = 0
|
||||
restoreScrollHeight = scrollDiv?.scrollHeight ?? 0
|
||||
shouldScrollToNew = false
|
||||
isScrollAtBottom = false
|
||||
void provider.addNextChunk('forward', messages[messages.length - 1]?.createdOn, limit)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreScroll (): Promise<void> {
|
||||
await wait()
|
||||
|
||||
if (scrollDiv == null || scroller == null) return
|
||||
|
||||
if (restoreScrollTop !== 0) {
|
||||
scroller.scroll(restoreScrollTop)
|
||||
} else if (restoreScrollHeight !== 0) {
|
||||
const delta = restoreScrollHeight - scrollDiv.scrollHeight
|
||||
scroller.scroll(delta)
|
||||
}
|
||||
backwardRequested = false
|
||||
restoreScrollHeight = 0
|
||||
restoreScrollTop = 0
|
||||
dateToJump = 0
|
||||
}
|
||||
|
||||
function scrollToNewMessages (): void {
|
||||
if (scrollDiv == null || !shouldScrollToNew) {
|
||||
read()
|
||||
return
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
read()
|
||||
}
|
||||
|
||||
async function handleMessagesUpdated (newCount: number): Promise<void> {
|
||||
if (newCount === messagesCount) {
|
||||
return
|
||||
}
|
||||
|
||||
const prevCount = messagesCount
|
||||
messagesCount = newCount
|
||||
|
||||
if (isFreeze()) {
|
||||
await wait()
|
||||
scrollToStartOfNew()
|
||||
return
|
||||
}
|
||||
|
||||
if (restoreScrollTop !== 0 || restoreScrollHeight !== 0) {
|
||||
void restoreScroll()
|
||||
} else if (dateToJump !== undefined) {
|
||||
await wait()
|
||||
scrollToDate(dateToJump)
|
||||
} else if (shouldScrollToNew && prevCount > 0 && newCount > prevCount) {
|
||||
await wait()
|
||||
scrollToNewMessages()
|
||||
} else {
|
||||
await wait()
|
||||
read()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScroll (): Promise<void> {
|
||||
updateScrollData()
|
||||
updateDownButtonVisibility($metadataStore, messages, scrollDiv)
|
||||
updateShouldScrollToNew()
|
||||
loadMore()
|
||||
updateSelectedDate()
|
||||
read()
|
||||
}
|
||||
|
||||
function handleResize (): void {
|
||||
if (!isScrollInitialized) return
|
||||
if (shouldScrollToNew) {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
loadMore()
|
||||
}
|
||||
|
||||
const newMessageTxListener = (tx: Tx): void => {
|
||||
if (tx._class !== core.class.TxCollectionCUD) return
|
||||
const ctx = tx as TxCollectionCUD<Doc, ActivityMessage>
|
||||
if (ctx.objectId !== doc._id) return
|
||||
const etx = TxProcessor.extractTx(tx)
|
||||
if (etx._class !== core.class.TxCreateDoc) return
|
||||
if (shouldScrollToNew) {
|
||||
void wait().then(scrollToNewMessages)
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
if (isFreeze()) {
|
||||
updateScrollData()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
chatReadMessagesStore.update(() => new Set())
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
addTxListener(newMessageTxListener)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
removeTxListener(newMessageTxListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex-col relative" class:h-full={fullHeight}>
|
||||
{#if !isThread && messages.length > 0 && selectedDate}
|
||||
<div class="selectedDate">
|
||||
<JumpToDateSelector {selectedDate} fixed on:jumpToDate={handleJumpToDate} idPrefix={`${uuid}-`} />
|
||||
</div>
|
||||
{/if}
|
||||
<BaseChatScroller
|
||||
bind:scroller
|
||||
bind:scrollDiv
|
||||
bind:contentDiv
|
||||
loadingOverlay={$isLoadingStore || !isScrollInitialized}
|
||||
onScroll={handleScroll}
|
||||
onResize={handleResize}
|
||||
>
|
||||
{#if !$isLoadingStore && messages.length === 0 && !isThread && !readonly}
|
||||
<BlankView
|
||||
icon={chunter.icon.Thread}
|
||||
header={chunter.string.NoMessagesInChannel}
|
||||
label={chunter.string.SendMessagesInChannel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if loadMoreAllowed && !isThread}
|
||||
<HistoryLoading isLoading={$isLoadingMoreStore} />
|
||||
{/if}
|
||||
|
||||
<slot name="header" />
|
||||
|
||||
{#each messages as message, index (message._id)}
|
||||
{@const isSelected = message._id === selectedMessageId}
|
||||
{@const canGroup = canGroupChatMessages(message, messages[index - 1])}
|
||||
{#if separatorIndex === index}
|
||||
<ActivityMessagesSeparator bind:element={separatorDiv} label={activity.string.New} />
|
||||
{/if}
|
||||
|
||||
{#if !isThread && message.createdOn && $datesStore.includes(message.createdOn)}
|
||||
<JumpToDateSelector
|
||||
idPrefix={`${uuid}-`}
|
||||
visible={selectedDate !== message.createdOn}
|
||||
selectedDate={message.createdOn}
|
||||
on:jumpToDate={handleJumpToDate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ActivityMessagePresenter
|
||||
{doc}
|
||||
value={message}
|
||||
skipLabel={isThread || isChunterSpace}
|
||||
hoverStyles="filledHover"
|
||||
attachmentImageSize="x-large"
|
||||
type={canGroup ? 'short' : 'default'}
|
||||
isHighlighted={isSelected}
|
||||
shouldScroll={false}
|
||||
{readonly}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if messages.length > 0}
|
||||
<div class="h-4" />
|
||||
{/if}
|
||||
|
||||
{#if loadMoreAllowed && $canLoadNextForwardStore}
|
||||
<HistoryLoading isLoading={$isLoadingMoreStore} />
|
||||
{/if}
|
||||
{#if !fixedInput}
|
||||
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} />
|
||||
{/if}
|
||||
</BaseChatScroller>
|
||||
{#if !isThread && isLatestMessageButtonVisible}
|
||||
<div class="down-button absolute">
|
||||
<ModernButton
|
||||
label={chunter.string.LatestMessages}
|
||||
shape="round"
|
||||
size="small"
|
||||
kind="primary"
|
||||
on:click={handleScrollToLatestMessage}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if fixedInput}
|
||||
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.selectedDate {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.down-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
bottom: 0.5rem;
|
||||
animation: 0.5s fadeIn;
|
||||
animation-fill-mode: forwards;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
99% {
|
||||
visibility: hidden;
|
||||
}
|
||||
100% {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,20 +1,24 @@
|
||||
<script lang="ts">
|
||||
import activity, { ActivityMessage } from '@hcengineering/activity'
|
||||
import ThreadParentMessage from './ThreadParentPresenter.svelte'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import ChannelScrollView from '../ChannelScrollView.svelte'
|
||||
import core, { Doc, Ref, Space } from '@hcengineering/core'
|
||||
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
|
||||
import notification from '@hcengineering/notification'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
|
||||
import ThreadParentMessage from './ThreadParentPresenter.svelte'
|
||||
import ReverseChannelScrollView from '../ReverseChannelScrollView.svelte'
|
||||
import { ChannelDataProvider } from '../../channelDataProvider'
|
||||
import chunter from '../../plugin'
|
||||
|
||||
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
|
||||
export let message: ActivityMessage
|
||||
|
||||
const query = createQuery()
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
const query = createQuery()
|
||||
const inboxClient = InboxNotificationsClientImpl.getClient()
|
||||
const contextByDocStore = inboxClient.contextByDoc
|
||||
|
||||
let channel: Doc | undefined = undefined
|
||||
let dataProvider: ChannelDataProvider | undefined = undefined
|
||||
@ -28,9 +32,18 @@
|
||||
{ limit: 1 }
|
||||
)
|
||||
|
||||
$: if (message !== undefined && dataProvider === undefined) {
|
||||
$: void updateProvider(message)
|
||||
|
||||
async function updateProvider (message: ActivityMessage): Promise<void> {
|
||||
if (dataProvider !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const context =
|
||||
$contextByDocStore.get(message._id) ??
|
||||
(await client.findOne(notification.class.DocNotifyContext, { objectId: message._id }))
|
||||
dataProvider = new ChannelDataProvider(
|
||||
undefined,
|
||||
context,
|
||||
message.space,
|
||||
message._id,
|
||||
chunter.class.ThreadMessage,
|
||||
@ -47,10 +60,8 @@
|
||||
|
||||
<div class="hulyComponent-content hulyComponent-content__container noShrink">
|
||||
{#if dataProvider !== undefined && channel !== undefined}
|
||||
<ChannelScrollView
|
||||
<ReverseChannelScrollView
|
||||
bind:selectedMessageId
|
||||
embedded
|
||||
skipLabels
|
||||
object={message}
|
||||
{channel}
|
||||
provider={dataProvider}
|
||||
@ -74,7 +85,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</ChannelScrollView>
|
||||
</ReverseChannelScrollView>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
@ -112,7 +112,7 @@ export async function getMessageLink (message: ActivityMessage): Promise<string>
|
||||
_class = message.attachedToClass
|
||||
}
|
||||
|
||||
const id = encodeObjectURI(_id, _class)
|
||||
const id = encodeURIComponent(encodeObjectURI(_id, _class))
|
||||
|
||||
return `${window.location.protocol}//${window.location.host}/${workbenchId}/${location.path[1]}/${chunterId}/${id}${threadParent}?message=${message._id}`
|
||||
}
|
||||
|
171
plugins/chunter-resources/src/scroll.ts
Normal file
171
plugins/chunter-resources/src/scroll.ts
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// 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 { getDay, type Timestamp } from '@hcengineering/core'
|
||||
import { get } from 'svelte/store'
|
||||
import { sortActivityMessages } from '@hcengineering/activity-resources'
|
||||
import { type ActivityMessage, type DisplayActivityMessage } from '@hcengineering/activity'
|
||||
import { type DocNotifyContext } from '@hcengineering/notification'
|
||||
|
||||
import { getClosestDate, readChannelMessages } from './utils'
|
||||
import { type ChannelDataProvider } from './channelDataProvider'
|
||||
|
||||
const dateSelectorHeight = 30
|
||||
const headerHeight = 52
|
||||
|
||||
function isDateRendered (date: Timestamp, uuid: string): boolean {
|
||||
const day = getDay(date)
|
||||
const id = `${uuid}-${day.toString()}`
|
||||
|
||||
return document.getElementById(id) != null
|
||||
}
|
||||
|
||||
export function getScrollToDateOffset (date: Timestamp, uuid: string): number | undefined {
|
||||
const day = getDay(date)
|
||||
const id = `${uuid}-${day.toString()}`
|
||||
const element = document.getElementById(id)
|
||||
|
||||
if (element === null) return undefined
|
||||
|
||||
const offsetTop = element?.offsetTop
|
||||
if (offsetTop === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return offsetTop - headerHeight - dateSelectorHeight / 2
|
||||
}
|
||||
|
||||
export function jumpToDate (
|
||||
e: CustomEvent<{ date?: Timestamp }>,
|
||||
provider: ChannelDataProvider,
|
||||
uuid: string,
|
||||
scrollDiv?: HTMLElement | null
|
||||
): {
|
||||
scrollOffset?: number
|
||||
dateToJump?: Timestamp
|
||||
} {
|
||||
const date = e.detail.date
|
||||
|
||||
if (date === undefined || scrollDiv == null) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const closestDate = getClosestDate(date, get(provider.datesStore))
|
||||
if (closestDate === undefined) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isDateRendered(closestDate, uuid)) {
|
||||
const offset = getScrollToDateOffset(closestDate, uuid)
|
||||
return { scrollOffset: offset }
|
||||
} else {
|
||||
void provider.jumpToDate(closestDate)
|
||||
return { dateToJump: closestDate }
|
||||
}
|
||||
}
|
||||
|
||||
export function getSelectedDate (
|
||||
provider: ChannelDataProvider,
|
||||
uuid: string,
|
||||
scrollDiv?: HTMLElement | null,
|
||||
contentDiv?: HTMLElement | null
|
||||
): Timestamp | undefined {
|
||||
if (contentDiv == null || scrollDiv == null) return
|
||||
|
||||
const containerRect = scrollDiv.getBoundingClientRect()
|
||||
const messagesElements = contentDiv?.getElementsByClassName('activityMessage')
|
||||
|
||||
if (messagesElements === undefined) return
|
||||
|
||||
const reversedDates = [...get(provider.datesStore)].reverse()
|
||||
const messages = get(provider.messagesStore)
|
||||
|
||||
let selectedDate: Timestamp | undefined
|
||||
|
||||
for (const message of messages) {
|
||||
const msgElement = messagesElements?.[message._id as any]
|
||||
if (msgElement == null) continue
|
||||
|
||||
const createdOn = message.createdOn
|
||||
if (createdOn === undefined) continue
|
||||
|
||||
const messageRect = msgElement.getBoundingClientRect()
|
||||
|
||||
const isInView =
|
||||
messageRect.top > 0 &&
|
||||
messageRect.top < containerRect.bottom &&
|
||||
messageRect.bottom - headerHeight - 2 * dateSelectorHeight > 0 &&
|
||||
messageRect.bottom <= containerRect.bottom
|
||||
|
||||
if (isInView) {
|
||||
selectedDate = reversedDates.find((date) => date <= createdOn)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDate !== undefined) {
|
||||
const day = getDay(selectedDate)
|
||||
const dateId = `${uuid}-${day.toString()}`
|
||||
const dateElement = document.getElementById(dateId)
|
||||
|
||||
let isElementVisible = false
|
||||
|
||||
if (dateElement !== null) {
|
||||
const elementRect = dateElement.getBoundingClientRect()
|
||||
isElementVisible = elementRect.top + 10 >= containerRect.top && elementRect.bottom <= containerRect.bottom
|
||||
}
|
||||
|
||||
if (isElementVisible) {
|
||||
selectedDate = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return selectedDate
|
||||
}
|
||||
|
||||
export function messageInView (msgElement: Element, containerRect: DOMRect): boolean {
|
||||
const rect = msgElement.getBoundingClientRect()
|
||||
return rect.bottom > containerRect.top && rect.top < containerRect.bottom
|
||||
}
|
||||
|
||||
const messagesToReadAccumulator: Set<DisplayActivityMessage> = new Set<DisplayActivityMessage>()
|
||||
let messagesToReadAccumulatorTimer: any
|
||||
|
||||
export function readViewportMessages (
|
||||
messages: ActivityMessage[],
|
||||
context: DocNotifyContext,
|
||||
scrollDiv?: HTMLElement | null,
|
||||
contentDiv?: HTMLElement | null
|
||||
): void {
|
||||
if (scrollDiv == null || contentDiv == null) return
|
||||
|
||||
const scrollRect = scrollDiv.getBoundingClientRect()
|
||||
const messagesElements = contentDiv?.getElementsByClassName('activityMessage')
|
||||
|
||||
for (const message of messages) {
|
||||
const msgElement = messagesElements?.[message._id as any]
|
||||
if (msgElement == null) continue
|
||||
|
||||
if (messageInView(msgElement, scrollRect)) {
|
||||
messagesToReadAccumulator.add(message)
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(messagesToReadAccumulatorTimer)
|
||||
messagesToReadAccumulatorTimer = setTimeout(() => {
|
||||
const messagesToRead = [...messagesToReadAccumulator]
|
||||
messagesToReadAccumulator.clear()
|
||||
void readChannelMessages(sortActivityMessages(messagesToRead), context)
|
||||
}, 500)
|
||||
}
|
@ -286,7 +286,7 @@ test.describe('Channel tests', () => {
|
||||
|
||||
test('Check if user can copy message', async ({ page }) => {
|
||||
const baseURL = process.env.PLATFORM_URI ?? 'http://localhost:8083'
|
||||
const expectedUrl = `${baseURL}/workbench/${data.workspaceName}/chunter/chunter:space:Random|chunter:class:Channel?message=`
|
||||
const expectedUrl = `${baseURL}/workbench/${data.workspaceName}/chunter/chunter%3Aspace%3ARandom%7Cchunter%3Aclass%3AChannel?message=`
|
||||
await leftSideMenuPage.clickChunter()
|
||||
await channelPage.clickChannel('random')
|
||||
await channelPage.sendMessage('Test message')
|
||||
|
@ -12,7 +12,7 @@ export class TalentsPage extends CommonRecruitingPage {
|
||||
}
|
||||
|
||||
pageHeader = (): Locator => this.page.locator('span[class*="header"]', { hasText: 'Talents' })
|
||||
buttonCreateTalent = (): Locator => this.page.getByRole('button', { name: 'Talent', exact: true })
|
||||
buttonCreateTalent = (): Locator => this.page.getByRole('button', { name: 'New Talent', exact: true })
|
||||
|
||||
textVacancyMatchingTalent = (): Locator =>
|
||||
this.page.locator(
|
||||
|
Loading…
Reference in New Issue
Block a user