UBERF-4360: rewrite chat (#4265)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-01-11 21:46:11 +07:00
committed by GitHub
parent 06f6f6a222
commit 7f6f5e28a6
183 changed files with 6360 additions and 5051 deletions
@@ -22,7 +22,6 @@
import { ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import NotificationView from './NotificationView.svelte'
import { hide } from '../utils'
export let filter: 'all' | 'read' | 'unread' = 'all'
export let _id: Ref<Doc> | undefined
@@ -161,11 +160,11 @@
selected++
changeSelected(selected)
}
if (key.code === 'Backspace') {
key.preventDefault()
key.stopPropagation()
hide(listProvider.docs()[selected] as DocUpdates)
}
// if (key.code === 'Backspace') {
// key.preventDefault()
// key.stopPropagation()
// hide(listProvider.docs()[selected] as DocUpdates)
// }
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
@@ -0,0 +1,55 @@
<!--
// Copyright © 2023 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 { DocNotifyContext } from '@hcengineering/notification'
import { Doc } from '@hcengineering/core'
import { getDocLinkTitle, getDocTitle } from '@hcengineering/view-resources'
import { Icon } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import chunter from '@hcengineering/chunter'
export let value: DocNotifyContext
const client = getClient()
const objectQuery = createQuery()
let object: Doc | undefined
$: objectQuery.query(value.attachedToClass, { _id: value.attachedTo }, (res) => {
object = res[0]
})
$: icon = object && client.getHierarchy().getClass(object._class).icon
async function getTitle (object: Doc) {
if (object._class === chunter.class.DirectMessage) {
return await getDocTitle(client, object._id, object._class, object)
}
return await getDocLinkTitle(client, object._id, object._class, object)
}
</script>
{#if object}
<div class="flex-presenter">
{#if icon}
<Icon {icon} size="small" />
{/if}
<div class="mr-4" />
{#await getTitle(object) then title}
{title}
{/await}
</div>
{/if}
@@ -15,16 +15,19 @@
<script lang="ts">
import { Doc } from '@hcengineering/core'
import { getPlatformColor, themeStore } from '@hcengineering/ui'
import { NotificationClientImpl } from '../utils'
import { InboxNotificationsClientImpl } from '../inboxNotificationsClient'
export let value: Doc
export let kind: 'table' | 'block' = 'block'
const notificationClient = NotificationClientImpl.getClient()
const store = notificationClient.docUpdatesStore
$: docUpdate = $store.get(value._id)
const inboxClient = InboxNotificationsClientImpl.getClient()
const notifyContextByDocStore = inboxClient.docNotifyContextByDoc
const inboxNotificationsByContextStore = inboxClient.inboxNotificationsByContext
$: hasNotification = docUpdate?.txes?.some((p) => p.isNew) && !docUpdate?.hidden
$: notifyContext = $notifyContextByDocStore.get(value._id)
$: inboxNotifications = notifyContext ? $inboxNotificationsByContextStore.get(notifyContext._id) ?? [] : []
$: hasNotification = !notifyContext?.hidden && inboxNotifications.some(({ isViewed }) => !isViewed)
</script>
{#if hasNotification}
@@ -0,0 +1,145 @@
<!--
// Copyright © 2023 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 { createQuery, getClient } from '@hcengineering/presentation'
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import notification, {
ActivityInboxNotification,
DisplayActivityInboxNotification,
InboxNotification
} from '@hcengineering/notification'
import { ActivityMessagePresenter, combineActivityMessages } from '@hcengineering/activity-resources'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { getLocation, location, navigate, Action } from '@hcengineering/ui'
import chunter from '@hcengineering/chunter'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import { getActions } from '@hcengineering/view-resources'
import { getResource } from '@hcengineering/platform'
export let value: DisplayActivityInboxNotification
const client = getClient()
const messagesQuery = createQuery()
const notificationsClient = InboxNotificationsClientImpl.getClient()
const notificationsStore = notificationsClient.inboxNotifications
let messages: ActivityMessage[] = []
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
let displayMessage: DisplayActivityMessage | undefined = undefined
let actions: Action[] = []
location.subscribe((loc) => {
selectedMessageId = loc.path[4] as Ref<ActivityMessage> | undefined
})
$: combinedNotifications = $notificationsStore.filter(({ _id }) =>
(value.combinedIds as Ref<InboxNotification>[]).includes(_id)
) as ActivityInboxNotification[]
$: messageIds = combinedNotifications.map(({ attachedTo }) => attachedTo)
$: messagesQuery.query(
activity.class.ActivityMessage,
{ _id: { $in: messageIds } },
(res) => {
messages = res
},
{
sort: {
createdBy: SortingOrder.Ascending
}
}
)
$: displayMessage = messages.length > 1 ? combineActivityMessages(messages)[0] : messages[0]
function handleMessageClicked (message?: ActivityMessage): void {
if (message === undefined) {
return
}
if (message._class === chunter.class.ThreadMessage) {
openDocActivity(message._id, true)
selectedMessageId = message._id
} else if (client.getHierarchy().isDerived(message.attachedToClass, activity.class.ActivityMessage)) {
openDocActivity(message.attachedTo, false)
selectedMessageId = message.attachedTo as Ref<ActivityMessage>
} else {
openDocActivity(message._id, false)
selectedMessageId = message._id
}
markNotificationViewed()
}
function handleReply (message?: ActivityMessage): void {
if (message === undefined) {
return
}
openDocActivity(message._id, true)
selectedMessageId = message._id
}
function markNotificationViewed () {
combinedNotifications.forEach((notification) => {
client.update(notification, { isViewed: true })
})
}
function openDocActivity (_id: Ref<Doc>, thread: boolean) {
const loc = getLocation()
loc.path[4] = _id
loc.query = {
...loc.query,
thread: `${thread}`
}
navigate(loc)
}
async function getAllActions (value: ActivityInboxNotification): Promise<Action[]> {
const notificationActions = await getActions(client, value, notification.class.InboxNotification)
const result: Action[] = []
for (const action of notificationActions) {
const actionImpl = await getResource(action.action)
result.push({
...action,
action: (event?: any) => actionImpl(value, event, action.actionProps)
})
}
return result
}
$: getAllActions(value).then((res) => {
actions = res
})
</script>
{#if displayMessage !== undefined}
<ActivityMessagePresenter
value={displayMessage}
showNotify={!value.isViewed}
isSelected={displayMessage._id === selectedMessageId}
showEmbedded
{actions}
onReply={() => {
handleReply(displayMessage)
}}
onClick={() => {
handleMessageClicked(displayMessage)
}}
/>
{/if}
@@ -0,0 +1,172 @@
<!--
// Copyright © 2023 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 { Employee, PersonAccount } from '@hcengineering/contact'
import {
Avatar,
SystemAvatar,
employeeByIdStore,
personAccountByIdStore,
personByIdStore,
EmployeePresenter
} from '@hcengineering/contact-resources'
import core, { getDisplayTime, Ref } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { MessageViewer } from '@hcengineering/presentation'
import notification, { CommonInboxNotification } from '@hcengineering/notification'
import { ActionIcon, IconMoreH, Label, showPopup } from '@hcengineering/ui'
import { Menu } from '@hcengineering/view-resources'
export let value: CommonInboxNotification
let isActionMenuOpened = false
$: personAccount = $personAccountByIdStore.get((value.createdBy ?? value.modifiedBy) as Ref<PersonAccount>)
$: person =
personAccount?.person !== undefined
? $employeeByIdStore.get(personAccount.person as Ref<Employee>) ?? $personByIdStore.get(personAccount.person)
: undefined
let content = ''
$: void translate(value.message, value.props)
.then((message) => {
content = message
})
.catch((err) => {
content = JSON.stringify(err, null, 2)
})
function handleActionMenuOpened (): void {
isActionMenuOpened = true
}
function handleActionMenuClosed (): void {
isActionMenuOpened = false
}
function showMenu (ev: MouseEvent): void {
showPopup(
Menu,
{
object: value,
baseMenuClass: notification.class.InboxNotification
},
ev.target as HTMLElement,
handleActionMenuClosed
)
handleActionMenuOpened()
}
</script>
<div class="root clear-mins">
{#if !value.isViewed}
<div class="notify" />
{/if}
{#if value.icon}
<SystemAvatar size="medium" icon={value.icon} iconProps={value.iconProps} />
{:else if person}
<Avatar size="medium" avatar={person.avatar} name={person.name} />
{:else}
<SystemAvatar size="medium" />
{/if}
<div class="content ml-2 w-full clear-mins">
<div class="header clear-mins">
{#if person}
<EmployeePresenter value={person} shouldShowAvatar={false} />
{:else}
<div class="strong">
<Label label={core.string.System} />
</div>
{/if}
<span class="text-sm">{getDisplayTime(value.createdOn ?? 0)}</span>
</div>
<div class="flex-row-center">
<div class="customContent">
<MessageViewer message={content} />
</div>
</div>
</div>
<div class="actions clear-mins flex flex-gap-2 items-center" class:opened={isActionMenuOpened}>
<ActionIcon icon={IconMoreH} size="small" action={showMenu} />
</div>
</div>
<style lang="scss">
.root {
position: relative;
display: flex;
flex-shrink: 0;
padding: 0.75rem 0.75rem 0.75rem 1.25rem;
border-radius: 8px;
gap: 1rem;
overflow: hidden;
.actions {
position: absolute;
visibility: hidden;
top: 0.75rem;
right: 0.75rem;
color: var(--theme-halfcontent-color);
&.opened {
visibility: visible;
}
}
.content {
padding: 0;
}
&:hover > .actions {
visibility: visible;
}
&:hover {
background-color: var(--highlight-hover);
}
}
.header {
display: flex;
align-items: baseline;
font-size: 0.875rem;
color: var(--theme-halfcontent-color);
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 3.5rem);
span {
margin-left: 0.25rem;
font-weight: 400;
line-height: 1.25rem;
}
}
.notify {
position: absolute;
top: 0.5rem;
left: 0.25rem;
height: 0.5rem;
width: 0.5rem;
background-color: var(--theme-inbox-notify);
border-radius: 50%;
}
</style>
@@ -13,88 +13,30 @@
// limitations under the License.
-->
<script lang="ts">
import { InboxNotification, DocNotifyContext } from '@hcengineering/notification'
import { ActionContext, getClient } from '@hcengineering/presentation'
import { Class, Doc, Ref, WithLookup } from '@hcengineering/core'
import { getLocation, Label, location, navigate, Scroller } from '@hcengineering/ui'
import { DisplayInboxNotification } from '@hcengineering/notification'
import { ActionContext } from '@hcengineering/presentation'
import { Class, Doc, Ref } from '@hcengineering/core'
import { Label, Scroller } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
import activity, { ActivityMessage, DisplayActivityMessage, DisplayDocUpdateMessage } from '@hcengineering/activity'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import Filter from '../Filter.svelte'
import { getDisplayActivityMessagesByNotifications } from '../../utils'
import { getDisplayInboxNotifications } from '../../utils'
import { InboxNotificationsFilter } from '../../types'
import InboxNotificationPresenter from './InboxNotificationPresenter.svelte'
export let label: IntlString
export let _class: Ref<Class<Doc>> | undefined = undefined
let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
let filter: 'all' | 'read' | 'unread' = 'all'
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotificationsByContextStore = inboxClient.inboxNotificationsByContext
const notificationsClient = InboxNotificationsClientImpl.getClient()
const client = getClient()
let displayNotifications: DisplayInboxNotification[] = []
let filter: InboxNotificationsFilter = 'all'
let docNotifyContextById: Map<Ref<DocNotifyContext>, DocNotifyContext> = new Map<
Ref<DocNotifyContext>,
DocNotifyContext
>()
let inboxNotificationByMessage: Map<Ref<ActivityMessage>, InboxNotification> = new Map<
Ref<ActivityMessage>,
InboxNotification
>()
let inboxNotifications: WithLookup<InboxNotification>[] = []
notificationsClient.docNotifyContexts.subscribe((res: DocNotifyContext[]) => {
docNotifyContextById = new Map(res.map((update) => [update._id, update]))
$: getDisplayInboxNotifications($inboxNotificationsByContextStore, filter, _class).then((res) => {
displayNotifications = res
})
notificationsClient.inboxNotifications.subscribe((res: InboxNotification[]) => {
inboxNotifications = res
inboxNotificationByMessage = new Map(res.map((notification) => [notification.attachedTo, notification]))
})
function markNotificationAsViewed (message: DisplayActivityMessage) {
const combinedIds =
message._class === activity.class.DocUpdateMessage
? (message as DisplayDocUpdateMessage).combinedMessagesIds
: undefined
const allMessagesIds = [message._id, ...(combinedIds ?? [])]
const notifications = allMessagesIds
.map((id) => inboxNotificationByMessage.get(id))
.filter((n): n is InboxNotification => !!n)
notifications.forEach((notification) => {
client.update(notification, { isViewed: true })
})
}
location.subscribe((loc) => {
selectedMessageId = loc.path[4] as Ref<ActivityMessage> | undefined
})
function openDocActivity (_id: Ref<Doc>) {
const loc = getLocation()
loc.path[4] = _id
navigate(loc)
}
function handleMessageClicked (message: DisplayActivityMessage) {
if (client.getHierarchy().isDerived(message.attachedToClass, activity.class.ActivityMessage)) {
openDocActivity(message.attachedTo)
selectedMessageId = message.attachedTo as Ref<ActivityMessage>
} else {
openDocActivity(message._id)
selectedMessageId = message._id
}
markNotificationAsViewed(message)
}
$: displayMessages = getDisplayActivityMessagesByNotifications(
inboxNotifications,
docNotifyContextById,
filter,
_class
)
</script>
<ActionContext
@@ -114,15 +56,8 @@
<Scroller>
<div class="content">
{#each displayMessages as message}
<ActivityMessagePresenter
value={message}
showNotify={!inboxNotificationByMessage.get(message._id)?.isViewed}
isSelected={message._id === selectedMessageId}
onClick={() => {
handleMessageClicked(message)
}}
/>
{#each displayNotifications as notification}
<InboxNotificationPresenter value={notification} />
{/each}
</div>
</Scroller>
@@ -14,39 +14,46 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Class, Doc, Ref, SortingOrder } from '@hcengineering/core'
import { Class, Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, IconClose, Spinner } from '@hcengineering/ui'
import { Component, getLocation, IconClose, location as locationStore, Spinner } from '@hcengineering/ui'
import view from '@hcengineering/view'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import {
ActivityExtension,
ActivityMessagePresenter,
combineActivityMessages
} from '@hcengineering/activity-resources'
import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import { buildRemovedDoc, checkIsObjectRemoved } from '@hcengineering/view-resources'
import { DocNotifyContext } from '@hcengineering/notification'
import { ActivityScrolledView } from '@hcengineering/activity-resources'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
export let _id: Ref<ActivityMessage>
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const inboxClient = InboxNotificationsClientImpl.getClient()
const selectedMessageQuery = createQuery()
const messagesQuery = createQuery()
const objectQuery = createQuery()
let messages: DisplayActivityMessage[] = []
let loc = getLocation()
let selectedMessage: ActivityMessage | undefined = undefined
let object: Doc | undefined = undefined
let isLoading: boolean = true
let notifyContext: DocNotifyContext | undefined = undefined
let scrollElement: HTMLDivElement | undefined
locationStore.subscribe((newLocation) => {
loc = newLocation
})
$: docNotifyContextByDocStore = inboxClient.docNotifyContextByDoc
$: selectedMessageQuery.query(
activity.class.ActivityMessage,
{ _id },
(result: ActivityMessage[]) => {
selectedMessage = result[0]
notifyContext = $docNotifyContextByDocStore.get(selectedMessage.attachedTo)
},
{
limit: 1
@@ -66,76 +73,57 @@
}
$: selectedMessage && loadObject(selectedMessage.attachedTo, selectedMessage.attachedToClass)
$: objectPresenter =
selectedMessage && hierarchy.classHierarchyMixin(selectedMessage.attachedToClass, view.mixin.ObjectPresenter)
$: selectedMessage &&
messagesQuery.query(
activity.class.ActivityMessage,
{ attachedTo: selectedMessage.attachedTo },
(result: ActivityMessage[]) => {
messages = combineActivityMessages(result)
isLoading = false
},
{
sort: {
modifiedOn: SortingOrder.Ascending
}
}
)
function scrollToBottom () {
setTimeout(() => {
if (scrollElement !== undefined) {
scrollElement.scrollTo(0, scrollElement.scrollHeight)
}
}, 100)
}
$: isThread = loc.query?.thread === 'true'
$: threadId =
selectedMessage?._class === chunter.class.ThreadMessage ? selectedMessage.attachedTo : selectedMessage?._id
</script>
<div class="ac-header full divide caption-height withoutBackground">
<div class="ac-header__wrap-title mr-3">
{#if objectPresenter && object}
<Component is={objectPresenter.presenter} props={{ value: object }} />
{/if}
{#if isThread && selectedMessage}
<Component
is={chunter.component.ThreadView}
props={{ _id: threadId, selectedMessageId: selectedMessage._id }}
on:close={() => dispatch('close')}
/>
{:else}
<div class="ac-header full divide caption-height withoutBackground">
<div class="ac-header__wrap-title mr-3">
{#if objectPresenter && object}
<Component is={objectPresenter.presenter} props={{ value: object }} />
{/if}
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="tool" on:click={() => dispatch('close')}>
<IconClose size="medium" />
</div>
</div>
{messages.length}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="tool" on:click={() => dispatch('close')}>
<IconClose size="medium" />
</div>
</div>
{#if isLoading}
<div class="spinner">
<Spinner size="small" />
</div>
{/if}
{#if isLoading}
<div class="spinner">
<Spinner size="small" />
</div>
{/if}
{#if !isLoading}
<div class="flex-col vScroll content" bind:this={scrollElement}>
{#each messages as message}
<ActivityMessagePresenter
value={message}
isHighlighted={message._id === _id}
shouldScroll={_id === message._id}
/>
{/each}
</div>
{/if}
{#if object}
<div class="ref-input">
<ActivityExtension kind="input" props={{ object }} on:submit={scrollToBottom} />
</div>
{#if object}
<ActivityScrolledView
bind:isLoading
selectedMessageId={_id}
{object}
lastViewedTimestamp={notifyContext?.lastViewedTimestamp}
_class={hierarchy.isDerived(object._class, chunter.class.ChunterSpace)
? chunter.class.ChatMessage
: activity.class.ActivityMessage}
skipLabels={hierarchy.isDerived(object._class, chunter.class.ChunterSpace)}
/>
{/if}
{/if}
<style lang="scss">
.content {
padding: 0 24px;
}
.spinner {
display: flex;
align-items: center;
@@ -153,8 +141,4 @@
opacity: 1;
}
}
.ref-input {
margin: 1.25rem 1rem;
}
</style>
@@ -0,0 +1,32 @@
<!--
// Copyright © 2023 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 view from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { Component } from '@hcengineering/ui'
import { Class, Doc, Ref } from '@hcengineering/core'
import { DisplayInboxNotification } from '@hcengineering/notification'
export let value: DisplayInboxNotification
const client = getClient()
const hierarchy = client.getHierarchy()
$: objectPresenter = hierarchy.classHierarchyMixin(value._class as Ref<Class<Doc>>, view.mixin.ObjectPresenter)
</script>
{#if objectPresenter}
<Component is={objectPresenter.presenter} props={{ value }} />
{/if}