Files
huly-platform/plugins/activity-resources/src/components/Activity.svelte
T
2025-10-22 09:14:35 +07:00

394 lines
11 KiB
Svelte

<!--
// Copyright © 2022 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 activity, {
ActivityExtension,
ActivityMessage,
ActivityReference,
DisplayActivityMessage,
WithReferences
} from '@hcengineering/activity'
import { Class, Doc, getCurrentAccount, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Grid, Lazy, location, Section, Spinner } from '@hcengineering/ui'
import { onDestroy, onMount } from 'svelte'
import { editingMessageStore, messageInFocus } from '../activity'
import { combineActivityMessages, sortActivityMessages } from '../activityMessagesUtils'
import { canGroupMessages, getActivityNewestFirst, getMessageFromLoc, getSpace } from '../utils'
import ActivityMessagePresenter from './activity-message/ActivityMessagePresenter.svelte'
import ActivityExtensionComponent from './ActivityExtension.svelte'
import ActivityFilter from './ActivityFilter.svelte'
import { Analytics } from '@hcengineering/analytics'
export let object: WithReferences<Doc>
export let showCommenInput: boolean = true
export let transparent: boolean = false
export let focusIndex: number = -1
export let boundary: HTMLElement | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const activityMessagesQuery = createQuery()
const refsQuery = createQuery()
let extensions: ActivityExtension[] = []
let filteredMessages: DisplayActivityMessage[] = []
let allMessages: ActivityMessage[] = []
let messages: ActivityMessage[] = []
let refs: ActivityReference[] = []
let isMessagesLoading = false
let isRefsLoading = true
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>, withoutAnimation?: boolean): 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
if (!withoutAnimation) {
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 = getActivityNewestFirst()
$: extensions = getExtensions(object._class)
function getExtensions (_class: Ref<Class<Doc>>): ActivityExtension[] {
try {
let clazz: Ref<Class<Doc>> | undefined = _class
while (clazz !== undefined) {
const res = client.getModel().findAllSync(activity.class.ActivityExtension, { ofClass: clazz })
if (res.length > 0) {
return res
}
clazz = hierarchy.getClass(clazz).extends
}
} catch (e: any) {
Analytics.handleError(e)
return []
}
return []
}
// Load references from other spaces separately because they can have any different spaces
$: if ((object.references ?? 0) > 0) {
refsQuery.query(
activity.class.ActivityReference,
{ attachedTo: object._id, space: { $ne: getSpace(object) } },
(res) => {
refs = res
isRefsLoading = false
},
{
sort: {
createdOn: SortingOrder.Ascending
}
}
)
} else {
isRefsLoading = false
refsQuery.unsubscribe()
}
$: allMessages = sortActivityMessages(messages.concat(refs))
function updateActivityMessages (objectId: Ref<Doc>, order: SortingOrder): void {
isMessagesLoading = true
const res = activityMessagesQuery.query(
activity.class.ActivityMessage,
{ attachedTo: objectId, space: getSpace(object) },
(result: ActivityMessage[]) => {
messages = combineActivityMessages(result, order)
isMessagesLoading = false
},
{
sort: {
createdOn: SortingOrder.Ascending
},
lookup: {
_id: {
reactions: activity.class.Reaction
}
},
showArchived: true
}
)
if (!res) {
isMessagesLoading = false
}
}
$: isLoading = isMessagesLoading || isRefsLoading
$: areMessagesLoaded = !isLoading && filteredMessages.length > 0
$: if (activityBox && areMessagesLoaded) {
shouldScroll = true
void scrollToMessage(selectedMessageId)
}
$: updateActivityMessages(object._id, isNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending)
export function editLastMessage (): void {
if (isMessagesLoading) return
const me = getCurrentAccount()
const mySocialIds = new Set(me.socialIds)
const start = isNewestFirst ? 0 : filteredMessages.length - 1
const end = isNewestFirst ? filteredMessages.length : -1
const step = isNewestFirst ? 1 : -1
let lastMessage: ActivityMessage | undefined
for (let i = start; i !== end; i += step) {
const m = filteredMessages[i]
if (m.collection === 'comments' && m.createdBy != null && mySocialIds.has(m.createdBy)) {
lastMessage = m
break
}
}
if (lastMessage == null) return
editingMessageStore.set(lastMessage._id)
void scrollToMessage(lastMessage._id, true)
}
function handleKeyDown (e: KeyboardEvent): void {
const key = e.key
if ((key === 'ArrowUp' && !isNewestFirst) || (key === 'ArrowDown' && isNewestFirst)) {
if ($editingMessageStore !== undefined) return
editLastMessage()
}
}
</script>
<Section label={activity.string.Activity} icon={activity.icon.Activity}>
<svelte:fragment slot="header">
{#if isLoading}
<div class="ml-1">
<Spinner size="small" />
</div>
{/if}
<ActivityFilter
messages={allMessages}
{object}
on:update={(e) => {
filteredMessages = e.detail
}}
bind:isNewestFirst
/>
</svelte:fragment>
<svelte:fragment slot="content">
{#if isNewestFirst && showCommenInput}
<div class="ref-input newest-first">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary, focusIndex, withTypingInfo: true, onKeyDown: handleKeyDown }}
/>
</div>
{/if}
<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 (message._id)}
{@const canGroup = canGroupMessages(message, filteredMessages[index - 1])}
{#if selectedMessageId}
<ActivityMessagePresenter
value={message}
doc={object}
hideLink={true}
type={canGroup ? 'short' : 'default'}
isHighlighted={selectedMessageId === message._id}
withShowMore
/>
{:else}
<Lazy>
<ActivityMessagePresenter
value={message}
doc={object}
hideLink={true}
type={canGroup ? 'short' : 'default'}
isHighlighted={selectedMessageId === message._id}
withShowMore
/>
</Lazy>
{/if}
{/each}
</Grid>
{/if}
</div>
{#if showCommenInput && !isNewestFirst}
<div class="ref-input oldest-first">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary, focusIndex, withTypingInfo: true, onKeyDown: handleKeyDown }}
/>
</div>
{/if}
</svelte:fragment>
</Section>
<style lang="scss">
.ref-input {
flex-shrink: 0;
&.newest-first {
margin-bottom: 1rem;
padding-top: 1rem;
}
&.oldest-first {
padding-bottom: 2.5rem;
}
}
.p-activity {
&.newest-first {
padding-bottom: 1.75rem;
}
&:not(.newest-first) {
margin: 1.75rem 0;
}
}
.invisible {
display: none;
}
:global(.grid .msgactivity-container.showIcon:last-child::after) {
content: none;
}
</style>