Scroll to activity message on inbox item click (#5747)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-06-07 12:03:36 +04:00 committed by GitHub
parent cae6113add
commit 6b74823cbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 311 additions and 38 deletions

View File

@ -14,13 +14,14 @@
// limitations under the License.
-->
<script lang="ts">
import { afterUpdate, createEventDispatcher } from 'svelte'
import { afterUpdate, createEventDispatcher, SvelteComponent } from 'svelte'
import { Writable, writable } from 'svelte/store'
import activity from '@hcengineering/activity'
import { Doc } from '@hcengineering/core'
import { Component, deviceOptionsStore as deviceInfo, Panel, Scroller } from '@hcengineering/ui'
import { Component, deviceOptionsStore as deviceInfo, Panel, Scroller, resizeObserver } from '@hcengineering/ui'
import type { ButtonItem } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform'
export let title: string | undefined = undefined
export let withoutActivity: boolean = false
@ -63,6 +64,8 @@
let count: number = 0
let panel: Panel
let activityRef: SvelteComponent | undefined
const waitCount = 10
const PanelScrollTop: Writable<Record<string, number>> = writable<Record<string, number>>({})
@ -88,7 +91,13 @@
}, 50)
}
afterUpdate(() => {
afterUpdate(async () => {
const fn = await getResource(activity.function.ShouldScrollToActivity)
if (!withoutActivity && fn?.()) {
return
}
if (lastHref !== window.location.href) {
startScrollHeightCheck()
}
@ -238,12 +247,19 @@
}
}}
>
<div class={contentClasses ?? 'popupPanel-body__main-content py-8 clear-mins'} class:max={useMaxWidth}>
<div
class={contentClasses ?? 'popupPanel-body__main-content py-8'}
class:max={useMaxWidth}
use:resizeObserver={(element) => {
activityRef?.onContainerResized?.(element)
}}
>
<slot />
{#if !withoutActivity}
{#key object._id}
<Component
is={activity.component.Activity}
bind:innerRef={activityRef}
props={{
object,
showCommenInput: !withoutInput,

View File

@ -1,9 +1,10 @@
import activity, { type SavedMessage } from '@hcengineering/activity'
import { SortingOrder, type WithLookup } from '@hcengineering/core'
import activity, { type ActivityMessage, type SavedMessage } from '@hcengineering/activity'
import { type Ref, SortingOrder, type WithLookup } from '@hcengineering/core'
import { writable } from 'svelte/store'
import { createQuery, getClient } from '@hcengineering/presentation'
export const savedMessagesStore = writable<Array<WithLookup<SavedMessage>>>([])
export const messageInFocus = writable<Ref<ActivityMessage> | undefined>(undefined)
const savedMessagesQuery = createQuery(true)

View File

@ -16,12 +16,15 @@
import activity, { ActivityExtension, ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, Grid, Label, Lazy, Spinner } from '@hcengineering/ui'
import { Grid, Label, Spinner, location, Lazy } from '@hcengineering/ui'
import { onDestroy, onMount } from 'svelte'
import ActivityExtensionComponent from './ActivityExtension.svelte'
import ActivityFilter from './ActivityFilter.svelte'
import { combineActivityMessages } from '../activityMessagesUtils'
import { canGroupMessages } from '../utils'
import { canGroupMessages, getMessageFromLoc } from '../utils'
import ActivityMessagePresenter from './activity-message/ActivityMessagePresenter.svelte'
import { messageInFocus } from '../activity'
export let object: Doc
export let showCommenInput: boolean = true
@ -38,6 +41,122 @@
let activityMessages: ActivityMessage[] = []
let isLoading = false
let activityBox: HTMLElement | undefined
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
let shouldScroll = true
let isAutoScroll = false
let prevScrollTimestamp = 0
let timer: any
let prevContainerHeight = -1
let prevContainerWidth = -1
const unsubscribe = messageInFocus.subscribe((id) => {
if (id !== undefined) {
selectedMessageId = id
shouldScroll = true
void scrollToMessage(id)
messageInFocus.set(undefined)
}
})
const unsubscribeLocation = location.subscribe((loc) => {
const id = getMessageFromLoc(loc)
if (id === undefined) {
boundary?.scrollTo({ top: 0 })
selectedMessageId = undefined
}
messageInFocus.set(id)
})
onMount(() => {
if (!boundary) {
return
}
boundary.addEventListener('wheel', () => {
shouldScroll = false
})
boundary.addEventListener('scroll', (a) => {
const diff = a.timeStamp - prevScrollTimestamp
if (!isAutoScroll) {
shouldScroll = false
}
isAutoScroll = isAutoScroll ? diff < 100 || prevScrollTimestamp === 0 : false
prevScrollTimestamp = a.timeStamp
})
})
onDestroy(() => {
unsubscribe()
unsubscribeLocation()
})
function restartAnimation (el: HTMLElement): void {
el.style.animation = 'none'
el.focus()
el.style.animation = ''
}
function tryScrollToMessage (delay: number = 100): void {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
void scrollToMessage(selectedMessageId)
}, delay)
}
async function scrollToMessage (id?: Ref<ActivityMessage>): Promise<void> {
if (!id || boundary == null || activityBox == null) {
return
}
const messagesElements = activityBox.getElementsByClassName('activityMessage')
const msgElement = messagesElements[id as any] as HTMLElement | undefined
if (msgElement == null && filteredMessages.some((msg) => msg._id === id)) {
tryScrollToMessage()
return
} else if (msgElement == null) {
return
}
shouldScroll = true
isAutoScroll = true
prevScrollTimestamp = 0
restartAnimation(msgElement)
msgElement.scrollIntoView({ behavior: 'instant' })
}
export function onContainerResized (container: HTMLElement): void {
if (!shouldScroll) return
if (prevContainerWidth > 0 && container.clientWidth !== prevContainerWidth) {
shouldScroll = false
return
}
if (
selectedMessageId &&
container.clientHeight !== prevContainerHeight &&
container.clientHeight > prevContainerHeight
) {
// A little delay to avoid a lot of jumping/twitching
tryScrollToMessage(300)
}
prevContainerHeight = container.clientHeight
prevContainerWidth = container.clientWidth
}
let isNewestFirst = JSON.parse(localStorage.getItem('activity-newest-first') ?? 'false')
$: void client.findAll(activity.class.ActivityExtension, { ofClass: object._class }).then((res) => {
@ -67,6 +186,13 @@
}
}
$: areMessagesLoaded = !isLoading && filteredMessages.length > 0
$: if (activityBox && areMessagesLoaded) {
shouldScroll = true
void scrollToMessage(selectedMessageId)
}
$: void updateActivityMessages(object._id, isNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending)
</script>
@ -93,23 +219,35 @@
<ActivityExtensionComponent kind="input" {extensions} props={{ object, boundary, focusIndex }} />
</div>
{/if}
<div class="p-activity select-text" id={activity.string.Activity} class:newest-first={isNewestFirst}>
<div
class="p-activity select-text"
id={activity.string.Activity}
class:newest-first={isNewestFirst}
bind:this={activityBox}
>
{#if filteredMessages.length}
<Grid column={1} rowGap={0}>
{#each filteredMessages as message, index}
{@const canGroup = canGroupMessages(message, filteredMessages[index - 1])}
<Lazy>
<Component
is={activity.component.ActivityMessagePresenter}
props={{
value: message,
hideLink: true,
space: object.space,
boundary,
type: canGroup ? 'short' : 'default'
}}
{#if selectedMessageId}
<ActivityMessagePresenter
value={message}
doc={object}
hideLink={true}
type={canGroup ? 'short' : 'default'}
isHighlighted={selectedMessageId === message._id}
/>
</Lazy>
{:else}
<Lazy>
<ActivityMessagePresenter
value={message}
doc={object}
hideLink={true}
type={canGroup ? 'short' : 'default'}
isHighlighted={selectedMessageId === message._id}
/>
</Lazy>
{/if}
{/each}
</Grid>
{/if}
@ -148,6 +286,4 @@
:global(.grid .msgactivity-container.showIcon:last-child::after) {
content: none;
}
// Remove the line in the last Activity message
</style>

View File

@ -20,6 +20,7 @@
import { Class, Doc, Ref } from '@hcengineering/core'
export let value: DisplayActivityMessage
export let doc: Doc | undefined = undefined
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
@ -53,6 +54,7 @@
props={{
space: value.space,
value,
doc,
showNotify,
skipLabel,
isHighlighted,

View File

@ -212,7 +212,7 @@
<style lang="scss">
@keyframes highlight {
50% {
background-color: var(--global-ui-highlight-BackgroundColor);
background-color: var(--global-ui-hover-highlight-BackgroundColor);
}
}

View File

@ -39,6 +39,7 @@
import { getIsTextType } from '../../utils'
export let value: DisplayDocUpdateMessage
export let doc: Doc | undefined = undefined
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
@ -117,10 +118,16 @@
return personById.get(personAccount.person)
}
$: void loadObject(value.objectId, value.objectClass)
$: void loadParentObject(value, parentMessage)
$: void loadObject(value.objectId, value.objectClass, doc)
$: void loadParentObject(value, parentMessage, doc)
async function loadObject (_id: Ref<Doc>, _class: Ref<Class<Doc>>, doc?: Doc): Promise<void> {
if (doc !== undefined && doc._id === _id) {
object = doc
isObjectRemoved = false
return
}
async function loadObject (_id: Ref<Doc>, _class: Ref<Class<Doc>>): Promise<void> {
isObjectRemoved = await checkIsObjectRemoved(client, _id, _class)
if (isObjectRemoved) {
@ -132,13 +139,23 @@
}
}
async function loadParentObject (message: DocUpdateMessage, parentMessage?: ActivityMessage): Promise<void> {
async function loadParentObject (
message: DocUpdateMessage,
parentMessage?: ActivityMessage,
doc?: Doc
): Promise<void> {
if (!parentMessage && message.objectId === message.attachedTo) {
return
}
const _id = parentMessage ? parentMessage.attachedTo : message.attachedTo
const _class = parentMessage ? parentMessage.attachedToClass : message.attachedToClass
if (doc !== undefined && doc._id === _id) {
parentObject = doc
return
}
const isRemoved = await checkIsObjectRemoved(client, _id, _class)
if (isRemoved) {

View File

@ -37,7 +37,8 @@ import {
pinMessage,
canSaveForLater,
canUnpinMessage,
removeFromSaved
removeFromSaved,
shouldScrollToActivity
} from './utils'
export * from './activity'
@ -84,7 +85,8 @@ export default async (): Promise<Resources> => ({
CanSaveForLater: canSaveForLater,
CanRemoveFromSaved: canRemoveFromSaved,
CanPinMessage: canPinMessage,
CanUnpinMessage: canUnpinMessage
CanUnpinMessage: canUnpinMessage,
ShouldScrollToActivity: shouldScrollToActivity
},
backreference: {
Update: updateReferences

View File

@ -2,7 +2,14 @@ import { get } from 'svelte/store'
import type { ActivityMessage, Reaction } from '@hcengineering/activity'
import core, { type Doc, type Ref, type TxOperations, getCurrentAccount, isOtherHour } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { type Location, getEventPositionElement, closePopup, showPopup, EmojiPopup } from '@hcengineering/ui'
import {
type Location,
getEventPositionElement,
closePopup,
showPopup,
EmojiPopup,
getCurrentResolvedLocation
} from '@hcengineering/ui'
import { type AttributeModel } from '@hcengineering/view'
import preference from '@hcengineering/preference'
@ -157,3 +164,8 @@ export function canGroupMessages (message: MessageData, prevMessage?: MessageDat
return time1 - time2 < groupMessagesThresholdMs
}
export function shouldScrollToActivity (): boolean {
const loc = getCurrentResolvedLocation()
return getMessageFromLoc(loc) !== undefined
}

View File

@ -320,6 +320,9 @@ export default plugin(activityId, {
AllFilter: '' as Ref<ActivityMessagesFilter>,
MentionNotification: '' as Ref<Doc>
},
function: {
ShouldScrollToActivity: '' as Resource<() => boolean>
},
backreference: {
// Update list of back references
Update: '' as Resource<(source: Doc, key: string, target: RelatedDocument[], label: IntlString) => Promise<void>>

View File

@ -411,4 +411,42 @@ export class ChannelDataProvider implements IChannelDataProvider {
this.clearMessages()
await this.loadInitialMessages(msg._id)
}
public jumpToMessage (message: ActivityMessage): boolean {
const metadata = get(this.metadataStore).find(({ _id }) => _id === message._id)
if (metadata === undefined) {
return false
}
const isAlreadyLoaded = get(this.messagesStore).some(({ _id }) => _id === message._id)
if (isAlreadyLoaded) {
return false
}
this.clearMessages()
void this.loadInitialMessages(message._id)
return true
}
public jumpToEnd (): boolean {
const last = get(this.metadataStore)[get(this.metadataStore).length - 1]
if (last === undefined) {
return false
}
const isAlreadyLoaded = get(this.messagesStore).some(({ _id }) => _id === last._id)
if (isAlreadyLoaded) {
return false
}
this.clearMessages()
void this.loadInitialMessages()
return true
}
}

View File

@ -17,12 +17,13 @@
import { DocNotifyContext } from '@hcengineering/notification'
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
import { getClient } from '@hcengineering/presentation'
import { getMessageFromLoc } from '@hcengineering/activity-resources'
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
import { location as locationStore } from '@hcengineering/ui'
import chunter from '../plugin'
import ChannelScrollView from './ChannelScrollView.svelte'
import { ChannelDataProvider } from '../channelDataProvider'
import { onDestroy } from 'svelte'
export let object: Doc
export let context: DocNotifyContext | undefined
@ -35,8 +36,23 @@
let dataProvider: ChannelDataProvider | undefined
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
locationStore.subscribe((newLocation) => {
selectedMessageId = getMessageFromLoc(newLocation)
const unsubscribe = messageInFocus.subscribe((id) => {
if (id !== undefined && id !== selectedMessageId) {
selectedMessageId = id
}
messageInFocus.set(undefined)
})
const unsubscribeLocation = locationStore.subscribe((newLocation) => {
const id = getMessageFromLoc(newLocation)
selectedMessageId = id
messageInFocus.set(id)
})
onDestroy(() => {
unsubscribe()
unsubscribeLocation()
})
$: isDocChannel = !hierarchy.isDerived(object._class, chunter.class.ChunterSpace)

View File

@ -432,6 +432,29 @@
}
}
function reinitializeScroll (): void {
isScrollInitialized = false
void initializeScroll(isLoading, separatorElement, separatorIndex)
}
function adjustScrollPosition (selectedMessageId: Ref<ActivityMessage> | undefined): void {
if (isLoading || !isScrollInitialized || isInitialScrolling) {
return
}
const msg = messages.find(({ _id }) => _id === selectedMessageId)
if (msg !== undefined) {
const isReload = provider.jumpToMessage(msg)
if (isReload) {
reinitializeScroll()
}
} else {
provider.jumpToEnd()
reinitializeScroll()
}
}
$: adjustScrollPosition(selectedMessageId)
function waitLastMessageRenderAndRead (onComplete?: () => void) {
if (isLastMessageViewed()) {
readViewportMessages()
@ -614,6 +637,7 @@
<div class="msg">
<ActivityMessagePresenter
doc={object}
value={message}
skipLabel={skipLabels}
{showEmbedded}

View File

@ -30,6 +30,7 @@
import ChatMessageInput from './ChatMessageInput.svelte'
export let value: WithLookup<ChatMessage> | undefined
export let doc: Doc | undefined = undefined
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
@ -87,10 +88,13 @@
parentMessage = res as DisplayActivityMessage
})
$: value &&
client.findOne(value.attachedToClass, { _id: value.attachedTo }).then((result) => {
$: if (doc && value?.attachedTo === doc._id) {
object = doc
} else if (value) {
void client.findOne(value.attachedToClass, { _id: value.attachedTo }).then((result) => {
object = result
})
}
$: parentMessage &&
client.findOne(parentMessage.attachedToClass, { _id: parentMessage.attachedTo }).then((result) => {

View File

@ -18,7 +18,7 @@ import activity, {
type DisplayDocUpdateMessage,
type DocUpdateMessage
} from '@hcengineering/activity'
import { activityMessagesComparator, combineActivityMessages } from '@hcengineering/activity-resources'
import { activityMessagesComparator, combineActivityMessages, messageInFocus } from '@hcengineering/activity-resources'
import {
SortingOrder,
getCurrentAccount,
@ -49,7 +49,8 @@ import {
parseLocation,
showPopup,
type Location,
type ResolvedLocation
type ResolvedLocation,
locationStorageKeyId
} from '@hcengineering/ui'
import { get, writable } from 'svelte/store'
@ -525,6 +526,7 @@ export function openInboxDoc (
if (_id === undefined || _class === undefined) {
loc.query = { message: null }
loc.path.length = 3
localStorage.setItem(`${locationStorageKeyId}_${notificationId}`, JSON.stringify(loc))
navigate(loc)
return
}
@ -540,7 +542,7 @@ export function openInboxDoc (
}
loc.query = { ...loc.query, message: message ?? null }
messageInFocus.set(message)
navigate(loc)
}