New Activity - squashed (#4032)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
Co-authored-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Kristina
2023-12-11 18:54:36 +07:00
committed by GitHub
co-authored by Andrey Sobolev
parent 119d65653a
commit ea3eb4af83
212 changed files with 7611 additions and 3159 deletions
@@ -0,0 +1,102 @@
<!--
// 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 notification, { DocAttributeUpdates } from '@hcengineering/notification'
import { IconAdd, IconDelete, Label } from '@hcengineering/ui'
import { personAccountByIdStore, PersonAccountRefPresenter } from '@hcengineering/contact-resources'
import { Person, PersonAccount } from '@hcengineering/contact'
import { Ref } from '@hcengineering/core'
export let value: DocAttributeUpdates
$: removed = getAccountRefs(value.removed)
$: added = getAccountRefs(value.added.length > 0 ? value.added : value.set)
function getAccountRefs (values: DocAttributeUpdates['removed' | 'added' | 'set']): Ref<PersonAccount>[] {
const persons = new Set<Ref<Person>>()
return values.filter((value) => {
const account = $personAccountByIdStore.get(value as Ref<PersonAccount>)
if (account === undefined) {
return false
}
if (persons.has(account.person)) {
return false
}
persons.add(account.person)
return true
}) as Ref<PersonAccount>[]
}
$: hasDifferentChanges = added.length > 0 && removed.length > 0
</script>
<div class="root">
<div class="label">
{#if hasDifferentChanges}
<Label label={notification.string.ChangedCollaborators} />:
{:else if added.length > 0}
<Label label={notification.string.NewCollaborators} />:
{:else if removed.length > 0}
<Label label={notification.string.RemovedCollaborators} />:
{/if}
</div>
{#if added.length > 0}
<div class="row">
{#if hasDifferentChanges}
<IconAdd size={'x-small'} fill={'var(--theme-trans-color)'} />
{/if}
{#each added as add}
<PersonAccountRefPresenter inline value={add} />
{/each}
</div>
{/if}
<div class="antiHSpacer"></div>
{#if removed.length > 0}
<div class="row">
{#if hasDifferentChanges}
<IconDelete size={'x-small'} fill={'var(--theme-trans-color)'} />
{/if}
{#each removed as remove}
<PersonAccountRefPresenter inline value={remove} />
{/each}
</div>
{/if}
</div>
<style lang="scss">
.root {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.label {
white-space: nowrap;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,30 @@
<!--
// 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 { ActivityMessageExtension, ActivityMessageExtensionKind } from '@hcengineering/notification'
import { Component } from '@hcengineering/ui'
export let kind: ActivityMessageExtensionKind
export let extensions: ActivityMessageExtension[] = []
export let props: Record<string, any> = {}
</script>
{#each extensions as extension}
{#each extension.components as component}
{#if component.kind === kind}
<Component is={component.component} {props} on:close on:open />
{/if}
{/each}
{/each}
@@ -0,0 +1,53 @@
<!--
// 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 { DisplayActivityMessage } from '@hcengineering/notification'
import view from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { Component } from '@hcengineering/ui'
import { Class, Doc, Ref } from '@hcengineering/core'
export let value: DisplayActivityMessage
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let hasNotifyActions = false
export let onClick: (() => void) | undefined = undefined
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,
showNotify,
isHighlighted,
isSelected,
shouldScroll,
embedded,
hasActionsMenu,
hasNotifyActions,
onClick
}}
/>
{/if}
@@ -0,0 +1,278 @@
<!--
// 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 { Person } from '@hcengineering/contact'
import { Avatar, EmployeePresenter, SystemAvatar } from '@hcengineering/contact-resources'
import { getClient } from '@hcengineering/presentation'
import core from '@hcengineering/core/lib/component'
import notification, {
DisplayActivityMessage,
ActivityMessageExtension,
DocUpdateMessageViewlet,
ChatMessageViewlet
} from '@hcengineering/notification'
import { Action, ActionIcon, IconMoreH, Label, showPopup } from '@hcengineering/ui'
import { getActions, Menu } from '@hcengineering/view-resources'
import { getDisplayTime } from '@hcengineering/core'
import ActivityMessageExtensionComponent from './ActivityMessageExtension.svelte'
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
export let message: DisplayActivityMessage
export let parentMessage: DisplayActivityMessage | undefined
export let viewlet: DocUpdateMessageViewlet | ChatMessageViewlet | undefined
export let person: Person | undefined = undefined
export let actions: Action[] = []
export let excludedActions: string[] = []
export let hasNotifyActions = false
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let onClick: (() => void) | undefined = undefined
const client = getClient()
let allActionIds: string[] = []
const notifyActions = [
notification.action.MarkAsUnreadInboxNotification,
notification.action.MarkAsReadInboxNotification,
notification.action.DeleteInboxNotification
]
let element: HTMLDivElement | undefined = undefined
let extensions: ActivityMessageExtension[] = []
let isActionMenuOpened = false
$: getActions(client, message, notification.class.ActivityMessage).then((res) => {
allActionIds = res.map(({ _id }) => _id)
})
function scrollToMessage () {
if (element && shouldScroll) {
element.scrollIntoView({ behavior: 'auto', block: 'end' })
shouldScroll = false
}
}
$: if (element && shouldScroll) {
setTimeout(scrollToMessage, 100)
}
client
.findAll(notification.class.ActivityMessageExtension, { ofMessage: message._class })
.then((res: ActivityMessageExtension[]) => {
extensions = res
})
function handleActionMenuOpened () {
isActionMenuOpened = true
}
function handleActionMenuClosed () {
isActionMenuOpened = false
}
$: key = parentMessage ? `${message._id}_${parentMessage._id}` : message._id
$: actualExcludedActions = hasNotifyActions ? excludedActions : [...notifyActions, ...excludedActions]
function showMenu (ev: MouseEvent) {
showPopup(
Menu,
{
object: message,
baseMenuClass: notification.class.ActivityMessage,
excludedActions: actualExcludedActions,
actions
},
ev.target as HTMLElement,
handleActionMenuClosed
)
handleActionMenuOpened()
}
$: isHidden = !!viewlet?.onlyWithParent && parentMessage === undefined
$: withActionMenu =
!embedded &&
hasActionsMenu &&
(actions.length > 0 || allActionIds.some((id) => !actualExcludedActions.includes(id)))
</script>
{#if !isHidden}
{#key key}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
bind:this={element}
id={message._id}
class="root clear-mins"
class:clickable={!!onClick}
class:highlighted={isHighlighted}
class:selected={isSelected}
class:embedded
on:click={onClick}
>
{#if showNotify && !embedded}
<div class="notify" />
{/if}
{#if !embedded}
<div class="min-w-6 mt-1">
{#if person}
<Avatar size="medium" avatar={person.avatar} name={person.name} />
{:else}
<SystemAvatar size="medium" />
{/if}
</div>
{:else}
<div class="embeddedMarker" />
{/if}
<div class="content ml-2 w-full clear-mins">
<div class="header clear-mins">
{#if person}
<EmployeePresenter value={person} shouldShowAvatar={false} />
{:else}
<span class="strong">
<Label label={core.string.System} />
</span>
{/if}
<slot name="header" />
<span class="text-sm">{getDisplayTime(message.createdOn ?? 0)}</span>
</div>
<slot name="content" />
<ActivityMessageExtensionComponent kind="footer" {extensions} props={{ object: message }} />
{#if parentMessage}
<div class="mt-2" />
<ActivityMessagePresenter value={parentMessage} embedded />
{/if}
</div>
<div
class="actions clear-mins flex flex-gap-2 items-center"
class:menuShowed={isActionMenuOpened || message.isPinned}
>
<ActivityMessageExtensionComponent
kind="action"
{extensions}
props={{ object: message }}
on:close={handleActionMenuClosed}
on:open={handleActionMenuOpened}
/>
{#if withActionMenu}
<ActionIcon icon={IconMoreH} size="small" action={showMenu} />
{/if}
</div>
</div>
{/key}
{/if}
<style lang="scss">
@keyframes highlight {
50% {
background-color: var(--theme-warning-color);
}
}
.root {
position: relative;
display: flex;
flex-shrink: 0;
padding: 0.75rem 0.75rem 0.75rem 1.25rem;
border-radius: 8px;
gap: 1rem;
&.clickable {
cursor: pointer;
}
&.highlighted {
animation: highlight 5000ms ease-in-out;
}
&.selected {
background-color: var(--highlight-select);
}
&.embedded {
background: var(--theme-navpanel-icons-divider);
padding: 0;
.content {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
.actions {
position: absolute;
visibility: hidden;
top: 0.75rem;
right: 0.75rem;
color: var(--theme-halfcontent-color);
&.menuShowed {
visibility: visible;
}
}
.content {
padding: 0;
}
&:hover > .actions {
visibility: visible;
}
&:hover:not(.embedded) {
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%;
}
.embeddedMarker {
width: 6px;
background: var(--theme-link-color);
}
</style>
@@ -0,0 +1,35 @@
<!--
// 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 { ActionIcon } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import { ActivityMessage } from '@hcengineering/notification'
export let object: ActivityMessage
const client = getClient()
async function toggleMessagePinning (): Promise<void> {
await client.update(object, { isPinned: !object.isPinned })
}
</script>
<ActionIcon
icon={view.icon.Pin}
iconProps={object.isPinned ? { fill: '#3265cb' } : undefined}
size="medium"
action={toggleMessagePinning}
/>
@@ -0,0 +1,59 @@
<!--
// 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 { DocNavLink } from '@hcengineering/view-resources'
import { Doc } from '@hcengineering/core'
import { Label } from '@hcengineering/ui'
import notification, { ChatMessage, ChatMessageViewlet } from '@hcengineering/notification'
import { Person } from '@hcengineering/contact'
import { getLinkData, LinkData } from '../../activityMessagesUtils'
export let message: ChatMessage
export let person: Person | undefined
export let viewlet: ChatMessageViewlet | undefined
export let object: Doc | undefined
export let parentObject: Doc | undefined
let linkData: LinkData | undefined = undefined
$: getLinkData(message, object, parentObject, person).then((data) => {
linkData = data
})
</script>
{#if viewlet?.label}
<span class="text-sm lower"> <Label label={viewlet.label} /></span>
{#if linkData}
<span class="text-sm lower"><Label label={linkData.preposition} /></span>
<span class="text-sm">
<DocNavLink {object} component={linkData.panelComponent} shrink={0}>
<span class="overflow-label select-text">{linkData.title}</span>
</DocNavLink>
</span>
{#if message.isEdited}
<span class="text-sm lower"><Label label={notification.string.Edited} /></span>
{/if}
{/if}
{/if}
<style lang="scss">
span {
margin-left: 0.25rem;
font-weight: 400;
line-height: 1.25rem;
}
</style>
@@ -0,0 +1,150 @@
<!--
// 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 { createEventDispatcher } from 'svelte'
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import { Doc, generateId, getCurrentAccount } from '@hcengineering/core'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
import notification, { ChatMessage } from '@hcengineering/notification'
export let object: Doc
export let chatMessage: ChatMessage | undefined = undefined
export let shouldSaveDraft: boolean = true
export let focusIndex: number = -1
export let boundary: HTMLElement | undefined = undefined
type MessageDraft = Pick<ChatMessage, '_id' | 'message' | 'attachments'>
const dispatch = createEventDispatcher()
const client = getClient()
const _class = notification.class.ChatMessage
const createdMessageQuery = createQuery()
const account = getCurrentAccount()
const draftKey = `${object._id}_${_class}`
const draftController = new DraftController<MessageDraft>(draftKey)
const currentDraft = shouldSaveDraft ? $draftsStore[draftKey] : undefined
const emptyMessage = {
message: '<p></p>',
attachments: 0
}
let inputRef: AttachmentRefInput
let currentMessage: MessageDraft = chatMessage ?? currentDraft ?? getDefault()
let _id = currentMessage._id
let inputContent = currentMessage.message
let loading = false
$: createdMessageQuery.query(notification.class.ChatMessage, { _id }, (result: ChatMessage[]) => {
if (result.length > 0 && _id !== chatMessage?._id) {
// Ouch we have got comment with same id created already.
currentMessage = getDefault()
_id = currentMessage._id
inputRef.removeDraft(false)
}
})
function objectChange (draft: MessageDraft, empty: Partial<MessageDraft>) {
if (shouldSaveDraft) {
draftController.save(draft, empty)
}
}
$: objectChange(currentMessage, emptyMessage)
function getDefault (): MessageDraft {
return {
_id: generateId(),
...emptyMessage
}
}
async function onUpdate (event: CustomEvent) {
if (!shouldSaveDraft) {
return
}
const { message, attachments } = event.detail
currentMessage.message = message
currentMessage.attachments = attachments
}
async function onMessage (event: CustomEvent) {
loading = true
try {
draftController.remove()
inputRef.removeDraft(false)
if (chatMessage) {
await editMessage(event)
} else {
await createMessage(event)
}
// Remove draft from Local Storage
currentMessage = getDefault()
_id = currentMessage._id
} catch (err) {
console.error(err)
}
dispatch('submit', false)
loading = false
}
async function createMessage (event: CustomEvent) {
const { message, attachments } = event.detail
await client.addCollection<Doc, ChatMessage>(
_class,
object.space,
object._id,
object._class,
'comments',
{ message, attachments },
_id,
Date.now(),
account._id
)
}
async function editMessage (event: CustomEvent) {
if (chatMessage === undefined) {
return
}
const { message, attachments } = event.detail
await client.update(chatMessage, { message, attachments, isEdited: true })
}
export function submit (): void {
inputRef.submit()
}
</script>
<AttachmentRefInput
{focusIndex}
bind:this={inputRef}
bind:content={inputContent}
{_class}
space={object.space}
bind:objectId={_id}
{shouldSaveDraft}
{boundary}
on:message={onMessage}
on:update={onUpdate}
on:focus
on:blur
bind:loading
/>
@@ -0,0 +1,143 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 { createEventDispatcher } from 'svelte'
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import notification, { ChatMessage } from '@hcengineering/notification'
import { closeTooltip, Label, Lazy, Spinner, resizeObserver, MiniToggle } from '@hcengineering/ui'
import { ObjectPresenter, DocNavLink } from '@hcengineering/view-resources'
import ChatMessageInput from './ChatMessageInput.svelte'
import ChatMessagePresenter from './ChatMessagePresenter.svelte'
export let objectId: Ref<Doc>
export let object: Doc
export let withInput: boolean = true
const dispatch = createEventDispatcher()
const query = createQuery()
let loading = true
let messages: ChatMessage[] = []
let activityOrderNewestFirst = JSON.parse(localStorage.getItem('activity-newest-first') ?? 'false')
$: query.query(
notification.class.ChatMessage,
{ attachedTo: objectId },
(res) => {
messages = res.sort((message) => (message?.isPinned ? -1 : 1))
loading = false
},
{ sort: { createdOn: activityOrderNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending } }
)
let isTextMode = false
$: if (isTextMode) {
dispatch('tooltip', { kind: 'popup' })
}
</script>
<div class="commentPopup-container">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex-between header"
use:resizeObserver={() => {
dispatch('changeContent')
}}
on:keydown={(evt) => {
console.log(evt)
if (isTextMode) {
evt.preventDefault()
evt.stopImmediatePropagation()
closeTooltip()
}
}}
>
<div class="fs-title mr-2">
<Label label={notification.string.Comments} />
</div>
<MiniToggle bind:on={activityOrderNewestFirst} label={notification.string.NewestFirst} />
<DocNavLink {object}>
<ObjectPresenter _class={object._class} objectId={object._id} value={object} />
</DocNavLink>
</div>
<div class="messages">
{#if loading}
<div class="flex-center">
<Spinner />
</div>
{:else}
{#each messages as message}
<div class="item">
<Lazy>
<ChatMessagePresenter value={message} />
</Lazy>
</div>
{/each}
{/if}
</div>
{#if withInput}
<div class="input">
<ChatMessageInput
{object}
on:focus={() => {
isTextMode = true
}}
/>
</div>
{/if}
</div>
<style lang="scss">
.commentPopup-container {
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0;
min-width: 0;
min-height: 0;
max-height: 30rem;
.header {
flex-shrink: 0;
margin: 0 0.25rem 0.5rem;
padding: 0.5rem 1.25rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-divider-color);
}
.messages {
overflow: auto;
flex: 1;
padding: 0 1rem;
min-width: 0;
min-height: 0;
.item {
max-width: 30rem;
}
.item + .item {
margin-top: 0.75rem;
}
}
.input {
padding: 0.5rem 0.25rem 0.25rem;
}
}
</style>
@@ -0,0 +1,197 @@
<!--
// 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 { Person, PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import { Account, Class, Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import notification, { ChatMessage, ChatMessageViewlet, DisplayActivityMessage } from '@hcengineering/notification'
import core from '@hcengineering/core/lib/component'
import { AttachmentDocList } from '@hcengineering/attachment-resources'
import { LinkPresenter } from '@hcengineering/view-resources'
import { Action, Button, IconEdit, ShowMore } from '@hcengineering/ui'
import view from '@hcengineering/view'
import ActivityMessageTemplate from '../activity-message/ActivityMessageTemplate.svelte'
import { getNotificationObject } from '../../activityMessagesUtils'
import ChatMessageHeader from './ChatMessageHeader.svelte'
import ChatMessageInput from './ChatMessageInput.svelte'
export let value: ChatMessage | undefined
export let hasNotifyActions = false
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let onClick: (() => void) | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const viewletQuery = createQuery()
const userQuery = createQuery()
const currentAccount = getCurrentAccount()
let user: PersonAccount | undefined = undefined
let person: Person | undefined = undefined
let parentMessage: DisplayActivityMessage | undefined = undefined
let parentObject: Doc | undefined
let object: Doc | undefined
let viewlet: ChatMessageViewlet | undefined
$: value &&
viewletQuery.query(
notification.class.ChatMessageViewlet,
{ objectClass: value.attachedToClass },
(result: ChatMessageViewlet[]) => {
viewlet = result[0]
}
)
$: value &&
userQuery.query(core.class.Account, { _id: value.createdBy }, (res: Account[]) => {
user = res[0] as PersonAccount
})
$: person = user?.person && $personByIdStore.get(user.person)
$: value &&
getParentMessage(value.attachedToClass, value.attachedTo).then((res) => {
parentMessage = res as DisplayActivityMessage
})
$: value &&
getNotificationObject(client, value.attachedTo, value.attachedToClass).then((result) => {
object = result.object
})
$: parentMessage &&
getNotificationObject(client, parentMessage.attachedTo, parentMessage.attachedToClass).then((result) => {
parentObject = result.object
})
$: links = getLinks(value?.message)
function getLinks (content?: string): HTMLLinkElement[] {
if (!content) {
return []
}
const parser = new DOMParser()
const parent = parser.parseFromString(content, 'text/html').firstChild?.childNodes[1] as HTMLElement
return parseLinks(parent.childNodes)
}
function parseLinks (nodes: NodeListOf<ChildNode>): HTMLLinkElement[] {
const res: HTMLLinkElement[] = []
nodes.forEach((node) => {
if (node.nodeType !== Node.TEXT_NODE) {
if (node.nodeName === 'A') {
res.push(node as HTMLLinkElement)
}
res.push(...parseLinks(node.childNodes))
}
})
return res
}
async function getParentMessage (_class: Ref<Class<Doc>>, _id: Ref<Doc>) {
if (hierarchy.isDerived(_class, notification.class.ActivityMessage)) {
return await client.findOne(_class, { _id })
}
}
async function handleEditAction () {
isEditing = true
}
let isEditing = false
let actions: Action[] = []
$: isOwn = user !== undefined && user._id === currentAccount._id
$: actions = [
...(isOwn
? [
{
label: notification.string.Edit,
icon: IconEdit,
action: handleEditAction
}
]
: [])
]
$: excludedActions = isOwn ? [] : [notification.action.DeleteChatMessage]
let refInput: ChatMessageInput
</script>
{#if value}
<ActivityMessageTemplate
message={value}
{viewlet}
{parentMessage}
{person}
{excludedActions}
{showNotify}
{isHighlighted}
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
{actions}
{hasNotifyActions}
{onClick}
>
<svelte:fragment slot="header">
<ChatMessageHeader {object} {parentObject} message={value} {viewlet} {person} />
</svelte:fragment>
<svelte:fragment slot="content">
{#if !isEditing}
<ShowMore>
<div class="clear-mins">
<MessageViewer message={value.message} />
<AttachmentDocList {value} />
{#each links as link}
<LinkPresenter {link} />
{/each}
</div>
</ShowMore>
{:else if object}
<ChatMessageInput
bind:this={refInput}
chatMessage={value}
{object}
on:submit={() => {
isEditing = false
}}
/>
<div class="flex-row-center gap-2 justify-end mt-2">
<Button
label={view.string.Cancel}
on:click={() => {
isEditing = false
}}
/>
<Button label={notification.string.Update} accent on:click={() => refInput.submit()} />
</div>
{/if}
</svelte:fragment>
</ActivityMessageTemplate>
{/if}
@@ -0,0 +1,47 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 type { Doc } from '@hcengineering/core'
import { Button, ButtonKind, ButtonSize, IconThread } from '@hcengineering/ui'
import ChatMessagePopup from './ChatMessagePopup.svelte'
export let value: number | undefined
export let object: Doc
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'small'
export let showCounter: boolean = true
export let compactMode: boolean = false
export let withInput: boolean = true
</script>
{#if value && value > 0}
<Button
{kind}
{size}
showTooltip={{
component: ChatMessagePopup,
props: { objectId: object._id, object, withInput }
}}
>
<div slot="icon">
<IconThread {size} />
</div>
<div slot="content" style:margin-left={showCounter && !compactMode ? '.375rem' : '0'}>
{#if showCounter && !compactMode}{value}{/if}
</div>
</Button>
{/if}
@@ -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 notification, { DocAttributeUpdates, DocUpdateMessageViewlet } from '@hcengineering/notification'
import { Component } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import AddedAttributesPresenter from './attributes/AddedAttributesPresenter.svelte'
import RemovedAttributesPresenter from './attributes/RemovedAttributesPresenter.svelte'
import SetAttributesPresenter from './attributes/SetAttributesPresenter.svelte'
export let viewlet: DocUpdateMessageViewlet | undefined
export let attributeUpdates: DocAttributeUpdates
export let attributeModel: AttributeModel
const client = getClient()
const hierarchy = client.getHierarchy()
$: presenter =
viewlet?.config?.[attributeModel.key]?.presenter ??
hierarchy.classHierarchyMixin(attributeUpdates.attrClass, notification.mixin.NotificationAttributePresenter)
?.presenter
</script>
{#if presenter}
<Component is={presenter} props={{ value: attributeUpdates }} />
{:else}
{#if attributeUpdates.added.length}
<AddedAttributesPresenter {viewlet} {attributeModel} values={attributeUpdates.added} />
{/if}
{#if attributeUpdates.removed.length}
<RemovedAttributesPresenter {viewlet} {attributeModel} values={attributeUpdates.removed} />
{/if}
{#if attributeUpdates.set.length}
<SetAttributesPresenter
{viewlet}
{attributeModel}
values={attributeUpdates.set}
prevValue={attributeUpdates.prevValue}
/>
{/if}
{/if}
@@ -0,0 +1,115 @@
<!--
// 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 { AttachedDoc, Attribute, Class, Collection, Doc, Ref } from '@hcengineering/core'
import { Icon, Label } from '@hcengineering/ui'
import notification, {
DocUpdateMessageViewlet,
DisplayDocUpdateMessage,
DocUpdateMessage
} from '@hcengineering/notification'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import NotificationObjectValue from './DocUpdateMessageObjectValue.svelte'
export let message: DisplayDocUpdateMessage
export let viewlet: DocUpdateMessageViewlet | undefined
export let objectName: IntlString
export let collectionName: IntlString | undefined
export let objectClass: Ref<Class<Doc>>
export let collectionAttribute: Attribute<Collection<AttachedDoc>> | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const clazz = hierarchy.getClass(message.objectClass)
const objectPanel = hierarchy.classHierarchyMixin(objectClass, view.mixin.ObjectPanel)
const objectPresenter = hierarchy.classHierarchyMixin(objectClass, view.mixin.ObjectPresenter)
const isOwn = message.objectId === message.attachedTo
let valueMessages: DocUpdateMessage[] = []
$: valueMessages = message.previousMessages?.length ? [...message.previousMessages, message] : [message]
$: hasDifferentActions = message.previousMessages?.some(({ action }) => action !== message.action)
$: icon = viewlet?.icon ?? collectionAttribute?.icon ?? clazz.icon ?? notification.icon.Notifications
</script>
<div class="content">
<span class="mr-1">
<Icon {icon} size="small" />
</span>
{#if hasDifferentActions}
<Label label={notification.string.UpdatedCollection} />
{:else if message.action === 'create'}
<Label label={notification.string.New} />
{:else if message.action === 'remove' && message.updateCollection}
<Label label={notification.string.Removed} />
{/if}
<span class="lower">
{#if collectionName && (message.previousMessages?.length || !isOwn)}
<Label label={collectionName} />:
{:else}
<Label label={objectName} />:
{/if}
</span>
{#if hasDifferentActions}
{@const removeMessages = valueMessages.filter(({ action }) => action === 'remove')}
{@const createMessages = valueMessages.filter(({ action }) => action === 'create')}
{#each createMessages as valueMessage, index}
<NotificationObjectValue
message={valueMessage}
{objectPresenter}
{objectPanel}
{viewlet}
withIcon={index === 0}
hasSeparator={createMessages.length > 1 && index !== createMessages.length - 1}
/>
{/each}
{#each removeMessages as valueMessage, index}
<NotificationObjectValue
message={valueMessage}
{objectPresenter}
{objectPanel}
{viewlet}
withIcon={index === 0}
hasSeparator={removeMessages.length > 1 && index !== removeMessages.length - 1}
/>
{/each}
{:else}
{#each valueMessages as valueMessage, index}
<NotificationObjectValue
message={valueMessage}
{objectPresenter}
{objectPanel}
{viewlet}
hasSeparator={valueMessages.length > 1 && index !== valueMessages.length - 1}
/>
{/each}
{/if}
</div>
<style lang="scss">
.content {
display: flex;
gap: 0.25rem;
align-items: center;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,109 @@
<!--
// 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 { DocNavLink } from '@hcengineering/view-resources'
import { Doc } from '@hcengineering/core'
import { Label } from '@hcengineering/ui'
import notification, { DocUpdateMessageViewlet, DisplayDocUpdateMessage } from '@hcengineering/notification'
import { IntlString } from '@hcengineering/platform'
import { AttributeModel } from '@hcengineering/view'
import { Person } from '@hcengineering/contact'
import { LinkData, getLinkData } from '../../activityMessagesUtils'
export let message: DisplayDocUpdateMessage
export let viewlet: DocUpdateMessageViewlet | undefined
export let person: Person | undefined
export let objectName: IntlString | undefined = undefined
export let object: Doc | undefined
export let parentObject: Doc | undefined
export let attributeModel: AttributeModel | undefined = undefined
export let collectionName: IntlString | undefined = undefined
const isOwn = message.objectId === message.attachedTo
let linkData: LinkData | undefined = undefined
$: getLinkData(message, object, parentObject, person).then((data) => {
linkData = data
})
function getTitle (): IntlString {
const { attributeUpdates } = message
const added = attributeUpdates?.added ?? []
const removed = attributeUpdates?.removed ?? []
if (added.length > 0 && removed.length === 0) {
return notification.string.Added
}
if (removed.length > 0 && added.length === 0) {
return notification.string.Removed
}
return notification.string.Changed
}
</script>
{#if viewlet?.label}
<span class="text-sm lower"> <Label label={viewlet.label} /></span>
{:else if message.previousMessages?.some(({ action }) => action !== message.action)}
{@const name = collectionName ?? objectName}
<span class="text-sm lower">
<Label label={notification.string.Updated} />
{#if name}
<Label label={name} />
{/if}
</span>
{:else if message.action === 'create' && objectName}
<span class="text-sm lower">
<Label label={isOwn ? notification.string.Created : notification.string.Added} />
{#if collectionName && (!isOwn || message.previousMessages?.length)}
<Label label={collectionName} />
{:else}
<Label label={objectName} />
{/if}
</span>
{:else if message.action === 'remove' && objectName}
<span class="text-sm lower">
<Label label={notification.string.Removed} />
{#if collectionName && message.previousMessages?.length}
<Label label={collectionName} />
{:else}
<Label label={objectName} />
{/if}
</span>
{:else if attributeModel}
<span class="text-sm lower"><Label label={getTitle()} /></span>
<span class="text-sm lower"> <Label label={attributeModel.label} /></span>
{/if}
{#if linkData}
{#if !isOwn || message.action === 'update'}
<span class="text-sm lower"><Label label={linkData.preposition} /></span>
{/if}
<span class="text-sm">
<DocNavLink object={linkData.object} component={linkData.panelComponent} shrink={0}>
<span class="overflow-label select-text">{linkData.title}</span>
</DocNavLink>
</span>
{/if}
<style lang="scss">
span {
margin-left: 0.25rem;
font-weight: 400;
line-height: 1.25rem;
}
</style>
@@ -0,0 +1,87 @@
<!--
// 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 { DocNavLink, getDocLinkTitle } from '@hcengineering/view-resources'
import { Component, Icon, IconAdd, IconDelete } from '@hcengineering/ui'
import { DocUpdateMessageViewlet, DisplayDocUpdateMessage } from '@hcengineering/notification'
import { getClient } from '@hcengineering/presentation'
import view, { ObjectPanel, ObjectPresenter } from '@hcengineering/view'
import { Doc } from '@hcengineering/core'
import { getNotificationObject } from '../../activityMessagesUtils'
export let message: DisplayDocUpdateMessage
export let viewlet: DocUpdateMessageViewlet | undefined
export let objectPanel: ObjectPanel | undefined
export let objectPresenter: ObjectPresenter | undefined
export let withIcon: boolean = false
export let hasSeparator: boolean = false
const client = getClient()
async function getValue (object: Doc | undefined): Promise<string | undefined> {
if (object === undefined) {
return ''
}
if (viewlet?.valueAttr) {
return (object as any)[viewlet.valueAttr]
}
return await getDocLinkTitle(client, object._id, object._class, object)
}
</script>
{#await getNotificationObject(client, message.objectId, message.objectClass) then { object }}
{#await getValue(object) then value}
{#if withIcon && message.action === 'create'}
<Icon icon={IconAdd} size="x-small" />
{/if}
{#if withIcon && message.action === 'remove'}
<Icon icon={IconDelete} size="x-small" />
{/if}
{#if value}
<span>
<DocNavLink
{object}
disabled={message.action === 'remove'}
component={objectPanel?.component ?? view.component.EditDoc}
shrink={0}
>
<span class="overflow-label select-text valueLink">{value}</span>
</DocNavLink>
{#if hasSeparator}
<span class="separator">,</span>
{/if}
</span>
{:else if objectPresenter && object}
<Component is={objectPresenter.presenter} props={{ value: object, accent: true, shouldShowAvatar: false }} />
{/if}
{/await}
{/await}
<style lang="scss">
.valueLink {
font-weight: 500;
color: var(--theme-link-color);
}
.separator {
font-weight: 500;
color: var(--theme-link-color);
margin-left: -0.25rem;
}
</style>
@@ -0,0 +1,211 @@
<!--
// 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 { Person, PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import { Account, AttachedDoc, Class, Collection, Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import notification, {
ActivityMessage,
DocUpdateMessageViewlet,
DocUpdateMessage,
DisplayDocUpdateMessage,
DisplayActivityMessage,
DocUpdateAction
} from '@hcengineering/notification'
import core from '@hcengineering/core/lib/component'
import { AttributeModel } from '@hcengineering/view'
import { IntlString } from '@hcengineering/platform'
import { Component, ShowMore } from '@hcengineering/ui'
import ActivityMessageTemplate from '../activity-message/ActivityMessageTemplate.svelte'
import DocUpdateMessageHeader from './DocUpdateMessageHeader.svelte'
import DocUpdateMessageContent from './DocUpdateMessageContent.svelte'
import DocUpdateMessageAttributes from './DocUpdateMessageAttributes.svelte'
import { getAttributeModel, getCollectionAttribute, getNotificationObject } from '../../activityMessagesUtils'
export let value: DisplayDocUpdateMessage
export let hasNotifyActions = false
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let onClick: (() => void) | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const viewletQuery = createQuery()
const userQuery = createQuery()
const collectionAttribute = getCollectionAttribute(hierarchy, value.attachedToClass, value.updateCollection)
const clazz = hierarchy.getClass(value.objectClass)
const objectName: IntlString | undefined =
(collectionAttribute?.type as Collection<AttachedDoc>)?.itemLabel || clazz.label
const collectionName = collectionAttribute?.label
let user: PersonAccount | undefined = undefined
let person: Person | undefined = undefined
let viewlet: DocUpdateMessageViewlet | undefined
let attributeModel: AttributeModel | undefined = undefined
let parentMessage: DisplayActivityMessage | undefined = undefined
let parentObject: Doc | undefined
let object: Doc | undefined
let isObjectRemoved: boolean = false
let isViewletLoading = true
let isObjectLoading = true
$: isLoading = isViewletLoading || isObjectLoading
$: loadViewlet(value.action, value.objectClass)
async function loadViewlet (action: DocUpdateAction, objectClass: Ref<Class<Doc>>) {
isViewletLoading = true
const res = viewletQuery.query(
notification.class.DocUpdateMessageViewlet,
{ action, objectClass },
(result: DocUpdateMessageViewlet[]) => {
viewlet = result[0]
isViewletLoading = false
}
)
if (!res) {
isViewletLoading = false
}
}
$: getAttributeModel(client, value.attributeUpdates, value.attachedToClass).then((model) => {
attributeModel = model
})
async function getParentMessage (_class: Ref<Class<Doc>>, _id: Ref<Doc>): Promise<ActivityMessage | undefined> {
if (hierarchy.isDerived(_class, notification.class.ActivityMessage)) {
return await client.findOne(notification.class.ActivityMessage, { _id: _id as Ref<ActivityMessage> })
}
}
$: getParentMessage(value.attachedToClass, value.attachedTo).then((res) => {
parentMessage = res as DisplayActivityMessage
})
$: userQuery.query(core.class.Account, { _id: value.createdBy }, (res: Account[]) => {
user = res[0] as PersonAccount
})
$: person = user?.person && $personByIdStore.get(user.person)
$: getNotificationObject(client, value.objectId, value.objectClass).then((result) => {
isObjectLoading = false
object = result.object
isObjectRemoved = result.isRemoved
})
$: getParentObject(value, parentMessage).then((result) => {
parentObject = result?.object
})
async function getParentObject (message: DocUpdateMessage, parentMessage?: ActivityMessage) {
if (parentMessage) {
return await getNotificationObject(client, parentMessage.attachedTo, parentMessage.attachedToClass)
}
if (message.objectId === message.attachedTo) {
return
}
return await getNotificationObject(client, message.attachedTo, message.attachedToClass)
}
$: if (object && value.objectClass !== object._class) {
object = undefined
}
</script>
{#if !isLoading && (!viewlet?.hideIfRemoved || !isObjectRemoved) && (value.action !== 'update' || attributeModel !== undefined)}
<ActivityMessageTemplate
message={value}
{parentMessage}
{person}
{showNotify}
{isHighlighted}
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
{viewlet}
{hasNotifyActions}
{onClick}
>
<svelte:fragment slot="header">
{#if viewlet?.labelComponent}
<Component is={viewlet.labelComponent} props={{ value: object }} />
{:else}
<DocUpdateMessageHeader
message={value}
{object}
{parentObject}
{viewlet}
{person}
{objectName}
{collectionName}
{attributeModel}
/>
{/if}
</svelte:fragment>
<svelte:fragment slot="content">
{#if viewlet?.component}
<ShowMore>
<div class="customContent">
{#each [...(value?.previousMessages ?? []), value] as msg}
{#await getNotificationObject(client, msg.objectId, msg.objectClass) then { object }}
{#if object}
<Component is={viewlet.component} props={{ message: value, value: object }} />
{/if}
{/await}
{/each}
</div>
</ShowMore>
{:else if value.action === 'create' || value.action === 'remove'}
<ShowMore>
<DocUpdateMessageContent
objectClass={value.objectClass}
message={value}
{viewlet}
{objectName}
{collectionName}
{collectionAttribute}
/>
</ShowMore>
{:else if value.attributeUpdates && attributeModel}
<DocUpdateMessageAttributes attributeUpdates={value.attributeUpdates} {attributeModel} {viewlet} />
{/if}
</svelte:fragment>
</ActivityMessageTemplate>
{/if}
<style lang="scss">
.customContent {
display: flex;
flex-wrap: wrap;
column-gap: 0.625rem;
row-gap: 0.625rem;
}
</style>
@@ -0,0 +1,31 @@
<!--
// 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 notification, { DocAttributeUpdates, DocUpdateMessageViewlet } from '@hcengineering/notification'
import { Label } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import ChangeAttributesTemplate from './ChangeAttributesTemplate.svelte'
export let viewlet: DocUpdateMessageViewlet | undefined
export let attributeModel: AttributeModel
export let values: DocAttributeUpdates['added']
</script>
<ChangeAttributesTemplate {viewlet} {attributeModel} {values}>
<svelte:fragment slot="text">
<Label label={notification.string.New} />
<span class="lower"><Label label={attributeModel.label} />:</span>
</svelte:fragment>
</ChangeAttributesTemplate>
@@ -0,0 +1,72 @@
<!--
// 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 { DocAttributeUpdates, DocUpdateMessageViewlet } from '@hcengineering/notification'
import { Icon, IconEdit, Component } from '@hcengineering/ui'
import { ObjectPresenter } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
import { Doc } from '@hcengineering/core'
import { AttributeModel } from '@hcengineering/view'
import { getAttributeValues } from '../../../activityMessagesUtils'
type Values = DocAttributeUpdates['set'] | DocAttributeUpdates['added'] | DocAttributeUpdates['removed']
export let viewlet: DocUpdateMessageViewlet | undefined
export let attributeModel: AttributeModel
export let values: Values
const client = getClient()
let attributeValues: Values | Doc[] = []
$: getAttributeValues(client, values, attributeModel._class).then((result) => {
attributeValues = result
})
$: attrViewletConfig = viewlet?.config?.[attributeModel.key]
$: attributeIcon = attrViewletConfig?.icon ?? attributeModel.icon ?? IconEdit
$: space = typeof attributeValues[0] === 'object' ? attributeValues[0]?.space : undefined
</script>
<div class="content">
<span class="mr-1">
{#if attrViewletConfig?.iconPresenter}
<Component is={attrViewletConfig?.iconPresenter} props={{ value: attributeValues[0], space, size: 'small' }} />
{:else}
<Icon icon={attributeIcon} size="small" />
{/if}
</span>
<slot name="text" />
{#each attributeValues as value}
<span class="strong">
{#if value !== null && typeof value === 'object'}
<ObjectPresenter {value} shouldShowAvatar={false} accent />
{:else}
<svelte:component this={attributeModel.presenter} {value} shouldShowAvatar={false} accent kind="list-header" />
{/if}
</span>
{/each}
</div>
<style lang="scss">
.content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
</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 notification, { DocAttributeUpdates, DocUpdateMessageViewlet } from '@hcengineering/notification'
import { Label } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import ChangeAttributesTemplate from './ChangeAttributesTemplate.svelte'
export let viewlet: DocUpdateMessageViewlet | undefined
export let attributeModel: AttributeModel
export let values: DocAttributeUpdates['removed']
</script>
<ChangeAttributesTemplate {viewlet} {attributeModel} {values}>
<svelte:fragment slot="text">
<Label label={notification.string.Removed} />
<span class="lower"> <Label label={attributeModel.label} />:</span>
</svelte:fragment>
</ChangeAttributesTemplate>
@@ -0,0 +1,113 @@
<!--
// 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 notification, { DocAttributeUpdates, DocUpdateMessageViewlet } from '@hcengineering/notification'
import ui, { Icon, Label, IconEdit } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import core from '@hcengineering/core/lib/component'
import ChangeAttributesTemplate from './ChangeAttributesTemplate.svelte'
export let viewlet: DocUpdateMessageViewlet | undefined
export let attributeModel: AttributeModel
export let values: DocAttributeUpdates['set']
export let prevValue: any
$: attrViewletConfig = viewlet?.config?.[attributeModel.key]
$: attributeIcon = attrViewletConfig?.icon ?? attributeModel.icon ?? IconEdit
$: isUnset = values.length > 0 && !values.some((value) => value !== null && value !== '')
$: isTextType = getIsTextType(attributeModel)
function getIsTextType (attributeModel: AttributeModel): boolean {
return attributeModel.attribute?.type?._class === core.class.TypeMarkup
}
let isDiffShown = false
function toggleShowMore () {
isDiffShown = !isDiffShown
}
</script>
{#if isUnset}
<div class="unset">
<span class="mr-1"><Icon icon={attributeIcon} size="small" /></span>
<Label label={notification.string.Unset} />
<span class="lower"><Label label={attributeModel.label} /></span>
</div>
{:else if isTextType}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="showMore" on:click={toggleShowMore}>
<div class="triangle" class:left={!isDiffShown} class:down={isDiffShown} />
<Label label={isDiffShown ? ui.string.ShowLess : ui.string.ShowMore} />
</div>
{#if isDiffShown}
<svelte:component this={attributeModel.presenter} value={values[0]} {prevValue} showOnlyDiff />
{/if}
{:else}
<ChangeAttributesTemplate {viewlet} {attributeModel} {values}>
<svelte:fragment slot="text">
<Label label={attributeModel.label} />
<span class="lower"><Label label={notification.string.Set} /></span>
<span class="lower"><Label label={notification.string.To} /></span>
</svelte:fragment>
</ChangeAttributesTemplate>
{/if}
<style lang="scss">
.unset {
display: flex;
align-items: center;
gap: 0.25rem;
}
.showMore {
color: var(--theme-link-color);
cursor: pointer;
display: flex;
align-items: center;
font-weight: 500;
gap: 0.5rem;
.triangle {
width: 0;
height: 0;
&.left {
border-top: 0.25rem solid transparent;
border-bottom: 0.25rem solid transparent;
border-left: 0.25rem solid var(--theme-link-color);
border-right: none;
}
&.down {
border-left: 0.25rem solid transparent;
border-right: 0.25rem solid transparent;
border-top: 0.25rem solid var(--theme-link-color);
border-bottom: none;
}
}
&:hover {
color: var(--theme-toggle-on-bg-hover);
.triangle {
border-left-color: var(--theme-toggle-on-bg-hover);
}
}
}
</style>
@@ -0,0 +1,24 @@
<!--
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
d="M10 2C14.4183 2 18 5.58172 18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2ZM10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17C13.866 17 17 13.866 17 10C17 6.13401 13.866 3 10 3ZM7.15467 12.4273C8.66416 13.9463 11.0877 14.0045 12.6671 12.5961L12.8453 12.4273C13.04 12.2314 13.3566 12.2304 13.5524 12.4251C13.7265 12.5981 13.7467 12.8674 13.6123 13.0627L13.5547 13.1322L13.5323 13.1545C11.5691 15.1054 8.39616 15.0953 6.44533 13.1322C6.25069 12.9363 6.25169 12.6197 6.44757 12.4251C6.64344 12.2304 6.96002 12.2314 7.15467 12.4273ZM12.5 7.5C13.0523 7.5 13.5 7.94772 13.5 8.5C13.5 9.05228 13.0523 9.5 12.5 9.5C11.9477 9.5 11.5 9.05228 11.5 8.5C11.5 7.94772 11.9477 7.5 12.5 7.5ZM7.5 7.5C8.05228 7.5 8.5 7.94772 8.5 8.5C8.5 9.05228 8.05228 9.5 7.5 9.5C6.94772 9.5 6.5 9.05228 6.5 8.5C6.5 7.94772 6.94772 7.5 7.5 7.5Z"
/>
</svg>
@@ -0,0 +1,27 @@
<!--
// 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">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.1,2.9C11.8,1.5,9.9,0.7,8,0.7c-1.9,0-3.8,0.8-5.1,2.1C0.7,5,0.1,8.3,1.4,11c0.1,0.3,0.2,0.5,0.2,0.7 c0,0.2-0.1,0.5-0.2,0.8c-0.2,0.6-0.5,1.4,0.1,1.9c0.6,0.6,1.4,0.3,1.9,0.1c0.3-0.1,0.6-0.2,0.8-0.2c0.2,0,0.4,0.1,0.7,0.2 c1,0.4,2,0.7,3,0.7c1.9,0,3.8-0.7,5.2-2.1h0C16,10.3,16,5.7,13.1,2.9z M12.3,12.3c-1.8,1.8-4.5,2.3-6.9,1.2 c-0.4-0.2-0.8-0.3-1.2-0.3c-0.4,0-0.8,0.1-1.2,0.3c-0.2,0.1-0.6,0.2-0.7,0.2c0-0.1,0.1-0.5,0.2-0.7c0.1-0.4,0.3-0.8,0.3-1.2 c0-0.4-0.2-0.8-0.3-1.2C1.4,8.3,1.9,5.5,3.7,3.7C4.9,2.6,6.4,1.9,8,1.9s3.1,0.6,4.3,1.8C14.7,6.1,14.7,9.9,12.3,12.3L12.3,12.3z"
/>
<path d="M10.6,7.5L10.6,7.5c-0.4,0-0.8,0.3-0.8,0.8S10.2,9,10.6,9s0.8-0.3,0.8-0.8S11,7.5,10.6,7.5z" />
<path d="M8,7.5L8,7.5c-0.4,0-0.8,0.3-0.8,0.8S7.5,9,8,9s0.8-0.3,0.8-0.8S8.4,7.5,8,7.5z" />
<path d="M5.3,7.5L5.3,7.5c-0.4,0-0.8,0.3-0.8,0.8S4.9,9,5.3,9S6,8.7,6,8.3S5.7,7.5,5.3,7.5z" />
</svg>
@@ -0,0 +1,141 @@
<!--
// 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 {
DisplayActivityMessage,
InboxNotification,
DocNotifyContext,
ActivityMessage,
DisplayDocUpdateMessage
} 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 { IntlString } from '@hcengineering/platform'
import ActivityMessagePresenter from '../activity-message/ActivityMessagePresenter.svelte'
import notification from '../../plugin'
import { getDisplayActivityMessagesByNotifications } from '../../activityMessagesUtils'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import Filter from '../Filter.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 notificationsClient = InboxNotificationsClientImpl.getClient()
const client = getClient()
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]))
})
notificationsClient.inboxNotifications.subscribe((res: InboxNotification[]) => {
inboxNotifications = res
inboxNotificationByMessage = new Map(res.map((notification) => [notification.attachedTo, notification]))
})
function markNotificationAsViewed (message: DisplayActivityMessage) {
const combinedIds =
message._class === notification.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, notification.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
context={{
mode: 'browser'
}}
/>
<div class="ac-header full divide caption-height">
<div class="ac-header__wrap-title mr-3">
<span class="ac-header__title"><Label {label} /></span>
</div>
<div class="flex flex-gap-2">
<Filter bind:filter />
</div>
</div>
<Scroller>
<div class="content">
{#each displayMessages as message}
<ActivityMessagePresenter
value={message}
showNotify={!inboxNotificationByMessage.get(message._id)?.isViewed}
hasNotifyActions
isSelected={message._id === selectedMessageId}
onClick={() => {
handleMessageClicked(message)
}}
/>
{/each}
</div>
</Scroller>
<style lang="scss">
.content {
padding: 0 24px;
}
</style>
@@ -0,0 +1,147 @@
<!--
// 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 { createEventDispatcher } from 'svelte'
import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, IconClose, Spinner } from '@hcengineering/ui'
import notification, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/notification'
import view from '@hcengineering/view'
import ActivityMessagePresenter from '../activity-message/ActivityMessagePresenter.svelte'
import { combineActivityMessages, getNotificationObject } from '../../activityMessagesUtils'
import ChatMessageInput from '../chat-message/ChatMessageInput.svelte'
export let _id: Ref<ActivityMessage>
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const selectedMessageQuery = createQuery()
const messagesQuery = createQuery()
let messages: DisplayActivityMessage[] = []
let selectedMessage: ActivityMessage | undefined = undefined
let object: Doc | undefined = undefined
let isLoading: boolean = true
let scrollElement: HTMLDivElement | undefined
$: selectedMessageQuery.query(
notification.class.ActivityMessage,
{ _id },
(result: ActivityMessage[]) => {
selectedMessage = result[0]
},
{
limit: 1
}
)
$: selectedMessage &&
getNotificationObject(client, selectedMessage.attachedTo, selectedMessage.attachedToClass).then((res) => {
object = res.object
})
$: objectPresenter =
selectedMessage && hierarchy.classHierarchyMixin(selectedMessage.attachedToClass, view.mixin.ObjectPresenter)
$: selectedMessage &&
messagesQuery.query(
notification.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)
}
</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}
</div>
{messages.length}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<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="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">
<ChatMessageInput {object} on:submit={scrollToBottom} />
</div>
{/if}
<style lang="scss">
.content {
padding: 0 24px;
}
.spinner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.tool {
margin-left: 0.75rem;
opacity: 0.4;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.ref-input {
margin: 1.25rem 1rem;
}
</style>