mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 03:14:40 +03:00
UBER-32 Inbox redesign (#3242)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
parent
e7ba92c764
commit
cf86b493c0
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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> {
|
||||
|
@ -2,6 +2,7 @@
|
||||
"extends": "./node_modules/@hcengineering/model-rig/profiles/default/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>,
|
||||
|
@ -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;
|
||||
|
@ -91,7 +91,6 @@
|
||||
user-select: none;
|
||||
|
||||
.list-item {
|
||||
margin: 0 0.5rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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]
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
42
plugins/chunter-resources/src/components/SpaceHeader.svelte
Normal file
42
plugins/chunter-resources/src/components/SpaceHeader.svelte
Normal 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}
|
@ -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]
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,11 @@
|
||||
"Change": "Изменено",
|
||||
"AddedRemoved": "Добавлено/удалено",
|
||||
"YouAddedCollaborators": "Вы были добавлены как участник",
|
||||
"ChangeCollaborators": "изменил(а) участники"
|
||||
"ChangeCollaborators": "изменил(а) участники",
|
||||
"Activity": "Активность",
|
||||
"People": "Люди",
|
||||
"All": "Все",
|
||||
"Read": "Прочитанное",
|
||||
"Unread": "Не прочитанное"
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
141
plugins/notification-resources/src/components/Activity.svelte
Normal file
141
plugins/notification-resources/src/components/Activity.svelte
Normal 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>
|
@ -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>
|
48
plugins/notification-resources/src/components/Filter.svelte
Normal file
48
plugins/notification-resources/src/components/Filter.svelte
Normal 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} />
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
161
plugins/notification-resources/src/components/People.svelte
Normal file
161
plugins/notification-resources/src/components/People.svelte
Normal 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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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}
|
@ -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 (
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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 }
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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 }
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user