UBER-32 Inbox redesign (#3242)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-05-24 12:35:48 +06:00 committed by GitHub
parent e7ba92c764
commit cf86b493c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1297 additions and 350 deletions

View File

@ -61,7 +61,6 @@ export default mergeIds(chunterId, chunter, {
MentionedIn: '' as IntlString,
Content: '' as IntlString,
Comment: '' as IntlString,
Message: '' as IntlString,
Reference: '' as IntlString,
Chat: '' as IntlString,
CreateBy: '' as IntlString,

View File

@ -37,6 +37,7 @@ import view, { createAction } from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import {
DocUpdates,
DocUpdateTx,
EmailNotification,
Notification,
NotificationGroup,
@ -124,7 +125,7 @@ export class TNotificationProvider extends TDoc implements NotificationProvider
@Model(notification.class.NotificationSetting, preference.class.Preference)
export class TNotificationSetting extends TPreference implements NotificationSetting {
attachedTo!: Ref<TNotificationProvider>
declare attachedTo: Ref<TNotificationProvider>
type!: Ref<TNotificationType>
enabled!: boolean
}
@ -159,9 +160,8 @@ export class TDocUpdates extends TDoc implements DocUpdates {
hidden!: boolean
attachedToClass!: Ref<Class<Doc>>
lastTx?: Ref<TxCUD<Doc>>
lastTxTime?: Timestamp
txes!: [Ref<TxCUD<Doc>>, Timestamp][]
txes!: DocUpdateTx[]
}
export function createModel (builder: Builder): void {

View File

@ -13,9 +13,23 @@
// limitations under the License.
//
import core, { AttachedDoc, Class, Collection, Data, Doc, DOMAIN_TX, Ref, TxOperations } from '@hcengineering/core'
import core, {
Account,
AttachedDoc,
Class,
Collection,
Data,
Doc,
DOMAIN_TX,
Ref,
Timestamp,
toIdMap,
Tx,
TxCUD,
TxOperations
} from '@hcengineering/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import notification, { DocUpdates, NotificationType } from '@hcengineering/notification'
import notification, { DocUpdates, DocUpdateTx, NotificationType } from '@hcengineering/notification'
import { DOMAIN_NOTIFICATION } from '.'
async function fillNotificationText (client: MigrationClient): Promise<void> {
@ -39,6 +53,75 @@ async function fillNotificationText (client: MigrationClient): Promise<void> {
)
}
interface OldDocUpdates extends Doc {
user: Ref<Account>
attachedTo: Ref<Doc>
attachedToClass: Ref<Class<Doc>>
hidden: boolean
lastTx?: Ref<TxCUD<Doc>>
lastTxTime?: Timestamp
txes: [Ref<TxCUD<Doc>>, Timestamp][]
}
async function fillDocUpdates (client: MigrationClient): Promise<void> {
const notifications = await client.find<OldDocUpdates>(DOMAIN_NOTIFICATION, {
_class: notification.class.DocUpdates,
lastTx: { $exists: true }
})
while (notifications.length > 0) {
const docs = notifications.splice(0, 1000)
const txIds = docs
.map((p) => {
const res = p.txes.map((p) => p[0])
if (p.lastTx !== undefined) {
res.push(p.lastTx)
}
return res
})
.flat()
const txes = await client.find<Tx>(DOMAIN_TX, { _id: { $in: txIds } })
const txesMap = toIdMap(txes)
for (const doc of docs) {
const txes: DocUpdateTx[] = doc.txes
.map((p) => {
const tx = txesMap.get(p[0])
if (tx === undefined) return undefined
const res: DocUpdateTx = {
_id: tx._id as Ref<TxCUD<Doc>>,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
isNew: true
}
return res
})
.filter((p) => p !== undefined) as DocUpdateTx[]
if (txes.length === 0 && doc.lastTx !== undefined) {
const tx = txesMap.get(doc.lastTx)
if (tx !== undefined) {
txes.unshift({
_id: tx._id as Ref<TxCUD<Doc>>,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
isNew: false
})
}
}
await client.update(
DOMAIN_NOTIFICATION,
{
_id: doc._id
},
{
$unset: { lastTx: 1 },
$set: {
txes
}
}
)
}
}
}
async function removeSettings (client: MigrationClient): Promise<void> {
const outdatedSettings = await client.find(DOMAIN_NOTIFICATION, { _class: notification.class.NotificationSetting })
for (const setting of outdatedSettings) {
@ -178,6 +261,7 @@ export const notificationOperation: MigrateOperation = {
await fillNotificationText(client)
await fillCollaborators(client)
await fillDocUpdatesHidder(client)
await fillDocUpdates(client)
await cleanOutdatedSettings(client)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {

View File

@ -2,6 +2,7 @@
"extends": "./node_modules/@hcengineering/model-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"target": "esnext",
"rootDir": "./src",
"outDir": "./lib",
}

View File

@ -1077,6 +1077,10 @@ export function createModel (builder: Builder): void {
component: recruit.component.ApplicantFilter
})
builder.mixin(recruit.class.Applicant, core.class.Class, notification.mixin.NotificationObjectPresenter, {
presenter: recruit.component.NotificationApplicantresenter
})
builder.createDoc(
notification.class.NotificationGroup,
core.space.Model,

View File

@ -101,7 +101,8 @@ export default mergeIds(recruitId, recruit, {
VacancyTemplateEditor: '' as AnyComponent,
ApplicationMatchPresenter: '' as AnyComponent,
MatchVacancy: '' as AnyComponent
MatchVacancy: '' as AnyComponent,
NotificationApplicantresenter: '' as AnyComponent
},
template: {
DefaultVacancy: '' as Ref<KanbanTemplate>,

View File

@ -103,13 +103,16 @@
}
&__bordered {
min-width: 0;
min-height: 3rem;
min-height: 3.25rem;
background-color: var(--theme-comp-header-color);
&:not(.embedded) {
border: 1px solid var(--theme-divider-color);
border-bottom: none;
border-radius: .5rem .5rem 0 0;
}
&.embedded {
border-bottom: 1px solid var(--theme-divider-color);
}
}
&__content {
flex-grow: 1;
@ -138,7 +141,10 @@
&:not(.embedded) {
border-radius: 0 0 .5rem .5rem;
}
&.embedded { border-left: none; }
&.embedded {
border-top: none;
border-left: none;
}
&.main {
justify-content: stretch;

View File

@ -91,7 +91,6 @@
user-select: none;
.list-item {
margin: 0 0.5rem;
min-width: 0;
border-radius: 0.25rem;
}

View File

@ -19,16 +19,25 @@
export let model: TabModel
export let selected = 0
export let withPadding: boolean = false
export let size: 'small' | 'medium' = 'medium'
</script>
<TabsControl {model} bind:selected>
<TabsControl {model} {withPadding} {size} bind:selected>
<svelte:fragment slot="rightButtons">
{#if $$slots.rightButtons}
<div class="flex">
<slot name="rightButtons" />
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="content" let:selected>
{@const tab = model[selected]}
{#if tab}
{#if typeof tab.component === 'string'}
<Component is={tab.component} props={tab.props} on:change />
<Component is={tab.component} props={tab.props} on:change on:open />
{:else}
<svelte:component this={tab.component} {...tab.props} on:change />
<svelte:component this={tab.component} {...tab.props} on:change on:open />
{/if}
{/if}
</svelte:fragment>

View File

@ -18,9 +18,11 @@
export let model: TabBase[]
export let selected = 0
export let withPadding: boolean = false
export let size: 'small' | 'medium' = 'medium'
</script>
<div class="flex-stretch container">
<div class="flex-stretch container" class:small={size === 'small'} class:pr-4={withPadding} class:pl-4={withPadding}>
{#each model as tab, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
@ -39,6 +41,7 @@
</div>
{/each}
<div class="grow" />
<slot name="rightButtons" />
</div>
<slot name="content" {selected} />
@ -50,8 +53,17 @@
margin-bottom: 0.5rem;
width: 100%;
height: 4.5rem;
align-items: center;
border-bottom: 1px solid var(--divider-color);
&.small {
height: 3.25rem;
.tab {
height: 3.25rem;
}
}
.tab {
height: 4.5rem;
color: var(--dark-color);
@ -60,7 +72,7 @@
&.selected {
border-top: 0.125rem solid transparent;
border-bottom: 0.125rem solid var(--caption-color);
border-bottom: 0.125rem solid var(--theme-tablist-plain-color);
color: var(--caption-color);
cursor: default;
}

View File

@ -130,7 +130,9 @@ export { default as IconUpOutline } from './components/icons/UpOutline.svelte'
export { default as IconDownOutline } from './components/icons/DownOutline.svelte'
export { default as IconShare } from './components/icons/Share.svelte'
export { default as IconDelete } from './components/icons/Delete.svelte'
export { default as IconActivityEdit } from './components/icons/ActivityEdit.svelte'
export { default as IconEdit } from './components/icons/Edit.svelte'
export { default as IconFilter } from './components/icons/Filter.svelte'
export { default as IconInfo } from './components/icons/Info.svelte'
export { default as IconBlueCheck } from './components/icons/BlueCheck.svelte'
export { default as IconCheck } from './components/icons/Check.svelte'

View File

@ -15,15 +15,15 @@
<script lang="ts">
import activity, { DisplayTx, TxViewlet } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import core, { Class, Doc, Ref, SortingOrder, TxCUD } from '@hcengineering/core'
import core, { Class, Doc, Ref, SortingOrder } from '@hcengineering/core'
import notification, { DocUpdateTx, DocUpdates, Writable } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, Grid, Label, Spinner } from '@hcengineering/ui'
import { ActivityKey, activityKey, newActivity } from '../activity'
import { filterCollectionTxes } from '../utils'
import ActivityFilter from './ActivityFilter.svelte'
import TxView from './TxView.svelte'
import notification, { DocUpdates, Writable } from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
export let object: Doc
export let showCommenInput: boolean = true
@ -93,7 +93,7 @@
let newTxIndexes: number[] = []
$: newTxIndexes = getNewTxes(filtered, newTxes)
function getNewTxes (filtered: DisplayTx[], newTxes: [Ref<TxCUD<Doc>>, number][]): number[] {
function getNewTxes (filtered: DisplayTx[], newTxes: DocUpdateTx[]): number[] {
const res: number[] = []
for (let i = 0; i < filtered.length; i++) {
if (isNew(filtered[i], newTxes)) {
@ -103,9 +103,9 @@
return res
}
function isNew (tx: DisplayTx | undefined, newTxes: [Ref<TxCUD<Doc>>, number][]): boolean {
function isNew (tx: DisplayTx | undefined, newTxes: DocUpdateTx[]): boolean {
if (tx === undefined) return false
const index = newTxes.findIndex((p) => p[0] === tx.originTx._id)
const index = newTxes.findIndex((p) => p._id === tx.originTx._id && p.isNew)
return index !== -1
}

View File

@ -15,8 +15,10 @@
-->
<script lang="ts">
import type { DisplayTx, TxViewlet } from '@hcengineering/activity'
import attachment from '@hcengineering/attachment'
import chunter from '@hcengineering/chunter'
import contact, { Employee, EmployeeAccount, getName } from '@hcengineering/contact'
import core, { AnyAttribute, Doc, getCurrentAccount, Ref, Class, TxCUD } from '@hcengineering/core'
import core, { AnyAttribute, Class, Doc, Ref, TxCUD, getCurrentAccount } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import ui, {
@ -24,23 +26,21 @@
AnyComponent,
Component,
Icon,
IconActivityEdit,
IconEdit,
IconMoreH,
Label,
ShowMore,
showPopup,
TimeSince
TimeSince,
showPopup
} from '@hcengineering/ui'
import type { AttributeModel } from '@hcengineering/view'
import attachment from '@hcengineering/attachment'
import chunter from '@hcengineering/chunter'
import { Menu, ObjectPresenter } from '@hcengineering/view-resources'
import { tick } from 'svelte'
import { ActivityKey } from '../activity'
import activity from '../plugin'
import { getPrevValue, getValue, TxDisplayViewlet, updateViewlet } from '../utils'
import { TxDisplayViewlet, getPrevValue, getValue, updateViewlet } from '../utils'
import TxViewTx from './TxViewTx.svelte'
import Edit from './icons/Edit.svelte'
import { tick } from 'svelte'
export let tx: DisplayTx
export let viewlets: Map<ActivityKey, TxViewlet>
@ -200,9 +200,9 @@
{:else if viewlet}
<Icon icon={viewlet.icon} size="small" />
{:else if viewlet === undefined && model.length > 0}
<Icon icon={modelIcon !== undefined ? modelIcon : Edit} size="small" />
<Icon icon={modelIcon !== undefined ? modelIcon : IconActivityEdit} size="small" />
{:else}
<Icon icon={Edit} size="small" />
<Icon icon={IconActivityEdit} size="small" />
{/if}
</div>
{/if}

View File

@ -105,7 +105,7 @@
function newMessagesStart (messages: Message[], docUpdates: Map<Ref<Doc>, DocUpdates>): number {
if (space === undefined) return -1
const docUpdate = docUpdates.get(space)
const lastView = docUpdate?.txes?.[0]?.[1]
const lastView = docUpdate?.txes?.[0]?.modifiedOn
if (docUpdate === undefined || lastView === undefined) return -1
for (let index = 0; index < messages.length; index++) {
const message = messages[index]

View File

@ -15,10 +15,12 @@
<script lang="ts">
import { Ref, Space } from '@hcengineering/core'
import ChannelView from './ChannelView.svelte'
import SpaceHeader from './SpaceHeader.svelte'
export let _id: Ref<Space>
</script>
<div class="antiPanel-component">
<SpaceHeader spaceId={_id} withSearch={false} />
<ChannelView space={_id} />
</div>

View File

@ -25,6 +25,7 @@
import { userSearch } from '../index'
export let spaceId: Ref<DirectMessage> | undefined
export let withSearch: boolean = true
let userSearch_: string = ''
userSearch.subscribe((v) => (userSearch_ = v))
@ -68,16 +69,18 @@
{/await}
{/await}
{/if}
<SearchEdit
value={userSearch_}
on:change={(ev) => {
userSearch.set(ev.detail)
{#if withSearch}
<SearchEdit
value={userSearch_}
on:change={(ev) => {
userSearch.set(ev.detail)
if (ev.detail !== '') {
navigateToSpecial('chunterBrowser')
}
}}
/>
if (ev.detail !== '') {
navigateToSpecial('chunterBrowser')
}
}}
/>
{/if}
</div>
<style lang="scss">

View File

@ -0,0 +1,42 @@
<!--
// 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 { ChunterSpace } from '@hcengineering/chunter'
import type { Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import chunter from '../plugin'
import DmHeader from './DmHeader.svelte'
import ChannelHeader from './ChannelHeader.svelte'
export let spaceId: Ref<ChunterSpace> | undefined
export let withSearch: boolean = true
const client = getClient()
const hierarchy = client.getHierarchy()
const query = createQuery()
let channel: ChunterSpace | undefined
$: query.query(chunter.class.ChunterSpace, { _id: spaceId }, (result) => {
channel = result[0]
})
</script>
{#if channel}
{#if hierarchy.isDerived(channel._class, chunter.class.DirectMessage)}
<DmHeader {spaceId} {withSearch} />
{:else}
<ChannelHeader {spaceId} />
{/if}
{/if}

View File

@ -160,7 +160,7 @@
function newMessagesStart (comments: ThreadMessage[], docUpdates: Map<Ref<Doc>, DocUpdates>): number {
const docUpdate = docUpdates.get(_id)
const lastView = docUpdate?.txes?.[0]?.[1]
const lastView = docUpdate?.txes?.[0]?.modifiedOn
if (docUpdate === undefined || lastView === undefined) return -1
for (let index = 0; index < comments.length; index++) {
const comment = comments[index]

View File

@ -25,8 +25,6 @@ export default mergeIds(chunterId, chunter, {
CreateChannel: '' as AnyComponent,
CreateDirectMessage: '' as AnyComponent,
ChannelHeader: '' as AnyComponent,
DmHeader: '' as AnyComponent,
ChannelView: '' as AnyComponent,
ChannelViewPanel: '' as AnyComponent,
ThreadViewPanel: '' as AnyComponent,
ThreadParentPresenter: '' as AnyComponent,

View File

@ -104,6 +104,7 @@ export function openMessageFromSpecial (message: ChunterMessage): void {
export function navigateToSpecial (specialId: string): void {
const loc = get(location)
loc.path[2] = chunterId
loc.path[3] = specialId
navigate(loc)
}

View File

@ -129,6 +129,8 @@ export default plugin(chunterId, {
},
component: {
CommentInput: '' as AnyComponent,
DmHeader: '' as AnyComponent,
ChannelView: '' as AnyComponent,
CommentsPresenter: '' as AnyComponent
},
class: {
@ -154,6 +156,7 @@ export default plugin(chunterId, {
ArchiveChannel: '' as IntlString,
UnarchiveChannel: '' as IntlString,
ArchiveConfirm: '' as IntlString,
Message: '' as IntlString,
UnarchiveConfirm: '' as IntlString,
ConvertToPrivate: '' as IntlString
},

View File

@ -19,6 +19,11 @@
"Change": "Change",
"AddedRemoved": "Added/removed",
"YouAddedCollaborators": "You was added to collaborators",
"ChangeCollaborators": "changed collaborators"
"ChangeCollaborators": "changed collaborators",
"Activity": "Activity",
"People": "People",
"All": "All",
"Read": "Read",
"Unread": "Unread"
}
}

View File

@ -19,6 +19,11 @@
"Change": "Изменено",
"AddedRemoved": "Добавлено/удалено",
"YouAddedCollaborators": "Вы были добавлены как участник",
"ChangeCollaborators": "изменил(а) участники"
"ChangeCollaborators": "изменил(а) участники",
"Activity": "Активность",
"People": "Люди",
"All": "Все",
"Read": "Прочитанное",
"Unread": "Не прочитанное"
}
}

View File

@ -41,6 +41,8 @@
"@hcengineering/activity": "^0.6.0",
"@hcengineering/contact": "^0.6.16",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/attachment": "^0.6.6",
"@hcengineering/chunter": "^0.6.7",
"@hcengineering/core": "^0.6.25",
"@hcengineering/view": "^0.6.6",
"@hcengineering/view-resources": "^0.6.0"

View File

@ -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 activity, { TxViewlet } from '@hcengineering/activity'
import { activityKey, ActivityKey } from '@hcengineering/activity-resources'
import { Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { createQuery } from '@hcengineering/presentation'
import { ListView, Loading, Scroller } from '@hcengineering/ui'
import { ActionContext, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import NotificationView from './NotificationView.svelte'
export let filter: 'all' | 'read' | 'unread' = 'all'
const dispatch = createEventDispatcher()
const query = createQuery()
let _id: Ref<Doc> | undefined
let docs: DocUpdates[] = []
let filtered: DocUpdates[] = []
let loading = true
$: query.query(
notification.class.DocUpdates,
{
user: getCurrentAccount()._id,
hidden: false
},
(res) => {
docs = res
getFiltered(docs, filter)
loading = false
},
{
sort: {
lastTxTime: -1
}
}
)
function getFiltered (docs: DocUpdates[], filter: 'all' | 'read' | 'unread'): void {
if (filter === 'read') {
filtered = docs.filter((p) => !p.txes.some((p) => p.isNew))
} else if (filter === 'unread') {
filtered = docs.filter((p) => p.txes.some((p) => p.isNew))
} else {
filtered = docs
}
listProvider.update(filtered)
if (loading || _id === undefined) {
changeSelected(selected)
} else if (filtered.find((p) => p.attachedTo === _id) === undefined) {
changeSelected(selected)
}
}
$: getFiltered(docs, filter)
$: changeSelected(selected)
function changeSelected (index: number) {
if (filtered[index] !== undefined) {
listProvider.updateFocus(filtered[index])
_id = filtered[index]?.attachedTo
dispatch('change', filtered[index])
} else if (filtered.length) {
if (index < filtered.length - 1) {
selected++
} else {
selected--
}
} else {
selected = 0
_id = undefined
dispatch('change', undefined)
}
}
let viewlets: Map<ActivityKey, TxViewlet>
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
const value = selected + offset
if (filtered[value] !== undefined) {
selected = value
listView?.select(selected)
}
}
})
const descriptors = createQuery()
descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
let selected = 0
let listView: ListView
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
<div class="clear-mins container">
<Scroller>
{#if loading}
<Loading />
{:else}
{#each filtered as item, i (item._id)}
<NotificationView
value={item}
selected={selected === i}
{viewlets}
on:click={() => {
selected = i
}}
/>
{/each}
{/if}
</Scroller>
</div>
<style lang="scss">
.container {
margin-top: -0.5rem;
}
</style>

View File

@ -0,0 +1,205 @@
<!--
// 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 core, { Account, Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { ActionIcon, Button, IconBack, ListView, Loading, Scroller } from '@hcengineering/ui'
import { ActionContext, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import NotificationView from './NotificationView.svelte'
import { Employee, EmployeeAccount, getName } from '@hcengineering/contact'
import { Avatar, employeeAccountByIdStore, employeeByIdStore } from '@hcengineering/contact-resources'
import chunter from '@hcengineering/chunter'
export let accountId: Ref<Account>
const dispatch = createEventDispatcher()
const query = createQuery()
let _id: Ref<Doc> | undefined
let docs: DocUpdates[] = []
let loading = true
const me = getCurrentAccount()._id
$: query.query(
notification.class.DocUpdates,
{
user: me,
hidden: false
},
(res) => {
docs = []
for (const doc of res) {
const txes = doc.txes.filter((p) => p.modifiedBy === accountId)
if (txes.length > 0) {
docs.push({
...doc,
txes
})
}
}
docs = docs
listProvider.update(docs)
if (loading || _id === undefined) {
changeSelected(selected)
} else if (docs.find((p) => p.attachedTo === _id) === undefined) {
changeSelected(selected)
}
loading = false
},
{
sort: {
lastTxTime: -1
}
}
)
$: changeSelected(selected)
function changeSelected (index: number) {
if (docs[index] !== undefined) {
listProvider.updateFocus(docs[index])
_id = docs[index]?.attachedTo
dispatch('change', docs[index])
} else if (docs.length) {
if (index < docs.length - 1) {
selected++
} else {
selected--
}
} else {
selected = 0
_id = undefined
dispatch('change', undefined)
}
}
let viewlets: Map<ActivityKey, TxViewlet>
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
const value = selected + offset
if (docs[value] !== undefined) {
selected = value
listView?.select(selected)
}
}
})
const descriptors = createQuery()
descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
let selected = 0
let listView: ListView
let employee: Employee | undefined = undefined
$: newTxes = docs.reduce((acc, cur) => acc + cur.txes.filter((p) => p.isNew).length, 0) // items.length
$: account = $employeeAccountByIdStore.get(accountId as Ref<EmployeeAccount>)
$: employee = account ? $employeeByIdStore.get(account.employee) : undefined
const client = getClient()
async function openDM () {
const current = (await client.findAll(chunter.class.DirectMessage, { members: accountId })).find(
(p) => p.members.length === 2
)
if (current !== undefined) {
dispatch('dm', current._id)
} else {
const id = await client.createDoc(chunter.class.DirectMessage, core.space.Space, {
name: '',
description: '',
private: true,
archived: false,
members: [me, accountId]
})
dispatch('dm', id)
}
}
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
<div class="flex-between header bottom-divider">
<div class="flex-row-center flex-gap-1">
<ActionIcon
icon={IconBack}
size="medium"
action={() => {
dispatch('close')
}}
/>
{#if employee}
<Avatar size="medium" avatar={employee.avatar} />
<span class="font-medium">{getName(employee)}</span>
{/if}
{#if newTxes > 0}
<div class="counter">
{newTxes}
</div>
{/if}
</div>
{#if me !== accountId}
<div>
<Button label={chunter.string.Message} kind="primary" on:click={openDM} />
</div>
{/if}
</div>
<div class="clear-mins container">
<Scroller>
{#if loading}
<Loading />
{:else}
{#each docs as item, i (item._id)}
<NotificationView
value={item}
selected={selected === i}
{viewlets}
on:click={() => {
selected = i
changeSelected(selected)
}}
/>
{/each}
{/if}
</Scroller>
</div>
<style lang="scss">
.header {
padding: 0.5rem 1rem;
height: 3.25rem;
}
.counter {
display: flex;
align-self: flex-start;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.25rem;
color: #2b5190;
background-color: var(--theme-calendar-today-bgcolor);
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,48 @@
<!--
// 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 { Button, IconFilter, SelectPopup, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import notification from '../plugin'
export let filter: 'all' | 'read' | 'unread' = 'all'
$: filters = [
{
id: 'all',
isSelected: filter === 'all',
label: notification.string.All
},
{
id: 'read',
isSelected: filter === 'read',
label: notification.string.Read
},
{
id: 'unread',
isSelected: filter === 'unread',
label: notification.string.Unread
}
]
function click (e: MouseEvent) {
showPopup(SelectPopup, { value: filters }, eventToHTMLElement(e), (res) => {
if (res) {
filter = res
}
})
}
</script>
<Button icon={IconFilter} on:click={click} />

View File

@ -13,74 +13,55 @@
// 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, ListView, Loading, Scroller } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { ActionContext, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { AnyComponent, Component, Tabs } from '@hcengineering/ui'
import Activity from './Activity.svelte'
import People from './People.svelte'
import notification from '../plugin'
import { Class, Doc, Ref } from '@hcengineering/core'
import { NotificationClientImpl } from '../utils'
import NotificationView from './NotificationView.svelte'
import { getClient } from '@hcengineering/presentation'
import { DocUpdates } from '@hcengineering/notification'
import view from '@hcengineering/view'
import Filter from './Filter.svelte'
import { EmployeeAccount } from '@hcengineering/contact'
import EmployeeInbox from './EmployeeInbox.svelte'
import chunter from '@hcengineering/chunter'
export let visibileNav: boolean
let filter: 'all' | 'read' | 'unread' = 'all'
const client = getClient()
const hierarchy = client.getHierarchy()
const notificationClient = NotificationClientImpl.getClient()
const query = createQuery()
let docs: DocUpdates[] = []
let loading = true
$: query.query(
notification.class.DocUpdates,
$: tabs = [
{
user: getCurrentAccount()._id,
hidden: false
},
(res) => {
docs = res
listProvider.update(docs)
if (loading || _id === undefined) {
changeSelected(selected)
} else if (docs.find((p) => p.attachedTo === _id) === undefined) {
changeSelected(selected)
}
loading = false
label: notification.string.Activity,
props: { filter },
component: Activity
},
{
sort: {
lastTxTime: -1
}
label: notification.string.People,
props: { filter },
component: People
}
)
]
$: changeSelected(selected)
let component: AnyComponent | undefined
let _id: Ref<Doc> | undefined
let _class: Ref<Class<Doc>> | undefined
let selectedEmployee: Ref<EmployeeAccount> | undefined = undefined
function changeSelected (index: number) {
if (docs[index] !== undefined) {
select(docs[index])
} else if (docs.length) {
if (index < docs.length - 1) {
selected++
} else {
selected--
}
} else {
selected = 0
async function select (value: DocUpdates | undefined) {
if (!value) {
component = undefined
_id = undefined
_class = undefined
return
}
}
async function select (value: DocUpdates) {
if (value.attachedTo !== _id && _id !== undefined) {
await notificationClient.read(_id)
}
listProvider.updateFocus(value)
const targetClass = hierarchy.getClass(value.attachedToClass)
const panelComponent = hierarchy.as(targetClass, view.mixin.ObjectPanel)
component = panelComponent.component ?? view.component.EditDoc
@ -88,81 +69,57 @@
_class = value.attachedToClass
}
let component: AnyComponent | undefined
let _id: Ref<Doc> | undefined
let _class: Ref<Class<Doc>> | undefined
let viewlets: Map<ActivityKey, TxViewlet>
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
const value = selected + offset
if (docs[value] !== undefined) {
selected = value
listView?.select(selected)
}
function openDM (value: Ref<Doc>) {
if (value) {
const targetClass = hierarchy.getClass(chunter.class.DirectMessage)
const panelComponent = hierarchy.as(targetClass, view.mixin.ObjectPanel)
component = panelComponent.component ?? view.component.EditDoc
_id = value
_class = chunter.class.DirectMessage
}
})
}
const descriptors = createQuery()
descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
let selected = 0
let listView: ListView
let selectedTab = 0
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
<div class="flex h-full">
{#if visibileNav}
<div class="antiPanel-component border-right filled indent aside inbox">
<div class="header">
<span class="fs-title overflow-label">
<Label label={notification.string.Inbox} />
</span>
</div>
<div class="top-divider clear-mins h-full">
<Scroller>
{#if loading}
<Loading />
{:else}
<ListView bind:this={listView} count={docs.length} selection={selected}>
<svelte:fragment slot="item" let:item>
<NotificationView
value={docs[item]}
selected={selected === item}
{viewlets}
on:click={() => {
selected = item
}}
/>
</svelte:fragment>
</ListView>
{/if}
</Scroller>
</div>
{#if selectedEmployee === undefined}
<Tabs
bind:selected={selectedTab}
model={tabs}
on:change={(e) => select(e.detail)}
on:open={(e) => {
selectedEmployee = e.detail
}}
withPadding
size="small"
>
<svelte:fragment slot="rightButtons">
<Filter bind:filter />
</svelte:fragment>
</Tabs>
{:else}
<EmployeeInbox
accountId={selectedEmployee}
on:change={(e) => select(e.detail)}
on:dm={(e) => openDM(e.detail)}
on:close={(e) => {
selectedEmployee = undefined
}}
/>
{/if}
</div>
{/if}
{#if component && _id && _class}
<Component is={component} props={{ embedded: true, _id, _class }} />
{:else}
<div class="antiPanel-component filled w-full" />
{/if}
<div class="antiPanel-component filled w-full">
{#if component && _id && _class}
<Component is={component} props={{ embedded: true, _id, _class }} />
{/if}
</div>
</div>
<style lang="scss">
.header {
min-height: 3.1rem;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 1.5rem;
}
.inbox {
min-width: 20rem;
}

View File

@ -15,13 +15,11 @@
<script lang="ts">
import { TxViewlet } from '@hcengineering/activity'
import { ActivityKey } from '@hcengineering/activity-resources'
import { EmployeeAccount, getName } from '@hcengineering/contact'
import { Avatar, employeeAccountByIdStore, employeeByIdStore } from '@hcengineering/contact-resources'
import core, { Doc, Ref, TxCUD, TxProcessor } from '@hcengineering/core'
import core, { Doc, 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, getEventPositionElement, showPopup } from '@hcengineering/ui'
import { AnySvelteComponent, TimeSince, getEventPositionElement, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { Menu } from '@hcengineering/view-resources'
import TxView from './TxView.svelte'
@ -29,16 +27,15 @@
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()
$: value.lastTx &&
client.findOne(core.class.TxCUD, { _id: value.lastTx }).then((res) => {
$: value.txes[0] &&
client.findOne(core.class.TxCUD, { _id: value.txes[0]._id }).then((res) => {
if (res !== undefined) {
tx = TxProcessor.extractTx(res) as TxCUD<Doc>
} else {
@ -54,16 +51,12 @@
getResource(presenterRes).then((res) => (presenter = res))
}
$: account = $employeeAccountByIdStore.get(tx?.modifiedBy as Ref<EmployeeAccount>)
$: employee = account && $employeeByIdStore.get(account.employee)
const docQuery = createQuery()
$: docQuery.query(value.attachedToClass, { _id: value.attachedTo }, (res) => {
;[doc] = res
})
$: newTxes = value.txes.length
$: newTxes = value.txes.filter((p) => p.isNew).length
function showMenu (e: MouseEvent) {
showPopup(Menu, { object: value, baseMenuClass: value._class }, getEventPositionElement(e))
@ -71,40 +64,51 @@
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if doc}
<div class="container cursor-pointer bottom-divider" class:selected on:contextmenu|preventDefault={showMenu} on:click>
<div class="header flex">
<Avatar avatar={employee?.avatar} size="medium" />
<div class="ml-2 w-full clear-mins">
<div class="flex-between mb-1">
<div class="labels-row">
{#if employee}
<span class="bold">{getName(employee)}</span>
{:else}
<span class="strong"><Label label={core.string.System} /></span>
{/if}
{#if newTxes > 0}
<div class="counter">
{newTxes}
</div>
{/if}
</div>
<div class="time ml-2"><TimeSince value={tx?.modifiedOn} /></div>
<div class="container cursor-pointer" class:selected>
<div class="notify" class:hidden={newTxes === 0} />
{#if doc}
<div
class="clear-mins content bottom-divider"
class:read={newTxes === 0}
on:contextmenu|preventDefault={showMenu}
on:click
>
<div class="w-full">
<div class="flex-between">
{#if presenter}
<svelte:component this={presenter} value={doc} inline />
{/if}
{#if newTxes > 0}
<div class="counter">
{newTxes}
</div>
{/if}
</div>
<div class="flex-between mt-2">
{#if tx}
<TxView {tx} {viewlets} objectId={value.attachedTo} />
{/if}
<div class="time">
<TimeSince value={tx?.modifiedOn} />
</div>
</div>
{#if presenter}
<svelte:component this={presenter} value={doc} inline />
{/if}
</div>
</div>
{#if tx}
<TxView {tx} {viewlets} objectId={value.attachedTo} />
{/if}
</div>
{/if}
{/if}
</div>
<style lang="scss">
.time {
align-self: flex-end;
margin-bottom: 0.25rem;
}
.container {
padding: 0.5rem;
margin-right: 0.5rem;
margin-left: 0.5rem;
display: flex;
border-radius: 0.25rem;
flex-grow: 1;
&:hover {
background-color: var(--highlight-hover);
@ -116,19 +120,40 @@
background-color: var(--highlight-select-hover);
}
}
.content {
margin-right: 0.5rem;
display: flex;
flex-grow: 1;
padding: 0.5rem 0;
&.read {
opacity: 0.6;
}
}
}
.notify {
height: 0.5rem;
width: 0.5rem;
margin-top: 0.75rem;
margin-right: 0.5rem;
background-color: #2b5190;
border-radius: 50%;
&.hidden {
opacity: 0;
}
}
.counter {
display: flex;
align-self: flex-start;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
height: 1.25rem;
width: 1.25rem;
font-weight: 600;
font-size: 0.75rem;
color: var(--theme-accent-color);
border: 1px solid var(--highlight-red);
color: #2b5190;
background-color: var(--theme-calendar-today-bgcolor);
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,161 @@
<!--
// 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 { EmployeeAccount } from '@hcengineering/contact'
import { employeeAccountByIdStore } from '@hcengineering/contact-resources'
import { Account, Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { createQuery } from '@hcengineering/presentation'
import { ListView, Loading, Scroller } from '@hcengineering/ui'
import { ActionContext, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import PeopleNotificationView from './PeopleNotificationsView.svelte'
export let filter: 'all' | 'read' | 'unread' = 'all'
const query = createQuery()
let _id: Ref<Doc> | undefined
let docs: DocUpdates[] = []
let map: Map<Ref<Account>, DocUpdates[]> = new Map()
let accounts: EmployeeAccount[] = []
let loading = true
$: query.query(
notification.class.DocUpdates,
{
user: getCurrentAccount()._id,
hidden: false
},
(res) => {
docs = res
getFiltered(docs, filter)
loading = false
},
{
sort: {
lastTxTime: -1
}
}
)
function getFiltered (docs: DocUpdates[], filter: 'all' | 'read' | 'unread'): void {
const filtered: DocUpdates[] = []
for (const doc of docs) {
if (filter === 'read') {
const txes = doc.txes.filter((p) => !p.isNew)
if (txes.length > 0) {
filtered.push({
...doc,
txes
})
}
} else if (filter === 'unread') {
const txes = doc.txes.filter((p) => p.isNew)
if (txes.length > 0) {
filtered.push({
...doc,
txes
})
}
} else {
filtered.push(doc)
}
}
map.clear()
for (const item of filtered) {
for (const tx of item.txes) {
const arr = map.get(tx.modifiedBy) ?? []
arr.push(item)
map.set(tx.modifiedBy, arr)
}
}
map = map
accounts = Array.from(map.keys())
.map((p) => $employeeAccountByIdStore.get(p as Ref<EmployeeAccount>))
.filter((p) => p !== undefined) as EmployeeAccount[]
listProvider.update(accounts)
if (loading || _id === undefined) {
changeSelected(selected)
} else if (filtered.find((p) => p.attachedTo === _id) === undefined) {
changeSelected(selected)
}
}
$: getFiltered(docs, filter)
$: changeSelected(selected)
function changeSelected (index: number) {
if (accounts[index] !== undefined) {
listProvider.updateFocus(accounts[index])
} else if (accounts.length) {
if (index < accounts.length - 1) {
selected++
} else {
selected--
}
} else {
selected = 0
}
}
let viewlets: Map<ActivityKey, TxViewlet>
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
const value = selected + offset
if (accounts[value] !== undefined) {
selected = value
listView?.select(selected)
}
}
})
const descriptors = createQuery()
descriptors.query(activity.class.TxViewlet, {}, (result) => {
viewlets = new Map(result.map((r) => [activityKey(r.objectClass, r.txClass), r]))
})
let selected = 0
let listView: ListView
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
<div class="clear-mins">
<Scroller>
{#if loading}
<Loading />
{:else}
{#each accounts as account, i}
<PeopleNotificationView
value={account}
items={map.get(account._id) ?? []}
selected={selected === i}
{viewlets}
on:open
on:click={() => {
selected = i
}}
/>
{/each}
{/if}
</Scroller>
</div>

View File

@ -0,0 +1,177 @@
<!--
// 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 } from '@hcengineering/activity-resources'
import { EmployeeAccount, getName } from '@hcengineering/contact'
import { Avatar, employeeByIdStore } from '@hcengineering/contact-resources'
import core, { Doc, TxCUD, TxProcessor } from '@hcengineering/core'
import notification, { DocUpdates } from '@hcengineering/notification'
import { ActionIcon, AnySvelteComponent, Label, TimeSince } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import TxView from './TxView.svelte'
import { createQuery, getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import { getResource } from '@hcengineering/platform'
import ArrowRight from './icons/ArrowRight.svelte'
export let value: EmployeeAccount
export let items: DocUpdates[]
export let viewlets: Map<ActivityKey, TxViewlet>
export let selected: boolean
$: firstItem = items[0]
$: employee = $employeeByIdStore.get(value.employee)
$: newTxes = items.reduce((acc, cur) => acc + cur.txes.filter((p) => p.isNew).length, 0) // items.length
const dispatch = createEventDispatcher()
let doc: Doc | undefined = undefined
let tx: TxCUD<Doc> | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
$: firstItem?.txes[0] &&
client.findOne(core.class.TxCUD, { _id: firstItem.txes[0]._id }).then((res) => {
if (res !== undefined) {
tx = TxProcessor.extractTx(res) as TxCUD<Doc>
} else {
tx = res
}
})
let presenter: AnySvelteComponent | undefined = undefined
$: presenterRes =
hierarchy.classHierarchyMixin(firstItem.attachedToClass, notification.mixin.NotificationObjectPresenter)
?.presenter ?? hierarchy.classHierarchyMixin(firstItem.attachedToClass, view.mixin.ObjectPresenter)?.presenter
$: if (presenterRes) {
getResource(presenterRes).then((res) => (presenter = res))
}
const docQuery = createQuery()
$: docQuery.query(firstItem.attachedToClass, { _id: firstItem.attachedTo }, (res) => {
;[doc] = res
})
</script>
<div class="container cursor-pointer" class:selected>
<div class="notify" class:hidden={newTxes === 0} />
{#if doc}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="clear-mins content bottom-divider" class:read={newTxes === 0} on:click>
<div class="w-full">
<div class="flex-between mb-2">
<div class="flex-row-center flex-gap-2">
<Avatar avatar={employee?.avatar} size="small" />
{#if employee}
<span class="font-medium">{getName(employee)}</span>
{:else}
<span class="font-medium"><Label label={core.string.System} /></span>
{/if}
{#if newTxes > 0}
<div class="counter">
{newTxes}
</div>
{/if}
</div>
<div>
<ActionIcon
icon={ArrowRight}
size="medium"
action={() => {
dispatch('open', value._id)
}}
/>
</div>
</div>
<div>
{#if presenter}
<svelte:component this={presenter} value={doc} inline />
{/if}
</div>
<div class="flex-between mt-2">
{#if tx && firstItem}
<TxView {tx} {viewlets} objectId={firstItem.attachedTo} />
{/if}
<div class="time">
<TimeSince value={tx?.modifiedOn} />
</div>
</div>
</div>
</div>
{/if}
</div>
<style lang="scss">
.time {
align-self: flex-end;
margin-bottom: 0.25rem;
}
.container {
margin-right: 0.5rem;
margin-left: 0.5rem;
display: flex;
border-radius: 0.25rem;
flex-grow: 1;
&:hover {
background-color: var(--highlight-hover);
}
&.selected {
background-color: var(--highlight-select);
&:hover {
background-color: var(--highlight-select-hover);
}
}
.content {
margin-right: 0.5rem;
display: flex;
flex-grow: 1;
padding: 0.5rem 0;
&.read {
opacity: 0.6;
}
}
}
.notify {
height: 0.5rem;
width: 0.5rem;
margin-top: 1.25rem;
margin-right: 0.5rem;
background-color: #2b5190;
border-radius: 50%;
&.hidden {
opacity: 0;
}
}
.counter {
display: flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.25rem;
color: #2b5190;
background-color: var(--theme-calendar-today-bgcolor);
border-radius: 50%;
}
</style>

View File

@ -22,30 +22,36 @@
updateViewlet
} from '@hcengineering/activity-resources'
import activity from '@hcengineering/activity-resources/src/plugin'
import { EmployeeAccount } from '@hcengineering/contact'
import { employeeAccountByIdStore } from '@hcengineering/contact-resources'
import core, { AnyAttribute, Doc, Ref, TxCUD } from '@hcengineering/core'
import attachment from '@hcengineering/attachment'
import chunter from '@hcengineering/chunter'
import { Employee, EmployeeAccount } from '@hcengineering/contact'
import { Avatar, employeeAccountByIdStore, employeeByIdStore } from '@hcengineering/contact-resources'
import core, { AnyAttribute, Class, Doc, Ref, TxCUD } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Component, Label, ShowMore } from '@hcengineering/ui'
import { AnyComponent, Component, Icon, IconActivityEdit, Label } from '@hcengineering/ui'
import type { AttributeModel } from '@hcengineering/view'
import { ObjectPresenter } from '@hcengineering/view-resources'
export let tx: TxCUD<Doc>
export let objectId: Ref<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 account: EmployeeAccount | undefined
let employee: Employee | undefined
let model: AttributeModel[] = []
let iconComponent: AnyComponent | undefined = undefined
let modelIcon: Asset | undefined = undefined
$: if (tx._id !== ptx?.tx._id) {
ptx = newDisplayTx(tx, client.getHierarchy(), objectId === tx.objectId)
if (tx.modifiedBy !== employee?._id) {
if (tx.modifiedBy !== account?._id) {
account = undefined
employee = undefined
}
props = undefined
@ -61,12 +67,15 @@
updateViewlet(client, viewlets, ptx).then((result) => {
if (result.id === tx._id) {
viewlet = result.viewlet
modelIcon = result.modelIcon
iconComponent = result.iconComponent
props = getProps(result.props)
model = result.model
}
})
$: employee = $employeeAccountByIdStore.get(tx.modifiedBy as Ref<EmployeeAccount>)
$: account = $employeeAccountByIdStore.get(tx.modifiedBy as Ref<EmployeeAccount>)
$: employee = account ? $employeeByIdStore.get(account?.employee) : undefined
function isMessageType (attr?: AnyAttribute): boolean {
return attr?.type._class === core.class.TypeMarkup
@ -91,12 +100,39 @@
})
$: isComment = viewlet && viewlet?.editable
$: isMention = viewlet?.display === 'emphasized' || isMessageType(model[0]?.attribute)
$: isColumn = isComment || isMention || hasMessageType
$: isAttached = isAttachment(tx)
$: isMentioned = isMention(tx.objectClass)
$: withAvatar = isComment || isMentioned || isAttached
$: isEmphasized = viewlet?.display === 'emphasized' || model.every((m) => isMessageType(m.attribute))
$: isColumn = isComment || isEmphasized || hasMessageType
function isAttachment (tx: TxCUD<Doc>): boolean {
return tx.objectClass === attachment.class.Attachment && tx._class === core.class.TxCreateDoc
}
function isMention (_class?: Ref<Class<Doc>>): boolean {
return _class === chunter.class.Backlink
}
</script>
{#if (viewlet !== undefined && !((viewlet?.hideOnRemove ?? false) && ptx?.removed)) || model.length > 0}
<div class="msgactivity-container">
{#if withAvatar}
<div class="msgactivity-avatar">
<Avatar avatar={employee?.avatar} size="x-small" />
</div>
{:else}
<div class="msgactivity-icon">
{#if iconComponent}
<Component is={iconComponent} {props} />
{:else if viewlet}
<Icon icon={viewlet.icon} size="medium" />
{:else if viewlet === undefined && model.length > 0}
<Icon icon={modelIcon !== undefined ? modelIcon : IconActivityEdit} size="medium" />
{:else}
<Icon icon={IconActivityEdit} size="medium" />
{/if}
</div>
{/if}
<div class="msgactivity-content clear-mins" class:content={isColumn} class:comment={isComment}>
<div class="msgactivity-content__header clear-mins">
<div class="msgactivity-content__title labels-row">
@ -193,103 +229,27 @@
{/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;
align-items: center;
justify-content: space-between;
.msgactivity-content {
display: flex;
flex-grow: 1;
margin-left: 0.5rem;
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;
flex-wrap: nowrap;
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;
}
.msgactivity-icon,
.msgactivity-avatar {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -0,0 +1,23 @@
<!--
// 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">
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">
<polygon points="6.4,14.4 5.6,13.6 11.3,8 5.6,2.4 6.4,1.6 12.7,8 " />
</svg>

View File

@ -30,6 +30,11 @@ export default mergeIds(notificationId, notification, {
Change: '' as IntlString,
AddedRemoved: '' as IntlString,
YouAddedCollaborators: '' as IntlString,
ChangeCollaborators: '' as IntlString
ChangeCollaborators: '' as IntlString,
Activity: '' as IntlString,
People: '' as IntlString,
All: '' as IntlString,
Read: '' as IntlString,
Unread: '' as IntlString
}
})

View File

@ -62,7 +62,8 @@ export class NotificationClientImpl implements NotificationClient {
const client = getClient()
const docUpdate = this.docUpdatesMap.get(_id)
if (docUpdate !== undefined) {
await client.update(docUpdate, { txes: [] })
docUpdate.txes.forEach((p) => (p.isNew = false))
await client.update(docUpdate, { txes: docUpdate.txes })
}
}
@ -70,7 +71,8 @@ export class NotificationClientImpl implements NotificationClient {
const client = getClient()
const docUpdate = this.docUpdatesMap.get(_id)
if (docUpdate !== undefined) {
await client.update(docUpdate, { txes: [] })
docUpdate.txes.forEach((p) => (p.isNew = false))
await client.update(docUpdate, { txes: docUpdate.txes })
} else {
const doc = await client.findOne(_class, { _id })
if (doc !== undefined) {
@ -160,9 +162,9 @@ export async function hide (object: DocUpdates | DocUpdates[]): Promise<void> {
export async function markAsUnread (object: DocUpdates): Promise<void> {
const client = getClient()
if (object.txes.length > 0) return
if (object.lastTx !== undefined && object.lastTxTime !== undefined) {
await client.update(object, {
txes: [[object.lastTx, object.lastTxTime]]
})
}
const txes = object.txes
txes[0].isNew = true
await client.update(object, {
txes
})
}

View File

@ -150,6 +150,16 @@ export interface Collaborators extends Doc {
collaborators: Ref<Account>[]
}
/**
* @public
*/
export interface DocUpdateTx {
_id: Ref<TxCUD<Doc>>
modifiedBy: Ref<Account>
modifiedOn: Timestamp
isNew: boolean
}
/**
* @public
*/
@ -158,9 +168,8 @@ export interface DocUpdates extends Doc {
attachedTo: Ref<Doc>
attachedToClass: Ref<Class<Doc>>
hidden: boolean
lastTx?: Ref<TxCUD<Doc>>
lastTxTime?: Timestamp
txes: [Ref<TxCUD<Doc>>, Timestamp][]
txes: DocUpdateTx[]
}
/**

View File

@ -0,0 +1,51 @@
<!--
// 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 { getName } from '@hcengineering/contact'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Applicant, Candidate, Vacancy } from '@hcengineering/recruit'
import recruit from '../plugin'
export let value: Applicant
const spaceQuery = createQuery()
const candidateQuery = createQuery()
let currentVacancy: Vacancy | undefined = undefined
let candidate: Candidate | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
$: spaceQuery.query(recruit.class.Vacancy, { _id: value.space }, (res) => ([currentVacancy] = res))
const shortLabel = value && hierarchy.getClass(value._class).shortLabel
$: candidateQuery.query(recruit.mixin.Candidate, { _id: value.attachedTo }, (res) => ([candidate] = res))
$: title = `${shortLabel}-${value?.number}`
</script>
{#if value}
<div>
<div class="flex-presenter clear-mins inline-presenter mb-1">
<span class="font-medium mr-2">{title}</span>
<span class="overflow-label">
{currentVacancy?.name}
</span>
</div>
<div>
<span class="overflow-label">
{candidate ? getName(candidate) : ''}
</span>
</div>
</div>
{/if}

View File

@ -43,15 +43,8 @@ import EditVacancy from './components/EditVacancy.svelte'
import KanbanCard from './components/KanbanCard.svelte'
import MatchVacancy from './components/MatchVacancy.svelte'
import NewCandidateHeader from './components/NewCandidateHeader.svelte'
import NotificationApplicantPresenter from './components/NotificationApplicantPresenter.svelte'
import Organizations from './components/Organizations.svelte'
import CreateOpinion from './components/review/CreateOpinion.svelte'
import CreateReview from './components/review/CreateReview.svelte'
import EditReview from './components/review/EditReview.svelte'
import OpinionPresenter from './components/review/OpinionPresenter.svelte'
import Opinions from './components/review/Opinions.svelte'
import OpinionsPresenter from './components/review/OpinionsPresenter.svelte'
import ReviewPresenter from './components/review/ReviewPresenter.svelte'
import Reviews from './components/review/Reviews.svelte'
import SkillsView from './components/SkillsView.svelte'
import TemplatesIcon from './components/TemplatesIcon.svelte'
import Vacancies from './components/Vacancies.svelte'
@ -62,6 +55,14 @@ import VacancyList from './components/VacancyList.svelte'
import VacancyModifiedPresenter from './components/VacancyModifiedPresenter.svelte'
import VacancyPresenter from './components/VacancyPresenter.svelte'
import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte'
import CreateOpinion from './components/review/CreateOpinion.svelte'
import CreateReview from './components/review/CreateReview.svelte'
import EditReview from './components/review/EditReview.svelte'
import OpinionPresenter from './components/review/OpinionPresenter.svelte'
import Opinions from './components/review/Opinions.svelte'
import OpinionsPresenter from './components/review/OpinionsPresenter.svelte'
import ReviewPresenter from './components/review/ReviewPresenter.svelte'
import Reviews from './components/review/Reviews.svelte'
import recruit from './plugin'
import {
getAppTitle,
@ -317,7 +318,8 @@ export default async (): Promise<Resources> => ({
VacancyList,
VacancyTemplateEditor,
MatchVacancy
MatchVacancy,
NotificationApplicantPresenter
},
completion: {
ApplicationQuery: async (

View File

@ -13,41 +13,30 @@
// limitations under the License.
-->
<script lang="ts">
import { createQuery, statusStore } from '@hcengineering/presentation'
import { createQuery } 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))
$: 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 clear-mins">
<div class="flex-presenter inline-presenter mr-2">
{#if currentProject}
<div class="icon">
<Icon icon={currentProject.icon ?? tracker.icon.Home} size="small" />
</div>
<span class="label no-underline nowrap">
{currentProject.name}
</span>
{/if}
<span class="overflow-label ml-2">
<span class="caption-color">{title}</span>
{value.title}
<div class="w-full">
<div class="flex-presenter overflow-label clear-mins inline-presenter mb-1">
<span class="font-medium mr-2">{title}</span>
<span>
{currentProject?.name}
</span>
</div>
{#if status}
<IssueStatusIcon value={status} size="small" />
{/if}
<div class="overflow-label">
{value.title}
</div>
</div>
{/if}

View File

@ -15,12 +15,18 @@
<script lang="ts">
import { Class, Doc, Ref, Space } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Button, IconClose, eventToHTMLElement, resolvedLocationStore, showPopup } from '@hcengineering/ui'
import {
Button,
IconClose,
IconFilter,
eventToHTMLElement,
resolvedLocationStore,
showPopup
} from '@hcengineering/ui'
import { Filter } from '@hcengineering/view'
import { filterStore, getFilterKey, setFilters } from '../../filter'
import view from '../../plugin'
import FilterTypePopup from './FilterTypePopup.svelte'
import IconFilter from '../icons/Filter.svelte'
import IconArrowDown from '../icons/ArrowDown.svelte'
export let _class: Ref<Class<Doc>> | undefined

View File

@ -165,7 +165,7 @@
hidden: false
},
(res) => {
hasNotification = res.some((p) => p.txes.length > 0)
hasNotification = res.some((p) => p.txes.some((p) => p.isNew))
}
)

View File

@ -79,13 +79,17 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
$push: {
txes: [tx._id as Ref<TxCUD<Doc>>, tx.modifiedOn]
txes: {
_id: tx._id as Ref<TxCUD<Doc>>,
modifiedOn: tx.modifiedOn,
modifiedBy: tx.modifiedBy,
isNew: true
}
}
})
)
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
lastTx: tx._id as Ref<TxCUD<Doc>>,
lastTxTime: tx.modifiedOn,
hidden: false
})
@ -98,9 +102,10 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise
attachedTo: channel._id,
attachedToClass: channel._class,
hidden: false,
lastTx: tx._id as Ref<TxCUD<Doc>>,
lastTxTime: tx.modifiedOn,
txes: [[tx._id as Ref<TxCUD<Doc>>, tx.modifiedOn]]
txes: [
{ _id: tx._id as Ref<TxCUD<Doc>>, modifiedOn: tx.modifiedOn, modifiedBy: tx.modifiedBy, isNew: true }
]
})
)
}

View File

@ -437,22 +437,20 @@ function pushNotification (
attachedTo: object._id,
attachedToClass: object._class,
hidden: false,
lastTx: originTx._id,
lastTxTime: originTx.modifiedOn,
txes: [[originTx._id, originTx.modifiedOn]]
txes: [{ _id: originTx._id, modifiedOn: originTx.modifiedOn, modifiedBy: originTx.modifiedBy, isNew: true }]
})
)
} else {
res.push(
control.txFactory.createTxUpdateDoc(current._class, current.space, current._id, {
$push: {
txes: [originTx._id, originTx.modifiedOn]
txes: { _id: originTx._id, modifiedOn: originTx.modifiedOn, modifiedBy: originTx.modifiedBy, isNew: true }
}
})
)
res.push(
control.txFactory.createTxUpdateDoc(current._class, current.space, current._id, {
lastTx: originTx._id,
lastTxTime: originTx.modifiedOn,
hidden: false
})

View File

@ -77,13 +77,17 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
$push: {
txes: [tx._id as Ref<TxCUD<Doc>>, tx.modifiedOn]
txes: {
_id: tx._id as Ref<TxCUD<Doc>>,
modifiedOn: tx.modifiedOn,
modifiedBy: tx.modifiedBy,
isNew: true
}
}
})
)
res.push(
control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, {
lastTx: tx._id as Ref<TxCUD<Doc>>,
lastTxTime: tx.modifiedOn,
hidden: false
})
@ -96,9 +100,10 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise
attachedTo: channel._id,
attachedToClass: channel._class,
hidden: false,
lastTx: tx._id as Ref<TxCUD<Doc>>,
lastTxTime: tx.modifiedOn,
txes: [[tx._id as Ref<TxCUD<Doc>>, tx.modifiedOn]]
txes: [
{ _id: tx._id as Ref<TxCUD<Doc>>, modifiedOn: tx.modifiedOn, modifiedBy: tx.modifiedBy, isNew: true }
]
})
)
}