mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-26 19:44:57 +02:00
394 lines
11 KiB
Svelte
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>
|