Use reverse scroll in chat (#6736)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-10-08 19:20:18 +04:00 committed by GitHub
parent a698889f61
commit 509952de4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1000 additions and 68 deletions

View File

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

View File

@ -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={() => {

View File

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

View File

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

View File

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

View File

@ -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>[]>()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
}

View File

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

View File

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