TSK-959 Inbox (part1) (#2885)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-04-05 13:12:06 +06:00 committed by GitHub
parent b2fe37e540
commit 0e6243dcea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1159 additions and 588 deletions

View File

@ -204,10 +204,34 @@ export function createModel (builder: Builder, options = { addApplication: true
fields: ['members']
})
builder.mixin(chunter.class.Channel, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['members']
})
builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ObjectPanel, {
component: chunter.component.ChannelViewPanel
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectPanel, {
component: chunter.component.ChannelViewPanel
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.DmPresenter
})
builder.mixin(chunter.class.Message, core.class.Class, notification.mixin.NotificationObjectPresenter, {
presenter: chunter.component.ThreadParentPresenter
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.MessagePresenter
})
builder.mixin(chunter.class.Message, core.class.Class, view.mixin.ObjectPanel, {
component: chunter.component.ThreadViewPanel
})
builder.mixin(chunter.class.Message, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.MessagePresenter
})

View File

@ -26,6 +26,7 @@ export default mergeIds(chunterId, chunter, {
component: {
CommentPresenter: '' as AnyComponent,
ChannelPresenter: '' as AnyComponent,
DirectMessagePresenter: '' as AnyComponent,
MessagePresenter: '' as AnyComponent,
DmPresenter: '' as AnyComponent,
Threads: '' as AnyComponent,

View File

@ -108,6 +108,7 @@ export class TContact extends TDoc implements Contact {
@UX(contact.string.Channel, contact.icon.Person)
export class TChannel extends TAttachedDoc implements Channel {
@Prop(TypeRef(contact.class.ChannelProvider), contact.string.ChannelProvider)
@Index(IndexKind.Indexed)
provider!: Ref<ChannelProvider>
@Prop(TypeString(), contact.string.Value)

View File

@ -30,6 +30,7 @@
"@hcengineering/ui": "^0.6.3",
"@hcengineering/platform": "^0.6.8",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/workbench": "^0.6.2",
"@hcengineering/notification": "^0.6.8",
"@hcengineering/setting": "^0.6.3"
}

View File

@ -14,14 +14,17 @@
// limitations under the License.
//
import { Account, Doc, Domain, DOMAIN_MODEL, IndexKind, Ref, TxCUD } from '@hcengineering/core'
import { Account, Class, Doc, Domain, DOMAIN_MODEL, IndexKind, Ref, Timestamp, TxCUD } from '@hcengineering/core'
import { ArrOf, Builder, Index, Mixin, Model, Prop, TypeRef, TypeString, UX } from '@hcengineering/model'
import core, { TAttachedDoc, TClass, TDoc } from '@hcengineering/model-core'
import type {
import {
AnotherUserNotifications,
DocUpdates,
EmailNotification,
LastView,
Notification,
notificationId,
NotificationObjectPresenter,
NotificationProvider,
NotificationSetting,
NotificationStatus,
@ -30,7 +33,9 @@ import type {
} from '@hcengineering/notification'
import type { IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import workbench from '@hcengineering/workbench'
import notification from './plugin'
import { AnyComponent } from '@hcengineering/ui'
export const DOMAIN_NOTIFICATION = 'notification' as Domain
@ -121,9 +126,29 @@ export class TTrackedDoc extends TClass {}
@UX(notification.string.Collaborators)
export class TCollaborators extends TDoc {
@Prop(ArrOf(TypeRef(core.class.Account)), notification.string.Collaborators)
@Index(IndexKind.Indexed)
collaborators!: Ref<Account>[]
}
@Mixin(notification.mixin.NotificationObjectPresenter, core.class.Class)
export class TNotificationObjectPresenter extends TClass implements NotificationObjectPresenter {
presenter!: AnyComponent
}
@Model(notification.class.DocUpdates, core.class.Doc, DOMAIN_NOTIFICATION)
export class TDocUpdates extends TDoc implements DocUpdates {
@Index(IndexKind.Indexed)
user!: Ref<Account>
@Index(IndexKind.Indexed)
attachedTo!: Ref<Doc>
attachedToClass!: Ref<Class<Doc>>
lastTx?: Ref<TxCUD<Doc>>
lastTxTime?: Timestamp
txes!: [Ref<TxCUD<Doc>>, Timestamp][]
}
export function createModel (builder: Builder): void {
builder.createModel(
TLastView,
@ -136,7 +161,9 @@ export function createModel (builder: Builder): void {
TAnotherUserNotifications,
TClassCollaborators,
TTrackedDoc,
TCollaborators
TCollaborators,
TDocUpdates,
TNotificationObjectPresenter
)
builder.createDoc(
@ -165,19 +192,6 @@ export function createModel (builder: Builder): void {
notification.ids.DMNotification
)
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
{
label: notification.string.Notification,
hidden: true,
textTemplate: '',
htmlTemplate: '',
subjectTemplate: ''
},
notification.ids.CollaboratorNotification
)
// Temporarily disabled, we should think about it
// builder.createDoc(
// notification.class.NotificationProvider,
@ -213,6 +227,20 @@ export function createModel (builder: Builder): void {
},
notification.ids.NotificationSettings
)
builder.createDoc(
workbench.class.Application,
core.space.Model,
{
label: notification.string.Inbox,
icon: notification.icon.Notifications,
alias: notificationId,
position: 'bottom',
hidden: false,
component: notification.component.Inbox
},
notification.app.Notification
)
}
export { notificationOperation } from './migration'

View File

@ -14,6 +14,8 @@
// limitations under the License.
//
import { Ref } from '@hcengineering/core'
import { Application } from '@hcengineering/workbench'
import notification, { notificationId } from '@hcengineering/notification'
import { IntlString, mergeIds } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
@ -29,6 +31,9 @@ export default mergeIds(notificationId, notification, {
EmailNotification: '' as IntlString,
Collaborators: '' as IntlString
},
app: {
Notification: '' as Ref<Application>
},
component: {
NotificationSettings: '' as AnyComponent
}

View File

@ -44,11 +44,11 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.CreateCollaboratorDoc
trigger: serverNotification.trigger.CollaboratorDocHandler
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverNotification.trigger.UpdateCollaboratorDoc
trigger: serverNotification.trigger.OnUpdateLastView
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {

View File

@ -13,31 +13,9 @@
// limitations under the License.
//
import core, { SortingOrder, TxOperations } from '@hcengineering/core'
import telegram from './plugin'
import core, { TxOperations } from '@hcengineering/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import contact from '@hcengineering/model-contact'
async function updateChannlLastMessage (client: TxOperations): Promise<void> {
const channels = await client.findAll(contact.class.Channel, {
provider: contact.channelProvider.Telegram
})
const targets = channels.filter((p) => p.lastMessage === undefined)
for (const channel of targets) {
const lastMessage = await client.findOne(
telegram.class.Message,
{
attachedTo: channel._id
},
{ sort: { sendOn: SortingOrder.Descending } }
)
if (lastMessage !== undefined) {
await client.updateDoc(channel._class, channel.space, channel._id, {
lastMessage: lastMessage.sendOn
})
}
}
}
import telegram from './plugin'
export const telegramOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {},
@ -60,7 +38,5 @@ export const telegramOperation: MigrateOperation = {
telegram.space.Telegram
)
}
await updateChannlLastMessage(tx)
}
}

View File

@ -827,6 +827,10 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.IssuePresenter
})
builder.mixin(tracker.class.Issue, core.class.Class, notification.mixin.NotificationObjectPresenter, {
presenter: tracker.component.NotificationIssuePresenter
})
builder.mixin(tracker.class.IssueTemplate, core.class.Class, view.mixin.ObjectPresenter, {
presenter: tracker.component.IssueTemplatePresenter
})

View File

@ -42,7 +42,8 @@ export default mergeIds(trackerId, tracker, {
Nope: '' as AnyComponent,
SprintSelector: '' as AnyComponent,
IssueStatistics: '' as AnyComponent,
TimeSpendReportPopup: '' as AnyComponent
TimeSpendReportPopup: '' as AnyComponent,
NotificationIssuePresenter: '' as AnyComponent
},
app: {
Tracker: '' as Ref<Application>

View File

@ -32,6 +32,7 @@ export class TApplication extends TDoc implements Application {
label!: IntlString
icon!: Asset
alias!: string
position?: 'top' | 'bottom'
hidden!: boolean
}

View File

@ -464,7 +464,10 @@ export abstract class TxProcessor implements WithTx {
* @public
*/
export class TxFactory {
constructor (readonly account: Ref<Account>) {}
private readonly txSpace: Ref<Space>
constructor (readonly account: Ref<Account>, readonly isDerived: boolean = false) {
this.txSpace = isDerived ? core.space.DerivedTx : core.space.Tx
}
createTxCreateDoc<T extends Doc>(
_class: Ref<Class<T>>,
@ -477,7 +480,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
space: this.txSpace,
objectId: objectId ?? generateId(),
objectClass: _class,
objectSpace: space,
@ -500,7 +503,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxCollectionCUD,
space: core.space.Tx,
space: this.txSpace,
objectId,
objectClass: _class,
objectSpace: space,
@ -523,7 +526,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxUpdateDoc,
space: core.space.Tx,
space: this.txSpace,
modifiedBy: modifiedBy ?? this.account,
modifiedOn: modifiedOn ?? Date.now(),
objectId,
@ -544,7 +547,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxRemoveDoc,
space: core.space.Tx,
space: this.txSpace,
modifiedBy: modifiedBy ?? this.account,
modifiedOn: modifiedOn ?? Date.now(),
objectId,
@ -565,7 +568,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxMixin,
space: core.space.Tx,
space: this.txSpace,
modifiedBy: modifiedBy ?? this.account,
modifiedOn: modifiedOn ?? Date.now(),
objectId,
@ -587,7 +590,7 @@ export class TxFactory {
return {
_id: generateId(),
_class: core.class.TxApplyIf,
space: core.space.Tx,
space: this.txSpace,
modifiedBy: modifiedBy ?? this.account,
modifiedOn: modifiedOn ?? Date.now(),
objectSpace: space,

View File

@ -45,6 +45,7 @@
export let allowClose = true
export let useMaxWidth: boolean | undefined = undefined
export let isFullSize = false
export let embedded = false
</script>
<Panel
@ -56,6 +57,7 @@
on:close
{allowClose}
{floatAside}
{embedded}
bind:useMaxWidth
{isFullSize}
>

View File

@ -341,7 +341,6 @@ export async function getAttributeEditor (
_class: Ref<Class<Obj>>,
key: KeyedAttribute | string
): Promise<AnySvelteComponent | undefined> {
console.log('get attribute editor', _class, key)
const hierarchy = client.getHierarchy()
const attribute = typeof key === 'string' ? hierarchy.getAttribute(_class, key) : key.attr
const presenterClass = attribute !== undefined ? getAttributePresenterClass(hierarchy, attribute) : undefined

View File

@ -61,8 +61,15 @@
flex-direction: column;
min-height: 0;
height: 100%;
border-radius: .5rem;
box-shadow: var(--popup-panel-shadow);
&.embedded {
width: 100%;
}
&:not(.embedded) {
border-radius: .5rem;
box-shadow: var(--popup-panel-shadow);
}
.popupPanel-title {
display: flex;
@ -109,7 +116,9 @@
height: 100%;
background-color: var(--body-color);
border: 1px solid var(--divider-color);
border-radius: 0 0 .5rem .5rem;
&:not(.embedded) {
border-radius: 0 0 .5rem .5rem;
}
&.main {
justify-content: stretch;

View File

@ -34,6 +34,7 @@
export let floatAside = false
export let allowClose = true
export let useMaxWidth: boolean | undefined = undefined
export let embedded = false
const dispatch = createEventDispatcher()
@ -79,74 +80,77 @@
<div
class="popupPanel"
class:embedded
use:resizeObserver={(element) => {
panelWidth = element.clientWidth
checkPanel()
}}
>
<div class="popupPanel-title__bordered {twoRows && !withoutTitle ? 'flex-col flex-no-shrink' : 'flex-row-center'}">
<div class="popupPanel-title {twoRows && !withoutTitle ? 'row-top' : 'row'}">
{#if allowClose}
<Button
icon={IconClose}
kind={'transparent'}
size={'medium'}
on:click={() => {
dispatch('close')
}}
/>
{#if !embedded}
<div class="popupPanel-title__bordered {twoRows && !withoutTitle ? 'flex-col flex-no-shrink' : 'flex-row-center'}">
<div class="popupPanel-title {twoRows && !withoutTitle ? 'row-top' : 'row'}">
{#if allowClose}
<Button
icon={IconClose}
kind={'transparent'}
size={'medium'}
on:click={() => {
dispatch('close')
}}
/>
{/if}
{#if $$slots.navigator}<slot name="navigator" />{/if}
<div class="popupPanel-title__content">
{#if !twoRows && !withoutTitle}<slot name="title" />{/if}
</div>
<div class="buttons-group xsmall-gap">
<slot name="utils" />
{#if isFullSize || useMaxWidth !== undefined || ($$slots.aside && isAside)}
<div class="buttons-divider" />
{/if}
{#if $$slots.aside && isAside}
<Button
icon={IconDetails}
kind={'transparent'}
size={'medium'}
selected={asideShown}
on:click={() => {
asideShown = !asideShown
}}
/>
{/if}
{#if useMaxWidth !== undefined}
<Button
icon={useMaxWidth ? IconMaxWidth : IconMinWidth}
kind={'transparent'}
size={'medium'}
selected={useMaxWidth}
on:click={() => {
useMaxWidth = !useMaxWidth
dispatch('maxWidth', useMaxWidth)
}}
/>
{/if}
{#if isFullSize}
<Button
icon={fullSize ? IconScale : IconScaleFull}
kind={'transparent'}
size={'medium'}
selected={fullSize}
on:click={() => {
fullSize = !fullSize
dispatch('fullsize')
}}
/>
{/if}
</div>
</div>
{#if twoRows && !withoutTitle}
<div class="popupPanel-title row-bottom"><slot name="title" /></div>
{/if}
{#if $$slots.navigator}<slot name="navigator" />{/if}
<div class="popupPanel-title__content">
{#if !twoRows && !withoutTitle}<slot name="title" />{/if}
</div>
<div class="buttons-group xsmall-gap">
<slot name="utils" />
{#if isFullSize || useMaxWidth !== undefined || ($$slots.aside && isAside)}
<div class="buttons-divider" />
{/if}
{#if $$slots.aside && isAside}
<Button
icon={IconDetails}
kind={'transparent'}
size={'medium'}
selected={asideShown}
on:click={() => {
asideShown = !asideShown
}}
/>
{/if}
{#if useMaxWidth !== undefined}
<Button
icon={useMaxWidth ? IconMaxWidth : IconMinWidth}
kind={'transparent'}
size={'medium'}
selected={useMaxWidth}
on:click={() => {
useMaxWidth = !useMaxWidth
dispatch('maxWidth', useMaxWidth)
}}
/>
{/if}
{#if isFullSize}
<Button
icon={fullSize ? IconScale : IconScaleFull}
kind={'transparent'}
size={'medium'}
selected={fullSize}
on:click={() => {
fullSize = !fullSize
dispatch('fullsize')
}}
/>
{/if}
</div>
</div>
{#if twoRows && !withoutTitle}
<div class="popupPanel-title row-bottom"><slot name="title" /></div>
{/if}
</div>
<div class="popupPanel-body {$deviceInfo.isMobile ? 'mobile' : 'main'}" class:asideShown>
{/if}
<div class="popupPanel-body {$deviceInfo.isMobile ? 'mobile' : 'main'}" class:asideShown class:embedded>
{#if $deviceInfo.isMobile}
<Scroller horizontal padding={'.5rem .75rem'}>
<div

View File

@ -22,12 +22,6 @@ export default mergeIds(activityId, activity, {
DocCreated: '' as IntlString,
DocDeleted: '' as IntlString,
CollectionUpdated: '' as IntlString,
Changed: '' as IntlString,
To: '' as IntlString,
Unset: '' as IntlString,
Added: '' as IntlString,
Removed: '' as IntlString,
From: '' as IntlString,
All: '' as IntlString
}
})

View File

@ -41,11 +41,7 @@ async function createPseudoViewlet (
label: IntlString,
display: 'inline' | 'content' | 'emphasized' = 'inline'
): Promise<TxDisplayViewlet> {
const doc = dtx.doc
if (doc === undefined) {
return
}
const docClass: Class<Doc> = client.getModel().getObject(doc._class)
const docClass: Class<Doc> = client.getModel().getObject(dtx.tx.objectClass)
let trLabel = docClass.label !== undefined ? await translate(docClass.label, {}) : undefined
if (dtx.collectionAttribute !== undefined) {
@ -56,7 +52,7 @@ async function createPseudoViewlet (
}
// Check if it is attached doc and collection have title override.
const presenter = await getObjectPresenter(client, doc._class, { key: 'doc-presenter' })
const presenter = await getObjectPresenter(client, dtx.tx.objectClass, { key: 'doc-presenter' })
if (presenter !== undefined) {
let collection = ''
if (dtx.collectionAttribute?.label !== undefined) {

View File

@ -113,7 +113,13 @@ export default plugin(activityId, {
Delete: '' as IntlString,
Edit: '' as IntlString,
Edited: '' as IntlString,
Activity: '' as IntlString
Activity: '' as IntlString,
Changed: '' as IntlString,
To: '' as IntlString,
Unset: '' as IntlString,
Added: '' as IntlString,
From: '' as IntlString,
Removed: '' as IntlString
},
class: {
TxViewlet: '' as Ref<Class<TxViewlet>>,

View File

@ -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">
import { Ref, Space } from '@hcengineering/core'
import ChannelView from './ChannelView.svelte'
export let _id: Ref<Space>
</script>
<div class="antiPanel-component">
<ChannelView space={_id} />
</div>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { chunterId, DirectMessage, Message } from '@hcengineering/chunter'
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import { NavLink } from '@hcengineering/ui'
import chunter from '../plugin'
import { getDmName } from '../utils'
export let value: Message
const client = getClient()
let dm: DirectMessage | undefined
const query = createQuery()
$: query.query(chunter.class.DirectMessage, { _id: value.space }, (result) => {
dm = result[0]
})
</script>
{#if dm}
{#await getDmName(client, dm) then name}
<NavLink app={chunterId} space={value.space}>
<span class="label">{name}</span>
</NavLink>
<div><MessageViewer message={value.content} /></div>
{/await}
{/if}

View File

@ -1,25 +1,22 @@
<!--
// 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 { chunterId, DirectMessage, Message } from '@hcengineering/chunter'
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import { NavLink } from '@hcengineering/ui'
import chunter from '../plugin'
import { getDmName } from '../utils'
import { Message } from '@hcengineering/chunter'
import { MessageViewer } from '@hcengineering/presentation'
export let value: Message
const client = getClient()
let dm: DirectMessage | undefined
const query = createQuery()
$: query.query(chunter.class.DirectMessage, { _id: value.space }, (result) => {
dm = result[0]
})
</script>
{#if dm}
{#await getDmName(client, dm) then name}
<NavLink app={chunterId} space={value.space}>
<span class="label">{name}</span>
</NavLink>
<div><MessageViewer message={value.content} /></div>
{/await}
{/if}
<div><MessageViewer message={value.content} /></div>

View File

@ -0,0 +1,20 @@
<!--
// 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 { Label } from '@hcengineering/ui'
import chunter from '../plugin'
</script>
<Label label={chunter.string.Thread} />

View File

@ -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 { Message } from '@hcengineering/chunter'
import ThreadView from './ThreadView.svelte'
import { Ref, Space } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import chunter from '../plugin'
export let _id: Ref<Message>
let space: Ref<Space> | undefined = undefined
const query = createQuery()
$: query.query(chunter.class.Message, { _id }, (res) => {
space = res[0].space
})
</script>
<div class="antiPanel-component">
{#if space}
<ThreadView {_id} currentSpace={space} />
{/if}
</div>

View File

@ -1,10 +1,9 @@
<script lang="ts">
import { TxCreateDoc } from '@hcengineering/core'
import { Message } from '@hcengineering/chunter'
import { MessageViewer } from '@hcengineering/presentation'
export let tx: TxCreateDoc<Message>
</script>
<div class="flex">
<span>{tx.attributes.content}</span>
</div>
<MessageViewer message={tx.attributes.content} />

View File

@ -35,18 +35,22 @@ import TxMessageCreate from './components/activity/TxMessageCreate.svelte'
import ChannelHeader from './components/ChannelHeader.svelte'
import ChannelPresenter from './components/ChannelPresenter.svelte'
import ChannelView from './components/ChannelView.svelte'
import ChannelViewPanel from './components/ChannelViewPanel.svelte'
import ChunterBrowser from './components/ChunterBrowser.svelte'
import CommentInput from './components/CommentInput.svelte'
import CommentPopup from './components/CommentPopup.svelte'
import CommentPresenter from './components/CommentPresenter.svelte'
import CommentsPresenter from './components/CommentsPresenter.svelte'
import MessagePresenter from './components/MessagePresenter.svelte'
import ThreadParentPresenter from './components/ThreadParentPresenter.svelte'
import ThreadViewPanel from './components/ThreadViewPanel.svelte'
import ConvertDmToPrivateChannelModal from './components/ConvertDmToPrivateChannel.svelte'
import CreateChannel from './components/CreateChannel.svelte'
import CreateDirectMessage from './components/CreateDirectMessage.svelte'
import DmHeader from './components/DmHeader.svelte'
import DmPresenter from './components/DmPresenter.svelte'
import EditChannel from './components/EditChannel.svelte'
import MessagePresenter from './components/MessagePresenter.svelte'
import DirectMessagePresenter from './components/DirectMessagePresenter.svelte'
import SavedMessages from './components/SavedMessages.svelte'
import Threads from './components/Threads.svelte'
import ThreadView from './components/ThreadView.svelte'
@ -260,11 +264,15 @@ export default async (): Promise<Resources> => ({
CommentInput,
CreateChannel,
CreateDirectMessage,
ThreadParentPresenter,
ThreadViewPanel,
ChannelHeader,
ChannelView,
ChannelViewPanel,
CommentPresenter,
CommentsPresenter,
ChannelPresenter,
DirectMessagePresenter,
MessagePresenter,
ChunterBrowser,
DmHeader,

View File

@ -27,6 +27,9 @@ export default mergeIds(chunterId, chunter, {
ChannelHeader: '' as AnyComponent,
DmHeader: '' as AnyComponent,
ChannelView: '' as AnyComponent,
ChannelViewPanel: '' as AnyComponent,
ThreadViewPanel: '' as AnyComponent,
ThreadParentPresenter: '' as AnyComponent,
EditChannel: '' as AnyComponent
},
function: {

View File

@ -42,7 +42,6 @@
"@hcengineering/core": "^0.6.21",
"@hcengineering/view": "^0.6.2",
"@hcengineering/attachment-resources": "^0.6.0",
"@hcengineering/notification-resources": "^0.6.0",
"@hcengineering/panel": "^0.6.2",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/attachment": "^0.6.1",

View File

@ -17,9 +17,8 @@
import type { Channel, ChannelProvider } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
import type { AttachedData, Doc, Ref } from '@hcengineering/core'
import { LastView } from '@hcengineering/notification'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import type { Asset, IntlString } from '@hcengineering/platform'
import notification, { LastView } from '@hcengineering/notification'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import {
Action,
@ -36,6 +35,7 @@
import { ViewAction } from '@hcengineering/view'
import { invokeAction } from '@hcengineering/view-resources'
import { createEventDispatcher, tick } from 'svelte'
import { writable, Writable } from 'svelte/store'
import { getChannelProviders } from '../utils'
import ChannelEditor from './ChannelEditor.svelte'
@ -50,8 +50,8 @@
export let focusIndex = -1
export let restricted: Ref<ChannelProvider>[] = []
const notificationClient = NotificationClientImpl.getClient()
const lastViews = notificationClient.getLastViews()
let lastViews: Writable<LastView> = writable()
getResource(notification.function.GetNotificationClient).then((res) => (lastViews = res().getLastViews()))
const dispatch = createEventDispatcher()
interface Item {

View File

@ -16,12 +16,12 @@
<script lang="ts">
import type { Channel, ChannelProvider } from '@hcengineering/contact'
import type { AttachedData, Doc, Ref } from '@hcengineering/core'
import { LastView } from '@hcengineering/notification'
import type { Asset, IntlString } from '@hcengineering/platform'
import notification, { LastView } from '@hcengineering/notification'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { Button } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { Writable, writable } from 'svelte/store'
import { getChannelProviders } from '../utils'
import ChannelsPopup from './ChannelsPopup.svelte'
@ -30,8 +30,9 @@
export let length: 'short' | 'full' = 'full'
export let reverse: boolean = false
export let integrations: Set<Ref<Doc>> = new Set<Ref<Doc>>()
const notificationClient = NotificationClientImpl.getClient()
const lastViews = notificationClient.getLastViews()
let lastViews: Writable<LastView> = writable()
getResource(notification.function.GetNotificationClient).then((res) => (lastViews = res().getLastViews()))
interface Item {
label: IntlString

View File

@ -61,7 +61,6 @@
placeholder={contact.string.PersonFirstNamePlaceholder}
bind:value={object.name}
on:change={nameChange}
focus
focusIndex={1}
/>
</div>

View File

@ -185,11 +185,21 @@ async function generateLocation (loc: Location, id: Ref<Contact>): Promise<Resol
export const employeeByIdStore = writable<IdMap<Employee>>(new Map())
export const employeesStore = writable<Employee[]>([])
const query = createQuery(true)
query.query(contact.class.Employee, {}, (res) => {
employeesStore.set(res)
employeeByIdStore.set(toIdMap(res))
})
function fillStore (): void {
const client = getClient()
if (client !== undefined) {
const query = createQuery(true)
query.query(contact.class.Employee, {}, (res) => {
employeesStore.set(res)
employeeByIdStore.set(toIdMap(res))
})
} else {
setTimeout(() => fillStore(), 500)
}
}
fillStore()
export function getAvatarTypeDropdownItems (hasGravatar: boolean): DropdownIntlItem[] {
return [

View File

@ -41,7 +41,6 @@
"@hcengineering/core": "^0.6.21",
"@hcengineering/view": "^0.6.2",
"@hcengineering/attachment-resources": "^0.6.0",
"@hcengineering/notification-resources": "^0.6.0",
"@hcengineering/panel": "^0.6.2",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/attachment": "^0.6.1",

View File

@ -57,6 +57,7 @@
export let _id: Ref<Document>
export let _class: Ref<Class<Document>>
export let embedded = false
let lastId: Ref<Doc> = _id
let lastClass: Ref<Class<Doc>> = _class
@ -378,6 +379,7 @@
isHeader
isAside={true}
isSub={false}
{embedded}
bind:innerWidth
floatAside={false}
useMaxWidth={true}

View File

@ -6,7 +6,7 @@
"NoNotifications": "No notifications",
"MentionNotification": "Mentioned",
"DM": "Direct message",
"DMNotification": "Sent you as message",
"DMNotification": "Sent you a message",
"EmailNotification": "by email",
"PlatformNotification": "in platform",
"Track": "Track",
@ -16,6 +16,7 @@
"RemoveAll": "Delete all notifications",
"MarkAllAsRead": "Mark all notifications as read",
"MarkAsRead": "Mark as read",
"Inbox": "Inbox",
"Collaborators": "Collaborators"
}
}

View File

@ -16,6 +16,7 @@
"RemoveAll": "Удалить все нотификации",
"MarkAllAsRead": "Отметить все нотификации как прочитанные",
"MarkAsRead": "Отметить нотификация прочитанной",
"Inbox": "Inbox",
"Collaborators": "Участники"
}
}

View File

@ -40,6 +40,7 @@
"@hcengineering/activity-resources": "^0.6.1",
"@hcengineering/activity": "^0.6.0",
"@hcengineering/contact": "^0.6.11",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/core": "^0.6.21",
"@hcengineering/view": "^0.6.2",
"@hcengineering/view-resources": "^0.6.0"

View File

@ -0,0 +1,97 @@
<!--
// 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 activity, { TxViewlet } from '@hcengineering/activity'
import { activityKey, ActivityKey } from '@hcengineering/activity-resources'
import { Class, Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { AnyComponent, Component, Label, Loading, Scroller } from '@hcengineering/ui'
import view from '@hcengineering/view'
import NotificationView from './NotificationView.svelte'
const client = getClient()
const hierarchy = client.getHierarchy()
const query = createQuery()
let docs: DocUpdates[] = []
let loading = true
$: query.query(
notification.class.DocUpdates,
{
user: getCurrentAccount()._id
},
(res) => {
docs = res
if (loading && docs.length > 0) {
select(docs[0].attachedTo, docs[0].attachedToClass)
}
loading = false
},
{
sort: {
lastTxTime: -1
}
}
)
function select (objectId: Ref<Doc>, objectClass: Ref<Class<Doc>>) {
const targetClass = hierarchy.getClass(objectClass)
const panelComponent = hierarchy.as(targetClass, view.mixin.ObjectPanel)
component = panelComponent.component ?? view.component.EditDoc
_id = objectId
_class = objectClass
}
function selectHandler (e: CustomEvent) {
select(e.detail._id, e.detail._class)
}
let component: AnyComponent | undefined
let _id: Ref<Doc> | undefined
let _class: Ref<Class<Doc>> | undefined
let viewlets: Map<ActivityKey, TxViewlet>
const descriptors = createQuery()
descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
</script>
<div class="flex h-full">
<div class="antiPanel-component border-right filled indent aside">
<div class="antiNav-header bottom-divider">
<span class="fs-title overflow-label">
<Label label={notification.string.Inbox} />
</span>
</div>
<Scroller>
{#if loading}
<Loading />
{:else}
{#each docs as doc}
<NotificationView value={doc} selected={doc.attachedTo === _id} {viewlets} on:click={selectHandler} />
{/each}
{/if}
</Scroller>
</div>
{#if loading}
<Loading />
{:else if component && _id && _class}
<Component is={component} props={{ embedded: true, _id, _class }} />
{/if}
</div>

View File

@ -1,6 +1,5 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
// 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
@ -15,199 +14,121 @@
-->
<script lang="ts">
import { TxViewlet } from '@hcengineering/activity'
import { ActivityKey, DisplayTx, newDisplayTx, TxView } from '@hcengineering/activity-resources'
import core, { Doc, TxCUD, TxProcessor, WithLookup, Ref, Class } from '@hcengineering/core'
import { Notification, NotificationStatus } from '@hcengineering/notification'
import { getClient } from '@hcengineering/presentation'
import { Button, Component, getPlatformColor, IconBack, IconCheck, IconDelete } from '@hcengineering/ui'
import type { AnyComponent } from '@hcengineering/ui'
import { ActivityKey } from '@hcengineering/activity-resources'
import contact, { EmployeeAccount, getName } from '@hcengineering/contact'
import { Avatar, employeeByIdStore } from '@hcengineering/contact-resources'
import core, { Doc, Ref, TxCUD, TxProcessor } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { AnySvelteComponent, Label, TimeSince } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getObjectPreview } from '@hcengineering/view-resources'
import plugin from '../plugin'
import { createEventDispatcher } from 'svelte'
import TxView from './TxView.svelte'
export let notification: WithLookup<Notification>
export let value: DocUpdates
export let viewlets: Map<ActivityKey, TxViewlet>
export let selected: boolean
let doc: Doc | undefined = undefined
let tx: TxCUD<Doc> | undefined = undefined
let account: EmployeeAccount | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
function getDisplayTx (notification: WithLookup<Notification>): DisplayTx | undefined {
let tx = notification.$lookup?.tx
if (tx) {
if (hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) {
tx = TxProcessor.extractTx(tx) as TxCUD<Doc>
$: value.lastTx &&
client.findOne(core.class.TxCUD, { _id: value.lastTx }).then((res) => {
if (res !== undefined) {
tx = TxProcessor.extractTx(res) as TxCUD<Doc>
} else {
tx = res
}
return newDisplayTx(tx, hierarchy)
}
}
async function changeState (notification: Notification, status: NotificationStatus): Promise<void> {
if (notification.status === status) return
await client.updateDoc(notification._class, notification.space, notification._id, {
status
})
let presenter: AnySvelteComponent | undefined = undefined
$: presenterRes =
hierarchy.classHierarchyMixin(value.attachedToClass, notification.mixin.NotificationObjectPresenter)?.presenter ??
hierarchy.classHierarchyMixin(value.attachedToClass, view.mixin.ObjectPresenter)?.presenter
$: if (presenterRes) {
getResource(presenterRes).then((res) => (presenter = res))
}
$: displayTx = getDisplayTx(notification)
const query = createQuery()
$: tx &&
query.query(contact.class.EmployeeAccount, { _id: tx.modifiedBy as Ref<EmployeeAccount> }, (r) => ([account] = r))
$: employee = account && $employeeByIdStore.get(account.employee)
let presenter: AnyComponent | undefined
let doc: Doc | undefined
let visible: boolean = false
async function updatePreviewPresenter (ref?: Ref<Class<Doc>>): Promise<void> {
presenter = ref !== undefined ? await getObjectPreview(client, ref) : undefined
}
$: if (displayTx) updatePreviewPresenter(displayTx.tx.objectClass)
$: if (presenter !== undefined && displayTx) {
client.findOne(displayTx.tx.objectClass, { _id: displayTx.tx.objectId }).then((res) => (doc = res))
}
const dispatch = createEventDispatcher()
const docQuery = createQuery()
$: docQuery.query(value.attachedToClass, { _id: value.attachedTo }, (res) => ([doc] = res))
$: newTxes = value.txes.length
</script>
{#if displayTx}
{@const isNew = notification.status !== NotificationStatus.Read}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if doc}
<div
class="content {isNew ? 'new' : 'readed'} with-document"
class:visible
style:--color={isNew ? getPlatformColor(11) : '#555555'}
on:click|preventDefault|stopPropagation={() => {
changeState(notification, NotificationStatus.Read)
visible = !visible
}}
class="container cursor-pointer bottom-divider"
class:selected
on:click={() => dispatch('click', { _id: value.attachedTo, _class: value.attachedToClass })}
>
<div class="subheader">
<div class="flex-grow">
<Component
is={view.component.ObjectPresenter}
props={{
objectId: displayTx.tx.objectId,
_class: displayTx.tx.objectClass,
value: displayTx.doc,
inline: true
}}
/>
</div>
<div class="buttons-group xsmall-gap">
<Button
icon={isNew ? IconCheck : IconBack}
iconProps={!isNew ? { kind: 'curve' } : {}}
kind={'transparent'}
showTooltip={{ label: plugin.string.MarkAsRead }}
size={'medium'}
on:click={() => {
if (!isNew && visible) visible = false
changeState(notification, isNew ? NotificationStatus.Read : NotificationStatus.Notified)
}}
/>
<Button
icon={IconDelete}
kind={'transparent'}
showTooltip={{ label: plugin.string.Remove }}
size={'medium'}
on:click={() => {
client.remove(notification)
}}
/>
<div class="content">
<div class="header flex">
<Avatar avatar={employee?.avatar} size="medium" />
<div class="ml-2 w-full">
<div class="flex-between mb-1">
<div class="caption-color flex">
{#if employee}
{getName(employee)}
{:else}
<Label label={core.string.System} />
{/if}
{#if newTxes > 0}
<div class="counter ml-2">
{newTxes}
</div>
{/if}
</div>
<div class="flex-center">
<div class="time ml-2"><TimeSince value={tx?.modifiedOn} /></div>
</div>
</div>
{#if presenter}
<svelte:component this={presenter} value={doc} />
{/if}
</div>
</div>
{#if tx}
<TxView {tx} {viewlets} />
{/if}
</div>
<TxView tx={displayTx} {viewlets} showIcon={false} contentHidden={!visible} />
{#if presenter && doc}
<div class="document-preview">
<Component is={presenter} props={{ object: doc }} />
</div>
{/if}
</div>
{/if}
<style lang="scss">
.content {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 0.75rem;
padding: 0.5rem 0.75rem 0.75rem;
min-height: 0;
border: 1px solid var(--button-border-color);
border-radius: 0.75rem;
transition-property: border-color, background-color, height;
transition-duration: 0.3s, 0.15s, 0.15s;
transition-timing-function: ease-in-out;
padding: 0.5rem;
}
&.new {
background-color: var(--popup-bg-hover);
}
&.readed {
background-color: var(--body-accent);
}
.container {
&:hover {
border-color: var(--button-border-hover);
background-color: var(--board-card-bg-hover);
}
&.with-document {
cursor: pointer;
&::before {
content: '';
position: absolute;
bottom: -0.25rem;
left: 1rem;
right: 1rem;
width: calc(100% - 2rem);
height: 0.75rem;
background-color: var(--body-accent);
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
z-index: -1;
transition: bottom 0.15s ease-in-out;
box-shadow: var(--primary-shadow);
}
&:hover::before {
bottom: -0.4rem;
}
&.visible::before {
bottom: 0.25rem;
}
}
.subheader {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 0.5rem 1.75rem;
margin-bottom: 0.5rem;
min-height: 0;
border-bottom: 1px solid var(--divider-color);
&::before {
content: '';
position: absolute;
top: 50%;
left: 0.5rem;
width: 0.5rem;
height: 0.5rem;
background-color: var(--color);
transform: translateY(calc(-50% - 0.25rem));
border-radius: 50%;
}
}
.document-preview {
overflow: hidden;
visibility: hidden;
margin-top: -0.5rem;
padding: 0;
max-height: 0;
background-color: var(--body-color);
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
opacity: 0;
transition-property: margin-top, max-height, opacity;
transition-timing-function: ease-in-out;
transition-duration: 0.15s;
}
&.visible .document-preview {
visibility: visible;
margin-top: 0.5rem;
padding: 0.75rem 1rem;
max-height: max-content;
opacity: 1;
&.selected {
background-color: var(--board-card-bg-hover);
}
}
.counter {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid;
border-color: var(--divider-color);
border-radius: 50%;
height: 1.25rem;
width: 1.25rem;
}
</style>

View File

@ -1,114 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 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, { TxViewlet } from '@hcengineering/activity'
import { activityKey, ActivityKey } from '@hcengineering/activity-resources'
import { EmployeeAccount } from '@hcengineering/contact'
import core, { getCurrentAccount, WithLookup } from '@hcengineering/core'
import { Notification, NotificationStatus } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, IconCheckAll, IconDelete, Scroller, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { Label } from '@hcengineering/ui'
import notification from '../plugin'
import NotificationView from './NotificationView.svelte'
const query = createQuery()
let notifications: WithLookup<Notification>[] = []
const client = getClient()
$: query.query(
notification.class.Notification,
{
attachedTo: (getCurrentAccount() as EmployeeAccount).employee
},
(res) => {
notifications = res
},
{
sort: {
'$lookup.tx.modifiedOn': -1
},
limit: 30,
lookup: {
tx: core.class.TxCUD
}
}
)
let viewlets: Map<ActivityKey, TxViewlet>
const descriptors = createQuery()
$: descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
const deleteNotifications = async () => {
const allNotifications = await client.findAll(notification.class.Notification, {
attachedTo: (getCurrentAccount() as EmployeeAccount).employee
})
for (const n of allNotifications) {
await client.remove(n)
}
}
const markAsReadNotifications = async () => {
const allNotifications = await client.findAll(notification.class.Notification, {
attachedTo: (getCurrentAccount() as EmployeeAccount).employee
})
for (const n of allNotifications) {
if (n.status !== NotificationStatus.Read) {
await client.updateDoc(n._class, n.space, n._id, {
status: NotificationStatus.Read
})
}
}
}
$: isMobile = $deviceInfo.isMobile
</script>
<div class="notifyPopup" class:justify-center={notifications.length === 0} class:min-w-168={!isMobile}>
<div class="header flex-between">
<span class="fs-title overflow-label"><Label label={notification.string.Notifications} /></span>
{#if notifications.length > 0}
<div class="buttons-group xxsmall-gap">
<Button
icon={IconCheckAll}
kind={'list'}
showTooltip={{ label: notification.string.MarkAllAsRead }}
size={'medium'}
on:click={markAsReadNotifications}
/>
<Button
icon={IconDelete}
kind={'list'}
showTooltip={{ label: notification.string.RemoveAll }}
size={'medium'}
on:click={deleteNotifications}
/>
</div>
{/if}
</div>
{#if notifications.length > 0}
<Scroller padding={'0 .5rem'}>
{#each notifications as n}
<NotificationView notification={n} {viewlets} />
{/each}
</Scroller>
{:else}
<div class="flex-grow flex-center">
<Label label={notification.string.NoNotifications} />
</div>
{/if}
</div>

View File

@ -0,0 +1,302 @@
<!--
// 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 { TxViewlet } from '@hcengineering/activity'
import {
ActivityKey,
DisplayTx,
getValue,
newDisplayTx,
TxDisplayViewlet,
updateViewlet
} from '@hcengineering/activity-resources'
import activity from '@hcengineering/activity-resources/src/plugin'
import contact, { EmployeeAccount } from '@hcengineering/contact'
import core, { AnyAttribute, Doc, Ref, TxCUD } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, Label, ShowMore } from '@hcengineering/ui'
import type { AttributeModel } from '@hcengineering/view'
import { ObjectPresenter } from '@hcengineering/view-resources'
export let tx: TxCUD<Doc>
export let viewlets: Map<ActivityKey, TxViewlet>
export let contentHidden: boolean = false
const client = getClient()
let ptx: DisplayTx | undefined
let viewlet: TxDisplayViewlet | undefined
let props: any
let employee: EmployeeAccount | undefined
let model: AttributeModel[] = []
$: if (tx._id !== ptx?.tx._id) {
ptx = newDisplayTx(tx, client.getHierarchy())
if (tx.modifiedBy !== employee?._id) {
employee = undefined
}
props = undefined
viewlet = undefined
model = []
}
const query = createQuery()
function getProps (props: any): any {
return { ...props, attr: ptx?.collectionAttribute }
}
$: ptx &&
updateViewlet(client, viewlets, ptx).then((result) => {
if (result.id === tx._id) {
viewlet = result.viewlet
props = getProps(result.props)
model = result.model
}
})
$: query.query(
contact.class.EmployeeAccount,
{ _id: tx.modifiedBy as Ref<EmployeeAccount> },
(account) => {
;[employee] = account
},
{ limit: 1 }
)
function isMessageType (attr?: AnyAttribute): boolean {
return attr?.type._class === core.class.TypeMarkup
}
async function updateMessageType (model: AttributeModel[], tx: DisplayTx): Promise<boolean> {
for (const m of model) {
if (isMessageType(m.attribute)) {
return true
}
const val = await getValue(client, m, tx)
if (val.added.length > 1 || val.removed.length > 1) {
return true
}
}
return false
}
let hasMessageType = false
$: ptx &&
updateMessageType(model, ptx).then((res) => {
hasMessageType = res
})
$: isComment = viewlet && viewlet?.editable
$: isMention = viewlet?.display === 'emphasized' || isMessageType(model[0]?.attribute)
$: isColumn = isComment || isMention || hasMessageType
</script>
{#if (viewlet !== undefined && !((viewlet?.hideOnRemove ?? false) && ptx?.removed)) || model.length > 0}
<div class="msgactivity-container">
<div class="msgactivity-content" class:content={isColumn} class:comment={isComment}>
<div class="msgactivity-content__header">
<div class="msgactivity-content__title labels-row">
{#if viewlet && viewlet?.editable}
{#if viewlet.label}
<span class="lower"><Label label={viewlet.label} params={viewlet.labelParams ?? {}} /></span>
{/if}
{#if ptx?.updated}
<span class="lower"><Label label={activity.string.Edited} /></span>
{/if}
{:else if viewlet && viewlet.label}
<span class="lower">
<Label label={viewlet.label} params={viewlet.labelParams ?? {}} />
</span>
{#if viewlet.labelComponent}
<Component is={viewlet.labelComponent} {props} />
{/if}
{/if}
{#if viewlet === undefined && model.length > 0 && ptx?.updateTx}
{#each model as m}
{#await getValue(client, m, ptx) then value}
{#if value.added.length}
<span class="lower"><Label label={activity.string.Added} /></span>
<span class="lower"><Label label={activity.string.To} /></span>
<span class="lower"><Label label={m.label} /></span>
{#each value.added as cvalue}
{#if value.isObjectAdded}
<ObjectPresenter value={cvalue} inline />
{:else}
<svelte:component this={m.presenter} value={cvalue} inline />
{/if}
{/each}
{:else if value.removed.length}
<span class="lower"><Label label={activity.string.Removed} /></span>
<span class="lower"><Label label={activity.string.From} /></span>
<span class="lower"><Label label={m.label} /></span>
{#each value.removed as cvalue}
{#if value.isObjectRemoved}
<ObjectPresenter value={cvalue} inline />
{:else}
<svelte:component this={m.presenter} value={cvalue} inline />
{/if}
{/each}
{:else if value.set === null || value.set === undefined || value.set === ''}
<span class="lower"><Label label={activity.string.Unset} /></span>
<span class="lower"><Label label={m.label} /></span>
{:else}
<span class="lower"><Label label={activity.string.Changed} /></span>
<span class="lower"><Label label={m.label} /></span>
<span class="lower"><Label label={activity.string.To} /></span>
{#if !hasMessageType}
<span class="strong">
{#if value.isObjectSet}
<ObjectPresenter value={value.set} inline />
{:else}
<svelte:component this={m.presenter} value={value.set} inline />
{/if}
</span>
{/if}
{/if}
{/await}
{/each}
{:else if viewlet === undefined && model.length > 0 && ptx?.mixinTx}
{#each model as m}
{#await getValue(client, m, ptx) then value}
{#if value.set === null || value.set === ''}
<span class="lower"><Label label={activity.string.Unset} /></span>
<span class="lower"><Label label={m.label} /></span>
{:else}
<span class="lower"><Label label={activity.string.Changed} /></span>
<span class="lower"><Label label={m.label} /></span>
<span class="lower"><Label label={activity.string.To} /></span>
{#if !hasMessageType}
<div class="strong">
{#if value.isObjectSet}
<ObjectPresenter value={value.set} inline />
{:else}
<svelte:component this={m.presenter} value={value.set} inline />
{/if}
</div>
{/if}
{/if}
{/await}
{/each}
{:else if viewlet && viewlet.display === 'inline' && viewlet.component}
{#if typeof viewlet.component === 'string'}
<Component is={viewlet.component} {props} inline />
{:else}
<svelte:component this={viewlet.component} {...props} inline />
{/if}
{/if}
</div>
</div>
{#if viewlet && viewlet.display !== 'inline'}
<div class="activity-content content" class:contentHidden>
<ShowMore>
{#if typeof viewlet.component === 'string'}
<Component is={viewlet.component} {props} />
{:else}
<svelte:component this={viewlet.component} {...props} />
{/if}
</ShowMore>
</div>
{:else if hasMessageType && model.length > 0 && (ptx?.updateTx || ptx?.mixinTx)}
{#await getValue(client, model[0], ptx) then value}
<div class="activity-content content" class:contentHidden>
<ShowMore>
{#if value.isObjectSet}
<ObjectPresenter value={value.set} inline />
{:else}
<svelte:component this={model[0].presenter} value={value.set} inline />
{/if}
</ShowMore>
</div>
{/await}
{/if}
</div>
</div>
{/if}
<style lang="scss">
.msgactivity-container {
position: relative;
display: flex;
justify-content: space-between;
.msgactivity-content {
display: flex;
flex-grow: 1;
margin-right: 1rem;
color: var(--content-color);
.msgactivity-content__header {
display: flex;
justify-content: space-between;
align-items: center;
flex-grow: 1;
}
.msgactivity-content__title {
display: inline-flex;
align-items: baseline;
flex-grow: 1;
}
&.content {
flex-direction: column;
padding-bottom: 0.25rem;
}
&.comment {
.activity-content {
margin-top: 0.25rem;
}
}
&:not(.comment) {
.msgactivity-content__header {
min-height: 1.75rem;
}
}
&:not(.content) {
align-items: center;
.msgactivity-content__header {
justify-content: space-between;
}
}
}
}
:global(.msgactivity-container + .msgactivity-container::before) {
content: '';
}
.activity-content {
overflow: hidden;
visibility: visible;
margin-top: 0.125rem;
max-height: max-content;
opacity: 1;
transition-property: max-height, opacity;
transition-timing-function: ease-in-out;
transition-duration: 0.15s;
&.contentHidden {
visibility: hidden;
padding: 0;
margin-top: -0.5rem;
max-height: 0;
opacity: 0;
}
}
</style>

View File

@ -15,7 +15,7 @@
//
import { Resources } from '@hcengineering/platform'
import NotificationsPopup from './components/NotificationsPopup.svelte'
import Inbox from './components/Inbox.svelte'
import NotificationSettings from './components/NotificationSettings.svelte'
import NotificationPresenter from './components/NotificationPresenter.svelte'
import { NotificationClientImpl } from './utils'
@ -26,7 +26,7 @@ export { default as BrowserNotificatator } from './components/BrowserNotificatat
export default async (): Promise<Resources> => ({
component: {
NotificationsPopup,
Inbox,
NotificationPresenter,
NotificationSettings
},

View File

@ -115,6 +115,13 @@ export interface ClassCollaborators extends Class<Doc> {
*/
export interface TrackedDoc extends Class<Doc> {}
/**
* @public
*/
export interface NotificationObjectPresenter extends Class<Doc> {
presenter: AnyComponent
}
/**
* @public
*/
@ -122,6 +129,18 @@ export interface Collaborators extends Doc {
collaborators: Ref<Account>[]
}
/**
* @public
*/
export interface DocUpdates extends Doc {
user: Ref<Account>
attachedTo: Ref<Doc>
attachedToClass: Ref<Class<Doc>>
lastTx?: Ref<TxCUD<Doc>>
lastTxTime?: Timestamp
txes: [Ref<TxCUD<Doc>>, Timestamp][]
}
/**
* @public
*/
@ -150,7 +169,8 @@ const notification = plugin(notificationId, {
AnotherUserNotifications: '' as Ref<Mixin<AnotherUserNotifications>>,
ClassCollaborators: '' as Ref<Mixin<ClassCollaborators>>,
Collaborators: '' as Ref<Mixin<Collaborators>>,
TrackedDoc: '' as Ref<Mixin<TrackedDoc>>
TrackedDoc: '' as Ref<Mixin<TrackedDoc>>,
NotificationObjectPresenter: '' as Ref<Mixin<NotificationObjectPresenter>>
},
class: {
LastView: '' as Ref<Class<LastView>>,
@ -158,12 +178,12 @@ const notification = plugin(notificationId, {
EmailNotification: '' as Ref<Class<EmailNotification>>,
NotificationType: '' as Ref<Class<NotificationType>>,
NotificationProvider: '' as Ref<Class<NotificationProvider>>,
NotificationSetting: '' as Ref<Class<NotificationSetting>>
NotificationSetting: '' as Ref<Class<NotificationSetting>>,
DocUpdates: '' as Ref<Class<DocUpdates>>
},
ids: {
MentionNotification: '' as Ref<NotificationType>,
DMNotification: '' as Ref<NotificationType>,
CollaboratorNotification: '' as Ref<NotificationType>,
PlatformNotification: '' as Ref<NotificationProvider>,
BrowserNotification: '' as Ref<NotificationProvider>,
EmailNotification: '' as Ref<NotificationProvider>,
@ -173,7 +193,7 @@ const notification = plugin(notificationId, {
MobileApp: '' as Ref<IntegrationType>
},
component: {
NotificationsPopup: '' as AnyComponent,
Inbox: '' as AnyComponent,
NotificationPresenter: '' as AnyComponent
},
icon: {
@ -186,7 +206,8 @@ const notification = plugin(notificationId, {
},
string: {
Notification: '' as IntlString,
Notifications: '' as IntlString
Notifications: '' as IntlString,
Inbox: '' as IntlString
},
function: {
GetNotificationClient: '' as Resource<NotificationClientFactoy>

View File

@ -28,6 +28,7 @@
import VacancyApplications from './VacancyApplications.svelte'
export let _id: Ref<Vacancy>
export let embedded = false
let object: Required<Vacancy>
let rawName: string = ''
@ -82,6 +83,7 @@
title={object.name}
isHeader={true}
isAside={true}
{embedded}
{object}
on:close={() => {
dispatch('close')
@ -100,15 +102,11 @@
</svelte:fragment>
<svelte:fragment slot="attributes" let:direction={dir}>
{#if dir === 'column'}
<div class="ac-subtitle">
<div class="ac-subtitle-content">
<DocAttributeBar
{object}
{mixins}
ignoreKeys={['name', 'description', 'fullDescription', 'private', 'archived']}
/>
</div>
</div>
<DocAttributeBar
{object}
{mixins}
ignoreKeys={['name', 'description', 'fullDescription', 'private', 'archived']}
/>
{/if}
</svelte:fragment>

View File

@ -48,7 +48,6 @@
"@hcengineering/task": "^0.6.3",
"@hcengineering/chunter": "^0.6.2",
"@hcengineering/notification": "^0.6.8",
"@hcengineering/notification-resources": "^0.6.0",
"@hcengineering/contact": "^0.6.11",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/view-resources": "^0.6.0",

View File

@ -0,0 +1,53 @@
<!--
// 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 { createQuery, statusStore } from '@hcengineering/presentation'
import type { Issue, Project } from '@hcengineering/tracker'
import { Icon } from '@hcengineering/ui'
import tracker from '../../plugin'
import IssueStatusIcon from './IssueStatusIcon.svelte'
export let value: Issue
const spaceQuery = createQuery()
let currentProject: Project | undefined = undefined
spaceQuery.query(tracker.class.Project, { _id: value.space }, (res) => ([currentProject] = res))
$: title = currentProject ? `${currentProject.identifier}-${value?.number}` : `${value?.number}`
$: status = $statusStore.byId.get(value.status)
</script>
{#if value}
<div class="flex-between">
<div class="flex-center">
{#if currentProject}
{#if currentProject.icon}
<Icon icon={currentProject.icon ?? tracker.icon.Home} size="inline" />
{/if}
<div class="ml-1 mr-1">
{currentProject.name}
</div>
{/if}
{title}
{value.title}
</div>
<div>
{#if status}
<IssueStatusIcon value={status} size="small" />
{/if}
</div>
</div>
{/if}

View File

@ -44,6 +44,7 @@
export let _id: Ref<Issue>
export let _class: Ref<Class<Issue>>
export let embedded = false
let lastId: Ref<Doc> = _id
let lastClass: Ref<Class<Doc>> = _class
@ -145,6 +146,7 @@
isAside={true}
isSub={false}
withoutActivity={false}
{embedded}
withoutTitle
bind:innerWidth
on:close={() => dispatch('close')}

View File

@ -72,6 +72,7 @@ import SprintLeadPresenter from './components/sprints/SprintLeadPresenter.svelte
import CreateIssueTemplate from './components/templates/CreateIssueTemplate.svelte'
import Views from './components/views/Views.svelte'
import Statuses from './components/workflow/Statuses.svelte'
import NotificationIssuePresenter from './components/issues/NotificationIssuePresenter.svelte'
import {
getIssueId,
@ -418,7 +419,8 @@ export default async (): Promise<Resources> => ({
TimeSpendReportPopup,
SprintComponentEditor,
SprintDatePresenter,
SprintLeadPresenter
SprintLeadPresenter,
NotificationIssuePresenter
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -39,6 +39,7 @@
export let _id: Ref<Doc>
export let _class: Ref<Class<Doc>>
export let embedded = false
let realObjectClass: Ref<Class<Doc>> = _class
let lastId: Ref<Doc> = _id
@ -277,6 +278,7 @@
{icon}
{title}
{object}
{embedded}
isHeader={mainEditor?.pinned ?? false}
isAside={true}
bind:panelWidth

View File

@ -120,13 +120,8 @@ export async function getListItemPresenter (client: Client, _class: Ref<Class<Ob
* @public
*/
export async function getObjectPreview (client: Client, _class: Ref<Class<Obj>>): Promise<AnyComponent | undefined> {
const clazz = client.getHierarchy().getClass(_class)
const presenterMixin = client.getHierarchy().as(clazz, view.mixin.PreviewPresenter)
if (presenterMixin.presenter === undefined) {
if (clazz.extends !== undefined) {
return await getObjectPreview(client, clazz.extends)
}
}
const hierarchy = client.getHierarchy()
const presenterMixin = hierarchy.classHierarchyMixin(_class, view.mixin.PreviewPresenter)
return presenterMixin?.presenter
}

View File

@ -34,6 +34,7 @@
location,
Location,
navigate,
NavLink,
openPanel,
PanelInstance,
Popup,
@ -61,12 +62,12 @@
import SpaceView from './SpaceView.svelte'
import login from '@hcengineering/login'
import { workspacesStore } from '../utils'
import App from './App.svelte'
let contentPanel: HTMLElement
let shownMenu: boolean = false
const { setTheme } = getContext('theme') as any
NotificationClientImpl.createClient()
let currentAppAlias: string | undefined
let currentSpace: Ref<Space> | undefined
@ -86,6 +87,7 @@
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const client = getClient()
NotificationClientImpl.createClient()
let apps: Application[] | Promise<Application[]> = client
.findAll(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
@ -546,7 +548,7 @@
</div>
</div>
<Applications
apps={getApps(apps)}
apps={getApps(apps).filter((p) => p.position !== 'bottom')}
active={currentApplication?._id}
direction={appsDirection}
bind:shown={shownMenu}
@ -570,15 +572,11 @@
}}
notify={false}
/>
<AppItem
icon={notification.icon.Notifications}
label={notification.string.Notifications}
selected={false}
action={async () => {
showPopup(notification.component.NotificationsPopup, {}, popupPosition)
}}
notify={hasNotification}
/>
{#each getApps(apps).filter((p) => p.position === 'bottom') as app}
<NavLink app={app.alias}>
<App selected={app._id === currentApplication?._id} icon={app.icon} label={app.label} />
</NavLink>
{/each}
<div class="flex-center" class:mt-2={appsDirection === 'vertical'} class:ml-2={appsDirection === 'horizontal'}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div

View File

@ -28,6 +28,7 @@ export interface Application extends Doc {
alias: string
icon: Asset
hidden: boolean
position?: 'top' | 'bottom'
navigatorModel?: NavigatorModel
locationResolver?: Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>

View File

@ -23,38 +23,41 @@ import core, {
AttachedDoc,
Class,
Doc,
generateId,
DocumentUpdate,
Hierarchy,
IdMap,
MixinUpdate,
Ref,
RefTo,
Space,
Timestamp,
toIdMap,
Tx,
TxCUD,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxMixin,
TxProcessor,
TxUpdateDoc
TxRemoveDoc,
TxUpdateDoc,
generateId
} from '@hcengineering/core'
import notification, {
ClassCollaborators,
Collaborators,
DocUpdates,
EmailNotification,
LastView,
NotificationProvider,
NotificationType
} from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import type { TriggerControl } from '@hcengineering/server-core'
import serverNotification, {
HTMLPresenter,
TextPresenter,
createLastViewTx,
getEmployeeAccount,
getEmployeeAccountById,
getUpdateLastViewTx,
HTMLPresenter,
TextPresenter
getUpdateLastViewTx
} from '@hcengineering/server-notification'
import { Content } from './types'
import { replaceAll } from './utils'
@ -378,6 +381,29 @@ export async function UpdateLastView (tx: Tx, control: TriggerControl): Promise<
return result
}
/**
* @public
*/
export async function OnUpdateLastView (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = TxProcessor.extractTx(tx) as TxUpdateDoc<LastView>
if (actualTx._class !== core.class.TxUpdateDoc) return []
if (actualTx.objectClass !== notification.class.LastView) return []
const result: Tx[] = []
for (const key in actualTx.operations) {
const docs = await control.findAll(notification.class.DocUpdates, { attachedTo: key as Ref<Doc> })
for (const doc of docs) {
const txes = doc.txes.filter((p) => p[1] > actualTx.operations[key])
result.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
txes
})
)
}
}
return result
}
function getBacklink (ptx: TxCollectionCUD<Doc, Backlink>): Backlink {
return TxProcessor.createDoc2Doc(ptx.tx as TxCreateDoc<Backlink>)
}
@ -394,20 +420,16 @@ async function getBacklinkDoc (backlink: Backlink, control: TriggerControl): Pro
)[0]
}
async function getValueCollaborators (
value: any,
attr: AnyAttribute,
control: TriggerControl
): Promise<EmployeeAccount[]> {
async function getValueCollaborators (value: any, attr: AnyAttribute, control: TriggerControl): Promise<Ref<Account>[]> {
const hierarchy = control.hierarchy
if (attr.type._class === core.class.RefTo) {
const to = (attr.type as RefTo<Doc>).to
if (hierarchy.isDerived(to, contact.class.Employee)) {
const acc = await getEmployeeAccount(value, control)
return acc !== undefined ? [acc] : []
return acc !== undefined ? [acc._id] : []
} else if (hierarchy.isDerived(to, core.class.Account)) {
const acc = await getEmployeeAccountById(value, control)
return acc !== undefined ? [acc] : []
return acc !== undefined ? [acc._id] : []
}
} else if (attr.type._class === core.class.ArrOf) {
const arrOf = (attr.type as ArrOf<RefTo<Doc>>).of
@ -417,12 +439,12 @@ async function getValueCollaborators (
const employeeAccounts = await control.modelDb.findAll(contact.class.EmployeeAccount, {
employee: { $in: Array.isArray(value) ? value : [value] }
})
return employeeAccounts
return employeeAccounts.map((p) => p._id)
} else if (hierarchy.isDerived(to, core.class.Account)) {
const employeeAccounts = await control.modelDb.findAll(contact.class.EmployeeAccount, {
_id: { $in: Array.isArray(value) ? value : [value] }
})
return employeeAccounts
return employeeAccounts.map((p) => p._id)
}
}
}
@ -434,7 +456,7 @@ async function getKeyCollaborators (
value: any,
field: string,
control: TriggerControl
): Promise<EmployeeAccount[] | undefined> {
): Promise<Ref<Account>[] | undefined> {
if (value !== undefined && value !== null) {
const attr = control.hierarchy.findAttribute(doc._class, field)
if (attr !== undefined) {
@ -447,84 +469,180 @@ async function getDocCollaborators (
doc: Doc,
mixin: ClassCollaborators,
control: TriggerControl
): Promise<EmployeeAccount[]> {
const collaborators: IdMap<EmployeeAccount> = new Map()
): Promise<Ref<Account>[]> {
const collaborators: Set<Ref<Account>> = new Set()
for (const field of mixin.fields) {
const value = (doc as any)[field]
const newCollaborators = await getKeyCollaborators(doc, value, field, control)
if (newCollaborators !== undefined) {
for (const newCollaborator of newCollaborators) {
collaborators.set(newCollaborator._id, newCollaborator)
collaborators.add(newCollaborator)
}
}
}
return Array.from(collaborators.values())
}
async function createCollabDocInfo (
collaborators: Ref<Account>[],
tx: TxCUD<Doc>,
objectId: Ref<Doc>,
objectClass: Ref<Class<Doc>>,
control: TriggerControl,
txId?: Ref<TxCUD<Doc>>
): Promise<TxCUD<DocUpdates>[]> {
const res: TxCUD<DocUpdates>[] = []
const targets = new Set(collaborators)
const docs = await control.findAll(notification.class.DocUpdates, { attachedTo: objectId })
for (const doc of docs) {
if (tx.modifiedBy === doc.user || !targets.delete(doc.user)) continue
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
$push: {
txes: [txId ?? tx._id, tx.modifiedOn]
}
})
)
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
lastTx: txId ?? tx._id,
lastTxTime: tx.modifiedOn
})
)
}
for (const target of targets) {
if (tx.modifiedBy === target) continue
res.push(
control.txFactory.createTxCreateDoc(notification.class.DocUpdates, notification.space.Notifications, {
user: target,
attachedTo: objectId,
attachedToClass: objectClass,
lastTx: txId ?? tx._id,
lastTxTime: tx.modifiedOn,
txes: [[txId ?? tx._id, tx.modifiedOn]]
})
)
}
return res
}
function getMixinTx (
actualTx: TxCUD<Doc>,
control: TriggerControl,
collaborators: EmployeeAccount[]
collaborators: Ref<Account>[]
): TxMixin<Doc, Collaborators> {
return control.txFactory.createTxMixin(
const tx = control.txFactory.createTxMixin(
actualTx.objectId,
actualTx.objectClass,
actualTx.objectSpace,
notification.mixin.Collaborators,
{
collaborators: collaborators.map((p) => p._id)
collaborators
}
)
return tx
}
/**
* @public
*/
export async function CreateCollaboratorDoc (tx: Tx, control: TriggerControl): Promise<Tx[]> {
export async function createCollaboratorDoc (
tx: TxCreateDoc<Doc>,
control: TriggerControl,
txId?: Ref<TxCUD<Doc>>
): Promise<Tx[]> {
const res: Tx[] = []
const hierarchy = control.hierarchy
const actualTx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
if (actualTx._class !== core.class.TxCreateDoc) return []
const mixin = hierarchy.classHierarchyMixin(actualTx.objectClass, notification.mixin.ClassCollaborators)
const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
if (mixin !== undefined) {
const doc = TxProcessor.createDoc2Doc(actualTx)
const doc = TxProcessor.createDoc2Doc(tx)
const collaborators = await getDocCollaborators(doc, mixin, control)
const mixinTx = getMixinTx(actualTx, control, collaborators)
const mixinTx = getMixinTx(tx, control, collaborators)
const notificationTxes = await createCollabDocInfo(collaborators, tx, doc._id, doc._class, control, txId)
res.push(mixinTx)
res.push(...notificationTxes)
}
return res
}
/**
* @public
*/
export async function collaboratorDocHandler (tx: Tx, control: TriggerControl, txId?: Ref<TxCUD<Doc>>): Promise<Tx[]> {
switch (tx._class) {
case core.class.TxCreateDoc:
if (tx.space === core.space.DerivedTx) return []
return await createCollaboratorDoc(tx as TxCreateDoc<Doc>, control, txId)
case core.class.TxUpdateDoc:
case core.class.TxMixin:
if (tx.space === core.space.DerivedTx) return []
return await updateCollaboratorDoc(tx as TxUpdateDoc<Doc>, control, txId)
case core.class.TxRemoveDoc:
return await removeCollaboratorDoc(tx as TxRemoveDoc<Doc>, control)
case core.class.TxCollectionCUD:
return await collectionCollabDoc(tx as TxCollectionCUD<Doc, AttachedDoc>, control)
}
return []
}
async function collectionCollabDoc (tx: TxCollectionCUD<Doc, AttachedDoc>, control: TriggerControl): Promise<Tx[]> {
const actualTx = TxProcessor.extractTx(tx)
let res = await collaboratorDocHandler(actualTx, control, tx._id)
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
if (doc !== undefined) {
if (control.hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
const collabMixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
res = res.concat(
await createCollabDocInfo(collabMixin.collaborators, tx, tx.objectId, tx.objectClass, control, tx._id)
)
}
}
return res
}
async function removeCollaboratorDoc (tx: TxRemoveDoc<Doc>, control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
const hierarchy = control.hierarchy
const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
if (mixin !== undefined) {
const docUpdates = await control.findAll(notification.class.DocUpdates, { attachedTo: tx.objectId })
for (const doc of docUpdates) {
res.push(control.txFactory.createTxRemoveDoc(doc._class, doc.space, doc._id))
}
}
return res
}
async function getNewCollaborators (
actualTx: TxUpdateDoc<Doc>,
ops: DocumentUpdate<Doc> | MixinUpdate<Doc, Doc>,
mixin: ClassCollaborators,
doc: Doc,
control: TriggerControl
): Promise<EmployeeAccount[]> {
const newCollaborators: IdMap<EmployeeAccount> = new Map()
if (actualTx.operations.$push !== undefined) {
for (const key in actualTx.operations.$push) {
): Promise<Ref<Account>[]> {
const newCollaborators: Set<Ref<Account>> = new Set()
if (ops.$push !== undefined) {
for (const key in ops.$push) {
if (mixin.fields.includes(key)) {
const value = (actualTx.operations.$push as any)[key]
const value = (ops.$push as any)[key]
const newCollabs = await getKeyCollaborators(doc, value, key, control)
if (newCollabs !== undefined) {
for (const newCollab of newCollabs) {
newCollaborators.set(newCollab._id, newCollab)
newCollaborators.add(newCollab)
}
}
}
}
}
for (const key in actualTx.operations) {
for (const key in ops) {
if (key.startsWith('$')) continue
if (mixin.fields.includes(key)) {
const value = (actualTx.operations as any)[key]
const value = (ops as any)[key]
const newCollabs = await getKeyCollaborators(doc, value, key, control)
if (newCollabs !== undefined) {
for (const newCollab of newCollabs) {
newCollaborators.set(newCollab._id, newCollab)
newCollaborators.add(newCollab)
}
}
}
@ -532,54 +650,57 @@ async function getNewCollaborators (
return Array.from(newCollaborators.values())
}
/**
* @public
*/
export async function UpdateCollaboratorDoc (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
const actualTx = TxProcessor.extractTx(tx) as TxUpdateDoc<Doc>
function isMixinTx (tx: TxUpdateDoc<Doc> | TxMixin<Doc, Doc>): tx is TxMixin<Doc, Doc> {
return tx._class === core.class.TxMixin
}
if (actualTx._class !== core.class.TxUpdateDoc) return []
const clazz = hierarchy.getClass(actualTx.objectClass)
if (!hierarchy.hasMixin(clazz, notification.mixin.ClassCollaborators)) return []
const mixin = hierarchy.as(clazz, notification.mixin.ClassCollaborators)
const doc = (await control.findAll(actualTx.objectClass, { _id: actualTx.objectId }, { limit: 1 }))[0]
async function updateCollaboratorDoc (
tx: TxUpdateDoc<Doc> | TxMixin<Doc, Doc>,
control: TriggerControl,
txId?: Ref<TxCUD<Doc>>
): Promise<Tx[]> {
const hierarchy = control.hierarchy
let res: Tx[] = []
const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
if (mixin === undefined) return []
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
if (doc === undefined) return []
let collaborators: EmployeeAccount[] = []
let mixinTx: TxMixin<Doc, Collaborators> | undefined
if (hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
// we should handle change field and subscribe new collaborators
const collabMixin = hierarchy.as(doc, notification.mixin.Collaborators)
const oldCollaborators = await control.modelDb.findAll(contact.class.EmployeeAccount, {
_id: { $in: collabMixin.collaborators as Ref<EmployeeAccount>[] }
})
const collabs = toIdMap(oldCollaborators)
const newCollaborators = (await getNewCollaborators(actualTx, mixin, doc, control)).filter(
(p) => !collabs.has(p._id)
)
const collabs = new Set(collabMixin.collaborators)
const ops = isMixinTx(tx) ? tx.attributes : tx.operations
const newCollaborators = (await getNewCollaborators(ops, mixin, doc, control)).filter((p) => !collabs.has(p))
if (newCollaborators.length > 0) {
mixinTx = control.txFactory.createTxMixin(
actualTx.objectId,
actualTx.objectClass,
actualTx.objectSpace,
notification.mixin.Collaborators,
{
res.push(
control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, {
$push: {
collaborators: {
$each: newCollaborators.map((p) => p._id),
$each: newCollaborators,
$position: 0
}
}
}
})
)
}
res = res.concat(
await createCollabDocInfo(
[...collabMixin.collaborators, ...newCollaborators],
tx,
doc._id,
doc._class,
control,
txId
)
)
} else {
collaborators = await getDocCollaborators(doc, mixin, control)
mixinTx = getMixinTx(actualTx, control, collaborators)
const collaborators = await getDocCollaborators(doc, mixin, control)
res.push(getMixinTx(tx, control, collaborators))
res = res.concat(await createCollabDocInfo(collaborators, tx, doc._id, doc._class, control, txId))
}
return mixinTx !== undefined ? [mixinTx] : []
return res
}
/**
@ -626,9 +747,9 @@ export * from './types'
export default async () => ({
trigger: {
OnBacklinkCreate,
CollaboratorDocHandler: collaboratorDocHandler,
OnUpdateLastView,
UpdateLastView,
CreateCollaboratorDoc,
UpdateCollaboratorDoc,
OnAddCollborator
}
})

View File

@ -15,9 +15,9 @@
//
import contact, { Employee, EmployeeAccount } from '@hcengineering/contact'
import core, { Account, Class, Doc, Mixin, Ref, TxCreateDoc, TxFactory, TxUpdateDoc } from '@hcengineering/core'
import { Account, Class, Doc, Mixin, Ref, TxCreateDoc, TxFactory, TxUpdateDoc } from '@hcengineering/core'
import notification, { LastView } from '@hcengineering/notification'
import { Plugin, plugin, Resource } from '@hcengineering/platform'
import { Plugin, Resource, plugin } from '@hcengineering/platform'
import type { TriggerControl, TriggerFunc } from '@hcengineering/server-core'
/**
@ -43,7 +43,7 @@ export async function getUpdateLastViewTx (
{ limit: 1 }
)
)[0]
const factory = new TxFactory(user)
const factory = new TxFactory(user, true)
if (current !== undefined) {
if (current[attachedTo] === -1 || current[attachedTo] >= lastView) {
return
@ -51,14 +51,12 @@ export async function getUpdateLastViewTx (
const u = factory.createTxUpdateDoc(current._class, current.space, current._id, {
[attachedTo]: lastView
})
u.space = core.space.DerivedTx
return u
} else {
const u = factory.createTxCreateDoc(notification.class.LastView, notification.space.Notifications, {
user,
[attachedTo]: lastView
})
u.space = core.space.DerivedTx
return u
}
}
@ -134,19 +132,17 @@ export async function createLastViewTx (
{ limit: 1 }
)
)[0]
const factory = new TxFactory(user)
const factory = new TxFactory(user, true)
if (current === undefined) {
const u = factory.createTxCreateDoc(notification.class.LastView, notification.space.Notifications, {
user,
[attachedTo]: 1
})
u.space = core.space.DerivedTx
return u
} else if (current[attachedTo] === undefined) {
const u = factory.createTxUpdateDoc(current._class, current.space, current._id, {
[attachedTo]: 1
})
u.space = core.space.DerivedTx
return u
}
}
@ -181,8 +177,8 @@ export default plugin(serverNotificationId, {
trigger: {
OnBacklinkCreate: '' as Resource<TriggerFunc>,
UpdateLastView: '' as Resource<TriggerFunc>,
CreateCollaboratorDoc: '' as Resource<TriggerFunc>,
UpdateCollaboratorDoc: '' as Resource<TriggerFunc>,
OnUpdateLastView: '' as Resource<TriggerFunc>,
CollaboratorDocHandler: '' as Resource<TriggerFunc>,
OnAddCollborator: '' as Resource<TriggerFunc>
}
})

View File

@ -44,7 +44,7 @@ import type { FullTextAdapter, IndexedDoc, WithFind } from './types'
* @public
*/
export class FullTextIndex implements WithFind {
txFactory = new TxFactory(core.account.System)
txFactory = new TxFactory(core.account.System, true)
consistency: Promise<void> | undefined

View File

@ -101,7 +101,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
}
async markRemove (doc: DocIndexState): Promise<void> {
const ops = new TxFactory(core.account.System)
const ops = new TxFactory(core.account.System, true)
await this.storage.tx(
ops.createTxUpdateDoc(doc._class, doc.space, doc._id, {
removed: true

View File

@ -183,7 +183,7 @@ export async function loadIndexStageStage (
const newIndex = ((attributes.index as number) ?? 0) + 1
result = `${newIndex}`
const ops = new TxFactory(core.account.System)
const ops = new TxFactory(core.account.System, true)
const data = {
stageId,
attributes: {

View File

@ -26,7 +26,7 @@ export class AsyncTriggerProcessor {
classes: Ref<Class<Doc>>[] = []
factory = new TxFactory(core.account.System)
factory = new TxFactory(core.account.System, true)
functions: AsyncTriggerFunc[] = []

View File

@ -209,7 +209,7 @@ class TServerStorage implements ServerStorage {
attachedTo: D,
update: DocumentUpdate<D>
): Promise<Tx> {
const txFactory = new TxFactory(modifiedBy)
const txFactory = new TxFactory(modifiedBy, true)
const baseClass = this.hierarchy.getBaseClass(_class)
if (baseClass !== _class) {
// Mixin operation is required.
@ -413,7 +413,7 @@ class TServerStorage implements ServerStorage {
private deleteObject (ctx: MeasureContext, object: Doc, removedMap: Map<Ref<Doc>, Doc>): Tx[] {
const result: Tx[] = []
const factory = new TxFactory(object.modifiedBy)
const factory = new TxFactory(object.modifiedBy, true)
if (this.hierarchy.isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
const nestedTx = factory.createTxRemoveDoc(adoc._class, adoc.space, adoc._id)
@ -468,7 +468,7 @@ class TServerStorage implements ServerStorage {
if (rtx.operations.space === undefined || rtx.operations.space === rtx.objectSpace) {
continue
}
const factory = new TxFactory(tx.modifiedBy)
const factory = new TxFactory(tx.modifiedBy, true)
for (const [, attribute] of this.hierarchy.getAllAttributes(rtx.objectClass)) {
if (!this.hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
continue

View File

@ -43,7 +43,7 @@ export class Triggers {
}
async apply (account: Ref<Account>, tx: Tx, ctrl: Omit<TriggerControl, 'txFactory'>): Promise<Tx[]> {
const derived = this.triggers.map((trigger) => trigger(tx, { ...ctrl, txFactory: new TxFactory(account) }))
const derived = this.triggers.map((trigger) => trigger(tx, { ...ctrl, txFactory: new TxFactory(account, true) }))
const result = await Promise.all(derived)
return result.flatMap((x) => x)
}

View File

@ -175,18 +175,16 @@ class SessionManager {
)[0]
if (user === undefined) return
const status = (await session.findAll(ctx, core.class.UserStatus, { modifiedBy: user._id }, { limit: 1 }))[0]
const txFactory = new TxFactory(user._id)
const txFactory = new TxFactory(user._id, true)
if (status === undefined) {
const tx = txFactory.createTxCreateDoc(core.class.UserStatus, user._id as string as Ref<Space>, {
online
})
tx.space = core.space.DerivedTx
await session.tx(ctx, tx)
} else if (status.online !== online) {
const tx = txFactory.createTxUpdateDoc(status._class, status.space, status._id, {
online
})
tx.space = core.space.DerivedTx
await session.tx(ctx, tx)
}
} catch {}