UBERF-5315: update chat (#4572)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-02-07 20:13:40 +04:00 committed by GitHub
parent baa8eb3752
commit 643f34bf96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1337 additions and 216 deletions

View File

@ -425,7 +425,7 @@ export function createModel (builder: Builder, options = { addApplication: true
textProvider: chunter.function.GetLink
},
label: chunter.string.CopyLink,
icon: chunter.icon.Thread,
icon: chunter.icon.Copy,
keyBinding: [],
input: 'none',
category: chunter.category.Chunter,
@ -693,6 +693,14 @@ export function createModel (builder: Builder, options = { addApplication: true
ofMessage: activity.class.ActivityInfoMessage,
components: [{ kind: 'footer', component: chunter.component.Replies }]
})
builder.mixin(chunter.class.Channel, core.class.Class, chunter.mixin.ObjectChatPanel, {
ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description']
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, chunter.mixin.ObjectChatPanel, {
ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description']
})
}
export default chunter

View File

@ -58,6 +58,14 @@ export function createModel (builder: Builder): void {
trigger: serverChunter.trigger.ChunterTrigger
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverChunter.trigger.OnChannelMembersChanged,
txMatch: {
_class: core.class.TxUpdateDoc,
objectClass: chunter.class.Channel
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverChunter.trigger.OnDirectMessageSent,
txMatch: {

View File

@ -13,3 +13,13 @@ export function groupByArray<T, K> (array: T[], keyProvider: (item: T) => K): Ma
return result
}
export function flipSet<T> (set: Set<T>, item: T): Set<T> {
if (set.has(item)) {
set.delete(item)
} else {
set.add(item)
}
return set
}

View File

@ -29,6 +29,7 @@
"MakePrivate": "Make private",
"MakePrivateDescription": "Only members can see it",
"Created": "Created",
"NoResults": "No results to show"
"NoResults": "No results to show",
"Next": "Next"
}
}

View File

@ -29,6 +29,7 @@
"MakePrivate": "Сделать личным",
"MakePrivateDescription": "Только пользователи могут видеть это",
"Created": "Созданные",
"NoResults": "Нет результатов"
"NoResults": "Нет результатов",
"Next": "Далее"
}
}

View File

@ -70,7 +70,8 @@ export default plugin(presentationId, {
MakePrivateDescription: '' as IntlString,
OpenInANewTab: '' as IntlString,
Created: '' as IntlString,
NoResults: '' as IntlString
NoResults: '' as IntlString,
Next: '' as IntlString
},
metadata: {
RequiredVersion: '' as Metadata<string>,

View File

@ -749,6 +749,10 @@ input.search {
.square-36 { width: 2.25rem; height: 2.25rem; }
/* --------- */
.svg-xx-small {
width: .5rem;
height: .5rem;
}
.svg-tiny {
width: .75rem;
height: .75rem;
@ -789,7 +793,7 @@ input.search {
width: inherit;
height: inherit;
}
.svg-card, .svg-x-small, .svg-small, .svg-medium, .svg-large, .svg-x-large { flex-shrink: 0; }
.svg-card, .svg-xx-small, .svg-x-small, .svg-small, .svg-medium, .svg-large, .svg-x-large { flex-shrink: 0; }
.svg-mask {
position: absolute;

View File

@ -184,6 +184,11 @@
.hulyModal-container {
height: 100%;
border-top: 1px solid transparent;
visibility: visible;
&.hidden {
visibility: hidden;
}
.hulyModal-content {
height: 100%;

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import type { Asset, IntlString } from '@hcengineering/platform'
import { AnySvelteComponent, LabelAndProps } from '../types'
import { AnySvelteComponent, IconSize, LabelAndProps } from '../types'
import { tooltip as tp } from '../tooltips'
import { ComponentType } from 'svelte'
import Spinner from './Spinner.svelte'
@ -25,6 +25,7 @@
export let label: IntlString | undefined = undefined
export let labelParams: Record<string, any> = {}
export let icon: Asset | AnySvelteComponent | ComponentType | undefined = undefined
export let iconSize: IconSize | undefined = undefined
export let iconProps: any | undefined = undefined
export let kind: 'primary' | 'secondary' | 'tertiary' | 'negative'
export let size: 'large' | 'medium' | 'small' | 'extra-small'
@ -37,6 +38,14 @@
export let inheritFont: boolean = false
export let tooltip: LabelAndProps | undefined = undefined
export let element: HTMLButtonElement | undefined = undefined
let actualIconSize: IconSize = 'small'
$: if (iconSize) {
actualIconSize = iconSize
} else if (type === 'type-button' && !hasMenu) {
actualIconSize = 'medium'
}
</script>
<button
@ -54,7 +63,7 @@
{#if loading}
<div class="icon animate"><Spinner size={type === 'type-button' && !hasMenu ? 'medium' : 'small'} /></div>
{:else if icon}<div class="icon">
<Icon {icon} {iconProps} size={type === 'type-button' && !hasMenu ? 'medium' : 'small'} />
<Icon {icon} {iconProps} size={actualIconSize} />
</div>{/if}
{#if label}<span><Label {label} params={labelParams} /></span>{/if}
{#if title}<span>{title}</span>{/if}

View File

@ -29,6 +29,8 @@
export let onCancel: (() => void) | undefined = undefined
export let canSave: boolean = false
export let okLabel: IntlString = ui.string.Ok
export let padding: string | undefined = undefined
export let hidden = false
const dispatch = createEventDispatcher()
@ -39,11 +41,18 @@
function onKeyDown (ev: KeyboardEvent) {
if (ev.key === 'Escape') close()
}
$: typePadding =
type === 'type-popup'
? 'var(--spacing-2) var(--spacing-3) var(--spacing-4)'
: type === 'type-aside'
? 'var(--spacing-2) var(--spacing-1_5)'
: 'var(--spacing-3)'
</script>
<svelte:window on:keydown={onKeyDown} />
<div class="hulyModal-container {type}">
<div class="hulyModal-container {type}" class:hidden>
<Header {type} on:close={close}>
<Label {label} params={labelProps} />
<svelte:fragment slot="actions">
@ -52,11 +61,7 @@
</Header>
<div class="hulyModal-content">
<Scroller
padding={type === 'type-popup'
? 'var(--spacing-2) var(--spacing-3) var(--spacing-4)'
: type === 'type-aside'
? 'var(--spacing-2) var(--spacing-1_5)'
: 'var(--spacing-3)'}
padding={padding ?? typePadding}
bottomPadding={type === 'type-popup'
? undefined
: type === 'type-aside'

View File

@ -6,12 +6,14 @@
export let kind: 'separated' | 'separated-free' = 'separated'
export let expansion: 'stretch' | 'default' = 'default'
export let padding: string | undefined = undefined
export let notifyFor: IModeSelector['mode'][] = []
$: modeList = props.config.map((c) => {
return {
id: c[0],
labelIntl: c[1],
labelParams: c[2],
showNotify: notifyFor.includes(c[0]),
action: () => {
props.onChange(c[0])
}

View File

@ -5,7 +5,7 @@
//
import type { Asset, IntlString } from '@hcengineering/platform'
import { AnySvelteComponent } from '../types'
import { AnySvelteComponent, IconSize } from '../types'
import { ComponentType } from 'svelte'
import ButtonBase from './ButtonBase.svelte'
@ -15,6 +15,7 @@
export let kind: 'primary' | 'secondary' | 'tertiary' | 'negative' = 'secondary'
export let size: 'large' | 'medium' | 'small' = 'large'
export let icon: Asset | AnySvelteComponent | ComponentType | undefined = undefined
export let iconSize: IconSize | undefined = undefined
export let disabled: boolean = false
export let loading: boolean = false
export let hasMenu: boolean = false
@ -29,6 +30,7 @@
{kind}
{size}
{icon}
{iconSize}
{loading}
{disabled}
{hasMenu}

View File

@ -102,12 +102,16 @@
<div class="color" style:background-color={item.color} />
{/if}
{#if item.label || item.labelIntl}
<span class="overflow-label" class:ml-1-5={item.icon || item.color}>
<span class="flex-center overflow-label" class:ml-1-5={item.icon || item.color}>
{#if item.label}
{item.label}
{:else if item.labelIntl}
<Label label={item.labelIntl} params={item.labelParams} />
{/if}
{#if item.showNotify}
<div class="notifyMarker" />
{/if}
</span>
{/if}
</div>
@ -285,4 +289,12 @@
}
}
}
.notifyMarker {
width: 4px;
height: 4px;
border-radius: 1px;
background: var(--global-higlight-Color);
margin-left: 0.5rem;
}
</style>

View File

@ -20,10 +20,10 @@
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<svg class="svg-{size}" {fill} viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 6C9.20948 6 4 10.6272 4 16C4 18.1619 4.82539 20.1782 6.25857 21.8353C6.48585 22.0981 6.56044 22.46 6.45559 22.7913C6.11025 23.8822 5.59859 24.9049 4.96437 25.8562C6.36749 25.6496 7.72455 25.2236 8.98685 24.5982C9.26684 24.4595 9.59558 24.4596 9.87546 24.5986C11.6632 25.4861 13.7558 26 16 26C22.7905 26 28 21.3728 28 16C28 10.6272 22.7905 6 16 6ZM2 16C2 9.2225 8.43112 4 16 4C23.5689 4 30 9.2225 30 16C30 22.7775 23.5689 28 16 28C13.6319 28 11.3944 27.4963 9.43078 26.6016C7.4311 27.5144 5.24057 28 3 28C2.63121 28 2.29235 27.797 2.11833 27.4719C1.94431 27.1467 1.96338 26.7522 2.16795 26.4453L3.37487 24.6349C3.78033 24.0267 4.11535 23.3749 4.37375 22.692C2.88402 20.7925 2 18.4911 2 16Z"
d="M7 1C3.60474 1 1 3.31362 1 6C1 7.08097 1.4127 8.08912 2.12929 8.91766C2.24292 9.04906 2.28022 9.23002 2.22779 9.39564C2.05513 9.9411 1.7993 10.4525 1.48219 10.9281C2.18374 10.8248 2.86228 10.6118 3.49342 10.2991C3.63342 10.2297 3.79779 10.2298 3.93773 10.2993C4.83161 10.7431 5.87792 11 7 11C10.3953 11 13 8.68638 13 6C13 3.31362 10.3953 1 7 1ZM7.77784e-07 6C7.77784e-07 2.61125 3.21556 0 7 0C10.7844 0 14 2.61125 14 6C14 9.38875 10.7844 12 7 12C5.81593 12 4.69721 11.7481 3.71539 11.3008C2.71555 11.7572 1.62029 12 0.500001 12C0.315603 12 0.146173 11.8985 0.0591635 11.7359C-0.027846 11.5733 -0.0183098 11.3761 0.0839756 11.2226L0.687434 10.3175C0.890167 10.0134 1.05767 9.68744 1.18687 9.346C0.442008 8.39623 7.77784e-07 7.24553 7.77784e-07 6Z"
/>
</svg>

View File

@ -122,6 +122,7 @@ export interface TabItem {
icon?: Asset | AnySvelteComponent
color?: string
tooltip?: IntlString
showNotify?: boolean
action?: () => void
}

View File

@ -19,7 +19,7 @@
export let icon: Asset | AnySvelteComponent | ComponentType
export let iconProps: any | undefined = undefined
export let size: 'x-small' | 'small' | 'medium' | 'large' = 'medium'
export let size: 'x-small' | 'small' = 'small'
export let action: (ev: MouseEvent) => Promise<void> | void = async () => {}
export let opened = false
</script>
@ -30,8 +30,13 @@
<style lang="scss">
.action {
padding: 0.125rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
width: 1.5rem;
height: 1.5rem;
padding: 0.25rem;
&:hover {
color: var(--accent-color);

View File

@ -29,6 +29,6 @@
<ActivityMessageAction
icon={view.icon.Pin}
iconProps={object.isPinned ? { fill: '#3265cb' } : undefined}
iconProps={{ fill: object.isPinned ? '#3265cb' : 'currentColor' }}
action={toggleMessagePinning}
/>

View File

@ -13,13 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { ActionIcon } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { ActivityMessage, SavedMessage } from '@hcengineering/activity'
import preference from '@hcengineering/preference'
import Bookmark from './icons/Bookmark.svelte'
import BookmarkBorder from './icons/BookmarkBorder.svelte'
import ActivityMessageAction from './ActivityMessageAction.svelte'
import Bookmark from './icons/Bookmark.svelte'
export let object: ActivityMessage
@ -45,7 +45,8 @@
</script>
<ActivityMessageAction
icon={Bookmark}
iconProps={savedMessage ? { fill: '#3265cb' } : undefined}
icon={savedMessage ? Bookmark : BookmarkBorder}
size={savedMessage ? 'x-small' : 'small'}
iconProps={{ fill: savedMessage ? 'var(--global-accent-TextColor)' : 'currentColor' }}
action={toggleSaveMessage}
/>

View File

@ -21,7 +21,7 @@
import { Person } from '@hcengineering/contact'
import { Avatar, EmployeePresenter, SystemAvatar } from '@hcengineering/contact-resources'
import core, { getDisplayTime } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Action, Label } from '@hcengineering/ui'
import { getActions } from '@hcengineering/view-resources'
@ -30,6 +30,7 @@
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
import ActivityMessageActions from '../ActivityMessageActions.svelte'
import { isReactionMessage } from '../../activityMessagesUtils'
import Bookmark from '../icons/Bookmark.svelte'
export let message: DisplayActivityMessage
export let parentMessage: DisplayActivityMessage | undefined = undefined
@ -54,12 +55,20 @@
export let onReply: (() => void) | undefined = undefined
const client = getClient()
const savedMessageQuery = createQuery()
let allActionIds: string[] = []
let element: HTMLDivElement | undefined = undefined
let extensions: ActivityMessageExtension[] = []
let isActionsOpened = false
let isSaved = false
savedMessageQuery.query(activity.class.SavedMessage, { attachedTo: message._id }, (res) => {
isSaved = res.length > 0
})
$: withActions &&
getActions(client, message, activity.class.ActivityMessage).then((res) => {
allActionIds = res.map(({ _id }) => _id)
@ -119,7 +128,7 @@
<div class="notify" />
{/if}
{#if !embedded}
<div class="min-w-6 mt-1">
<div class="min-w-6 mt-1 relative">
{#if $$slots.icon}
<slot name="icon" />
{:else if person}
@ -127,6 +136,11 @@
{:else}
<SystemAvatar size="medium" />
{/if}
{#if isSaved}
<div class="saveMarker">
<Bookmark size="xx-small" />
</div>
{/if}
</div>
{:else}
<div class="embeddedMarker" />
@ -184,7 +198,7 @@
<style lang="scss">
@keyframes highlight {
50% {
background-color: var(--theme-warning-color);
background-color: var(--global-ui-highlight-BackgroundColor);
}
}
@ -204,7 +218,7 @@
}
&.highlighted {
animation: highlight 5000ms ease-in-out;
animation: highlight 3000ms ease-in-out;
}
&.selected {
@ -292,4 +306,21 @@
border-radius: 0.5rem;
background: var(--secondary-button-default);
}
.saveMarker {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: var(--spacing-1);
border-radius: 50%;
background: linear-gradient(0deg, var(--button-primary-BackgroundColor), var(--button-primary-BackgroundColor)),
linear-gradient(0deg, var(--global-ui-BackgroundColor), var(--global-ui-BackgroundColor));
border: 1px solid var(--global-ui-BackgroundColor);
top: -0.5rem;
left: -0.5rem;
color: var(--white-color);
}
</style>

View File

@ -13,12 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let size: 'tiny' | 'xx-small' | 'x-small' | 'small' | 'medium' | 'large' = 'small'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<svg class="svg-{size}" {fill} viewBox="0 0 10 14" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.2,18c-0.1,0-0.2,0-0.3-0.1l-6-4l-6,4c-0.2,0.1-0.4,0.1-0.5,0c-0.2-0.1-0.3-0.3-0.3-0.4V4.2 C3.2,3,4.3,2,5.5,2h8.9c1.3,0,2.3,1,2.3,2.2v13.3c0,0.2-0.1,0.4-0.3,0.4C16.4,18,16.3,18,16.2,18z M10,12.8c0.1,0,0.2,0,0.3,0.1 l5.5,3.6V4.2c0-0.6-0.6-1.2-1.3-1.2H5.5C4.8,3,4.2,3.5,4.2,4.2v12.4l5.5-3.6C9.8,12.9,9.9,12.8,10,12.8z"
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 2C0 0.895431 0.89543 0 2 0H8C9.10457 0 10 0.89543 10 2V13.5C10 13.6844 9.89851 13.8538 9.73593 13.9408C9.57335 14.0278 9.37608 14.0183 9.22265 13.916L5 11.1009L0.77735 13.916C0.623922 14.0183 0.42665 14.0278 0.264071 13.9408C0.101492 13.8538 0 13.6844 0 13.5V2Z"
/>
</svg>

View File

@ -0,0 +1,24 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.2,18c-0.1,0-0.2,0-0.3-0.1l-6-4l-6,4c-0.2,0.1-0.4,0.1-0.5,0c-0.2-0.1-0.3-0.3-0.3-0.4V4.2 C3.2,3,4.3,2,5.5,2h8.9c1.3,0,2.3,1,2.3,2.2v13.3c0,0.2-0.1,0.4-0.3,0.4C16.4,18,16.3,18,16.2,18z M10,12.8c0.1,0,0.2,0,0.3,0.1 l5.5,3.6V4.2c0-0.6-0.6-1.2-1.3-1.2H5.5C4.8,3,4.2,3.5,4.2,4.2v12.4l5.5-3.6C9.8,12.9,9.9,12.8,10,12.8z"
/>
</svg>

View File

@ -8,12 +8,16 @@
<symbol id="lock" viewBox="0 0 16 16">
<path d="M12,7.1h-0.7V5.4c0-1.8-1.5-3.3-3.3-3.3c-1.8,0-3.3,1.5-3.3,3.3v1.7H4c-0.8,0-1.5,0.7-1.5,1.5v4.1c0,0.8,0.7,1.5,1.5,1.5h8 c0.8,0,1.5-0.7,1.5-1.5V8.6C13.5,7.8,12.8,7.1,12,7.1z M5.7,5.4c0-1.2,1-2.3,2.3-2.3s2.3,1,2.3,2.3v1.7H5.7V5.4z M12.5,12.7 c0,0.3-0.2,0.5-0.5,0.5H4c-0.3,0-0.5-0.2-0.5-0.5V8.6c0-0.3,0.2-0.5,0.5-0.5h8c0.3,0,0.5,0.2,0.5,0.5V12.7z"/>
</symbol>
<symbol id="thread" viewBox="0 0 24 24">
<path d="M22.8,10.2c0-5-4.3-9-9.6-9s-9.6,4-9.6,9c0,0.1,0,0.3,0,0.4c-1.5,1.1-2.4,2.8-2.4,4.6c0,1.7,0.8,3.3,2.2,4.4 l-0.2,1.5c-0.1,0.5,0.2,1,0.6,1.3c0.2,0.1,0.5,0.2,0.7,0.2c0.2,0,0.5-0.1,0.7-0.2l2.4-1.4c1.7-0.1,3.3-0.8,4.4-1.9 c0.3,0,0.6,0.1,0.8,0.1l4,2.4c0.3,0.2,0.6,0.3,0.9,0.3c0.3,0,0.7-0.1,0.9-0.3c0.6-0.4,0.9-1,0.8-1.7l-0.3-2.7 C21.5,15.5,22.8,13,22.8,10.2z M7.5,19.6c-0.1,0-0.3,0-0.4,0.1l-2.4,1.4l0.2-1.7c0-0.3-0.1-0.6-0.3-0.7c-1.2-0.8-1.9-2.1-1.9-3.5 c0-1.4,0.8-2.8,2-3.6c0.8-0.5,1.7-0.8,2.7-0.8c2.3,0,4.2,1.5,4.7,3.5c0.1,0.3,0.1,0.6,0.1,0.9c0,0.2,0,0.5-0.1,0.7c0,0,0,0.1,0,0.1 c-0.1,0.7-0.4,1.3-0.9,1.9C10.4,19,9,19.6,7.5,19.6z M18,16.2c-0.2,0.2-0.3,0.4-0.3,0.7l0.4,3.2c0,0.1-0.1,0.2-0.1,0.2 c-0.1,0-0.1,0.1-0.3,0l-4.2-2.5c-0.1-0.1-0.3-0.1-0.4-0.1c0.1-0.2,0.2-0.3,0.2-0.5c0,0,0,0,0-0.1c0.1-0.3,0.2-0.6,0.2-0.8 c0-0.1,0-0.1,0-0.2c0-0.3,0.1-0.6,0.1-0.9c0-0.2,0-0.4,0-0.6c-0.3-3-3-5.3-6.2-5.3c-0.3,0-0.6,0-0.8,0.1c-0.1,0-0.1,0-0.2,0 c-0.3,0-0.5,0.1-0.8,0.2c0,0,0,0-0.1,0C5.4,9.7,5.3,9.7,5.1,9.8c0.3-3.9,3.8-7,8.1-7c4.5,0,8.1,3.4,8.1,7.5 C21.2,12.6,20.1,14.8,18,16.2z" />
<symbol id="thread" viewBox="0 0 14 12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 1C3.60474 1 1 3.31362 1 6C1 7.08097 1.4127 8.08912 2.12929 8.91766C2.24292 9.04906 2.28022 9.23002 2.22779 9.39564C2.05513 9.9411 1.7993 10.4525 1.48219 10.9281C2.18374 10.8248 2.86228 10.6118 3.49342 10.2991C3.63342 10.2297 3.79779 10.2298 3.93773 10.2993C4.83161 10.7431 5.87792 11 7 11C10.3953 11 13 8.68638 13 6C13 3.31362 10.3953 1 7 1ZM7.77784e-07 6C7.77784e-07 2.61125 3.21556 0 7 0C10.7844 0 14 2.61125 14 6C14 9.38875 10.7844 12 7 12C5.81593 12 4.69721 11.7481 3.71539 11.3008C2.71555 11.7572 1.62029 12 0.500001 12C0.315603 12 0.146173 11.8985 0.0591635 11.7359C-0.027846 11.5733 -0.0183098 11.3761 0.0839756 11.2226L0.687434 10.3175C0.890167 10.0134 1.05767 9.68744 1.18687 9.346C0.442008 8.39623 7.77784e-07 7.24553 7.77784e-07 6Z"/>
</symbol>
<symbol id="channelbrowser" viewBox="0 0 24 24">
<path d="M12.4,20.2H8.6c-4.3,0-5.9-1.6-5.9-5.8V8.7c0-4.4,1.5-6,5.9-6h5.7c4.4,0,6,1.6,6,6v3.7c0,0.4,0.3,0.7,0.8,0.7 s0.8-0.3,0.8-0.7V8.7c0-5.2-2.2-7.5-7.5-7.5H8.6c-5.2,0-7.4,2.2-7.4,7.5v5.7c0,5.1,2.3,7.3,7.4,7.3h3.8c0.4,0,0.7-0.3,0.7-0.8 S12.8,20.2,12.4,20.2z" />
<path d="M22.2,21.4l-1-1c0,0,0,0-0.1,0c0.4-0.6,0.7-1.4,0.7-2.2c0-2.2-1.7-3.9-3.9-3.9s-4,1.7-4,3.9c0,2.1,1.8,4,4,4 c0.8,0,1.6-0.2,2.2-0.7c0,0,0,0,0,0.1l1,1c0.1,0.1,0.3,0.2,0.5,0.2s0.4-0.1,0.5-0.2C22.5,22.1,22.5,21.7,22.2,21.4z M17.9,20.5 c-1.3,0-2.5-1.2-2.5-2.5c0-1.4,1.1-2.4,2.5-2.4s2.4,1.1,2.4,2.4S19.3,20.5,17.9,20.5z" />
<path d="M16.3,9.5c0.4,0,0.8-0.3,0.8-0.8S16.7,8,16.3,8h-2.9l0.3-2.9c0-0.4-0.3-0.8-0.7-0.8c-0.4,0-0.8,0.3-0.8,0.7 l-0.3,3H9.7L10,5.1c0-0.4-0.3-0.8-0.7-0.8C9,4.2,8.6,4.5,8.6,4.9L8.2,8H5.3C4.9,8,4.5,8.3,4.5,8.7s0.3,0.8,0.8,0.8h2.8l-0.2,2.1 H4.7c-0.4,0-0.8,0.3-0.8,0.8S4.3,13,4.7,13h3l-0.3,2.9c0,0.4,0.3,0.8,0.7,0.8c0,0,0.1,0,0.1,0c0.4,0,0.7-0.3,0.7-0.7l0.3-3h2.2 l-0.3,2.9c0,0.4,0.3,0.8,0.7,0.8c0,0,0.1,0,0.1,0c0.4,0,0.7-0.3,0.7-0.7l0.3-3h2.8c0.4,0,0.8-0.3,0.8-0.8s-0.3-0.8-0.8-0.8H13 l0.2-2.1H16.3z M11.5,11.5H9.3l0.2-2.1h2.2L11.5,11.5z" />
</symbol>
<symbol id="copy" viewBox="0 0 12 14">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 2C3 0.89543 3.89543 0 5 0H10C11.1046 0 12 0.895431 12 2V9C12 10.1046 11.1046 11 10 11H5C3.89543 11 3 10.1046 3 9V2ZM5 1C4.44772 1 4 1.44772 4 2V9C4 9.55228 4.44772 10 5 10H10C10.5523 10 11 9.55229 11 9V2C11 1.44772 10.5523 1 10 1H5Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C1.44772 4 1 4.44772 1 5V12C1 12.5523 1.44772 13 2 13H7C7.55228 13 8 12.5523 8 12V11H9V12C9 13.1046 8.10457 14 7 14H2C0.895431 14 0 13.1046 0 12V5C0 3.89543 0.895431 3 2 3H3V4H2Z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -91,6 +91,9 @@
"DescriptionOptional": "Description (optional)",
"Visibility": "Visibility",
"Public": "Public",
"Private": "Private"
"Private": "Private",
"NewDirectChat": "New direct chat",
"AddMembers": "Add members",
"PinnedCount": "{count} pinned"
}
}

View File

@ -91,6 +91,9 @@
"DescriptionOptional": "Описание (необязательно)",
"Visibility": "Видимость",
"Public": "Публичный",
"Private": "Закрытый"
"Private": "Закрытый",
"NewDirectChat": "Новый личный чат",
"AddMembers": "Добавить участников",
"PinnedCount": "{count} закреплено"
}
}

View File

@ -22,5 +22,6 @@ loadMetadata(chunter.icon, {
Hashtag: `${icons}#hashtag`,
Thread: `${icons}#thread`,
Lock: `${icons}#lock`,
ChannelBrowser: `${icons}#channelbrowser`
ChannelBrowser: `${icons}#channelbrowser`,
Copy: `${icons}#copy`
})

View File

@ -19,10 +19,12 @@
import { Channel } from '@hcengineering/chunter'
import { ActivityMessagesFilter } from '@hcengineering/activity'
import contact from '@hcengineering/contact'
import { DocNotifyContext } from '@hcengineering/notification'
import Header from './Header.svelte'
import chunter from '../plugin'
import { getChannelIcon, getChannelName } from '../utils'
import PinnedMessages from './PinnedMessages.svelte'
export let _id: Ref<Doc>
export let _class: Ref<Class<Doc>>
@ -76,7 +78,9 @@
{isAsideShown}
on:aside-toggled
on:close
/>
>
<PinnedMessages {_id} {_class} />
</Header>
</div>
<style lang="scss">

View File

@ -0,0 +1,119 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Person } from '@hcengineering/contact'
import { ButtonIcon, IconDelete, ModernButton, Scroller } from '@hcengineering/ui'
import { IconAddMember, personByIdStore, UserDetails } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { createEventDispatcher } from 'svelte'
import chunter from '../plugin'
export let ids: Ref<Person>[] = []
export let disableRemoveFor: Ref<Person>[] = []
const dispatch = createEventDispatcher()
let persons: Person[] = []
$: updatePersons(ids)
function updatePersons (ids: Ref<Person>[]) {
persons = ids.map((_id) => $personByIdStore.get(_id)).filter((person): person is Person => !!person)
}
</script>
<div class="root">
<div class="item" style:padding="var(--spacing-1_5)" class:withoutBorder={persons.length === 0}>
<ModernButton
label={chunter.string.AddMembers}
icon={IconAddMember}
iconSize="small"
kind="secondary"
size="small"
on:click={() => dispatch('add')}
/>
</div>
<Scroller>
{#each persons as person, index}
<div class="item" class:withoutBorder={index === persons.length - 1}>
<div class="item__content" class:disabled={disableRemoveFor.includes(person._id)}>
<UserDetails {person} />
{#if !disableRemoveFor.includes(person._id)}
<div class="item__action">
<ButtonIcon
icon={IconDelete}
size="small"
on:click={() => {
dispatch('remove', person._id)
}}
/>
</div>
{/if}
</div>
</div>
{/each}
</Scroller>
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
padding: 1px;
border-radius: 0.75rem;
background: var(--global-ui-highlight-BackgroundColor);
border: 1px solid var(--global-ui-BorderColor);
max-height: 30rem;
}
.item {
padding: var(--spacing-0_75);
border-bottom: 1px solid var(--global-ui-BorderColor);
&.withoutBorder {
border: 0;
}
.item__content {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-0_75);
border-radius: var(--small-BorderRadius);
cursor: pointer;
&.disabled {
cursor: default;
}
&:hover {
background: var(--global-ui-highlight-BackgroundColor);
.item__action {
visibility: visible;
}
}
}
.item__action {
visibility: hidden;
&:hover {
visibility: visible;
}
}
}
</style>

View File

@ -19,12 +19,13 @@
import activity, { ActivityMessage, ActivityMessagesFilter } from '@hcengineering/activity'
import { createQuery, getClient } from '@hcengineering/presentation'
import { combineActivityMessages } from '@hcengineering/activity-resources'
import { Channel } from '@hcengineering/chunter'
import Channel from './Channel.svelte'
import PinnedMessages from './PinnedMessages.svelte'
import ChannelComponent from './Channel.svelte'
import ChannelHeader from './ChannelHeader.svelte'
import DocChatPanel from './chat/DocChatPanel.svelte'
import DocAside from './chat/DocAside.svelte'
import chunter from '../plugin'
import ChannelAside from './chat/ChannelAside.svelte'
export let context: DocNotifyContext
export let object: Doc | undefined = undefined
@ -47,7 +48,8 @@
})
$: isDocChat = !hierarchy.isDerived(context.attachedToClass, chunter.class.ChunterSpace)
$: withAside = !embedded && isDocChat && !isThreadOpened
$: withAside =
!embedded && !isThreadOpened && !hierarchy.isDerived(context.attachedToClass, chunter.class.DirectMessage)
$: updateMessagesQuery(isDocChat ? activity.class.ActivityMessage : chunter.class.ChatMessage, context.attachedTo)
@ -75,6 +77,14 @@
}
}
function toChannel (object?: Doc) {
return object as Channel | undefined
}
function toChannelRef (ref: Ref<Doc>) {
return ref as Ref<Channel>
}
defineSeparators('aside', panelSeparators)
</script>
@ -99,8 +109,7 @@
<Loading />
{:else}
<div class="popupPanel-body__main">
<PinnedMessages {context} />
<Channel {context} {object} {filters} messages={activityMessages} />
<ChannelComponent {context} {object} {filters} messages={activityMessages} />
</div>
{#if withAside && isAsideShown}
@ -108,7 +117,15 @@
<div class="popupPanel-body__aside" class:float={false} class:shown={withAside && isAsideShown}>
<Separator name="aside" float index={0} />
<div class="antiPanel-wrap__content">
<DocChatPanel _id={context.attachedTo} _class={context.attachedToClass} {object} />
{#if hierarchy.isDerived(context.attachedToClass, chunter.class.Channel)}
<ChannelAside
_id={toChannelRef(context.attachedTo)}
_class={context.attachedToClass}
object={toChannel(object)}
/>
{:else}
<DocAside _id={context.attachedTo} _class={context.attachedToClass} {object} />
{/if}
</div>
</div>
{/if}

View File

@ -73,8 +73,11 @@
<div class="antiHSpacer x2" />
{/if}
{#if withFilters}
<ChannelMessagesFilter bind:selectedFilters={filters} />
<div class="mr-2">
<ChannelMessagesFilter bind:selectedFilters={filters} />
</div>
{/if}
<slot />
{#if titleKind === 'breadcrumbs'}
<Breadcrumbs
items={[

View File

@ -13,15 +13,18 @@
// limitations under the License.
-->
<script lang="ts">
import { eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import { eventToHTMLElement, Label, ModernButton, showPopup } from '@hcengineering/ui'
import PinnedMessagesPopup from './PinnedMessagesPopup.svelte'
import { createQuery } from '@hcengineering/presentation'
import { DocNotifyContext } from '@hcengineering/notification'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { Class, Doc, Ref } from '@hcengineering/core'
import view from '@hcengineering/view'
import chunter from '../plugin'
export let context: DocNotifyContext
export let _class: Ref<Class<Doc>>
export let _id: Ref<Doc>
const pinnedQuery = createQuery()
@ -29,28 +32,25 @@
$: pinnedQuery.query(
activity.class.ActivityMessage,
{ attachedTo: context.attachedTo, isPinned: true },
{ attachedTo: _id, isPinned: true },
(res: ActivityMessage[]) => {
pinnedMessagesCount = res.length
}
)
function openMessagesPopup (ev: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) {
showPopup(
PinnedMessagesPopup,
{ attachedTo: context.attachedTo, attachedToClass: context.attachedToClass },
eventToHTMLElement(ev)
)
function openMessagesPopup (ev: MouseEvent) {
showPopup(PinnedMessagesPopup, { attachedTo: _id, attachedToClass: _class }, eventToHTMLElement(ev))
}
</script>
{#if pinnedMessagesCount > 0}
<div class="bottom-divider over-underline pt-2 pb-2 container">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div on:click={openMessagesPopup}>
<Label label={chunter.string.Pinned} />
{pinnedMessagesCount}
</div>
<div class="mr-2">
<ModernButton
icon={view.icon.Pin}
size="small"
label={chunter.string.PinnedCount}
labelParams={{ count: pinnedMessagesCount }}
on:click={openMessagesPopup}
/>
</div>
{/if}

View File

@ -13,22 +13,31 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, Ref } from '@hcengineering/core'
import { Class, Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { ActivityMessagePresenter } from '@hcengineering/activity-resources'
import { ActionIcon, IconClose } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import chunter from '../plugin'
export let attachedTo: Ref<Doc>
export let attachedToClass: Ref<Class<Doc>>
const client = getClient()
const hierarchy = client.getHierarchy()
const messagesQuery = createQuery()
const dispatch = createEventDispatcher()
let pinnedMessages: DisplayActivityMessage[] = []
$: messagesQuery.query(activity.class.ActivityMessage, { attachedTo, isPinned: true }, (res: ActivityMessage[]) => {
pinnedMessages = res as DisplayActivityMessage[]
if (pinnedMessages.length === 0) {
dispatch('close')
}
})
async function unPinMessaage (message: ActivityMessage): Promise<void> {
@ -42,11 +51,12 @@
<ActivityMessagePresenter
value={message}
withActions={false}
skipLabel={!hierarchy.isDerived(message._class, activity.class.DocUpdateMessage)}
hoverable={false}
skipLabel={!hierarchy.isDerived(attachedToClass, chunter.class.ChunterSpace)}
/>
<div class="remove">
<div class="actions">
<ActionIcon
size="medium"
size="small"
icon={IconClose}
action={() => {
unPinMessaage(message)
@ -58,20 +68,38 @@
</div>
<style lang="scss">
.message {
min-width: 30rem;
border-radius: var(--medium-BorderRadius);
.actions {
visibility: hidden;
position: absolute;
top: -0.5rem;
right: 0.85rem;
box-shadow: 0.25rem 0.75rem 1rem 0.125rem var(--global-popover-ShadowColor);
border: 1px solid var(--global-subtle-ui-BorderColor);
background: linear-gradient(0deg, var(--global-surface-01-BorderColor), var(--global-surface-01-BorderColor)),
linear-gradient(0deg, var(--global-ui-BackgroundColor), var(--global-ui-BackgroundColor));
padding: var(--spacing-0_5);
border-radius: var(--small-BorderRadius);
}
&:hover > .actions {
visibility: visible;
}
&:hover {
background-color: var(--global-ui-BackgroundColor);
}
}
.antiPopup {
max-width: 40rem;
}
.message {
min-width: 30rem;
}
.popup {
padding: 1rem;
max-height: 20rem;
max-height: 24.5rem;
color: var(--caption-color);
}
.remove {
position: absolute;
top: 0.75rem;
right: 0.75rem;
}
</style>

View File

@ -0,0 +1,154 @@
<!--
// Copyright © 2024 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 core, { Account, Class, getCurrentAccount, Ref } from '@hcengineering/core'
import presentation from '@hcengineering/presentation'
import { Label, showPopup, tooltip } from '@hcengineering/ui'
import { Channel, ChunterSpace } from '@hcengineering/chunter'
import { Person, PersonAccount } from '@hcengineering/contact'
import { EmployeeBox, personAccountByIdStore, SelectUsersPopup } from '@hcengineering/contact-resources'
import ChannelMembers from '../ChannelMembers.svelte'
import DocAside from './DocAside.svelte'
import { joinChannel, leaveChannel } from '../../utils'
export let _id: Ref<ChunterSpace>
export let _class: Ref<Class<ChunterSpace>>
export let object: ChunterSpace | undefined
const currentAccount = getCurrentAccount()
let members: Ref<Person>[] = []
$: creatorPersonRef = object?.createdBy
? $personAccountByIdStore.get(object.createdBy as Ref<PersonAccount>)?.person
: undefined
$: disabledRemoveFor = currentAccount._id !== object?.createdBy && creatorPersonRef ? [creatorPersonRef] : []
$: updateMembers(object)
function updateMembers (object: Channel | undefined) {
if (object === undefined) {
members = []
return
}
members = object.members
.map((accountId) => {
const personAccount = $personAccountByIdStore.get(accountId as Ref<PersonAccount>)
if (personAccount === undefined) {
return undefined
}
return personAccount.person
})
.filter((_id): _id is Ref<Person> => !!_id)
}
async function changeMembers (personRefs: Ref<Person>[], object?: Channel) {
if (object === undefined) {
return
}
const personAccounts = Array.from($personAccountByIdStore.values())
const newMembers: Ref<Account>[] = personAccounts
.filter(({ person }) => personRefs.includes(person))
.map(({ _id }) => _id)
const toLeave = object.members.filter((_id) => !newMembers.includes(_id))
const toJoin = newMembers.filter((_id) => !object.members.includes(_id))
await Promise.all([leaveChannel(object, toLeave), joinChannel(object, toJoin)])
}
async function removeMember (ev: CustomEvent) {
if (object === undefined) {
return
}
const personId = ev.detail as Ref<Person> | undefined
if (personId === undefined) {
return
}
const personAccount = Array.from($personAccountByIdStore.values()).find((account) => account.person === personId)
if (personAccount === undefined) {
return
}
await leaveChannel(object, personAccount._id)
}
function openSelectUsersPopup () {
showPopup(
SelectUsersPopup,
{
okLabel: presentation.string.Add,
disableDeselectFor: disabledRemoveFor,
selected: members
},
'top',
(result?: Ref<Person>[]) => {
if (result != null) {
changeMembers(result, object)
}
}
)
}
</script>
<DocAside {_id} {_class} {object}>
{#if object && creatorPersonRef}
<div class="popupPanel-body__aside-grid" style:margin-top="0">
<span
class="labelOnPanel"
use:tooltip={{
component: Label,
props: { label: core.string.CreatedBy }
}}><Label label={core.string.CreatedBy} /></span
>
<div class="flex flex-grow min-w-0">
<EmployeeBox
value={creatorPersonRef}
label={core.string.CreatedBy}
kind={'link'}
size={'medium'}
avatarSize={'card'}
width={'100%'}
showNavigate={false}
readonly
/>
</div>
</div>
{/if}
<div class="members">
<ChannelMembers
ids={members}
disableRemoveFor={disabledRemoveFor}
on:add={openSelectUsersPopup}
on:remove={removeMember}
/>
</div>
</DocAside>
<style lang="scss">
.members {
padding: var(--spacing-2);
}
</style>

View File

@ -125,7 +125,6 @@
: 'landscape'} background-comp-header-color"
>
<div class="antiPanel-wrap__content">
<NavHeader label={chunter.string.Chat} />
<ChatNavigator
{selectedContextId}
selectedObjectClass={selectedContext?.attachedToClass}

View File

@ -41,20 +41,10 @@
</script>
<Scroller>
<div class="header">
<div class="identifier">
<Label label={clazz.label} />
{#if objectLinkTitle}
<DocNavLink {object}>
{objectLinkTitle}
</DocNavLink>
{/if}
</div>
</div>
{#if object}
<DocAttributeBar {object} {mixins} ignoreKeys={objectChatPanel?.ignoreKeys ?? []} showHeader={false} />
{/if}
<slot />
</Scroller>
<style lang="scss">

View File

@ -67,9 +67,9 @@
hidden: false
})
const navigate = await getResource(chunter.actionImpl.OpenChannel)
const openChannelFn = await getResource(chunter.actionImpl.OpenChannel)
await navigate(undefined, undefined, { _id: notifyContextId })
await openChannelFn(undefined, undefined, { _id: notifyContextId, mode: 'channels' })
}
function handleCancel () {

View File

@ -13,24 +13,42 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
import { deepEqual } from 'fast-equals'
import { DirectMessage } from '@hcengineering/chunter'
import contact, { Employee } from '@hcengineering/contact'
import contact, { Employee, PersonAccount } from '@hcengineering/contact'
import core, { getCurrentAccount, Ref } from '@hcengineering/core'
import { getClient, SpaceCreateCard } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import { UserBoxList } from '@hcengineering/contact-resources'
import { SelectUsersPopup } from '@hcengineering/contact-resources'
import notification, { DocNotifyContext } from '@hcengineering/notification'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Modal, showPopup } from '@hcengineering/ui'
import chunter from '../../../plugin'
import { buildDmName } from '../../../utils'
import ChannelMembers from '../../ChannelMembers.svelte'
const dispatch = createEventDispatcher()
const client = getClient()
const myAccId = getCurrentAccount()._id
const query = createQuery()
let employeeIds: Ref<Employee>[] = []
let dmName = ''
let accounts: PersonAccount[] = []
let hidden = true
$: loadDmName(accounts).then((r) => {
dmName = r
})
$: query.query(contact.class.PersonAccount, { person: { $in: employeeIds } }, (res) => {
accounts = res
})
async function loadDmName (employeeAccounts: PersonAccount[]) {
return await buildDmName(client, employeeAccounts)
}
async function createDirectMessage () {
const employeeAccounts = await client.findAll(contact.class.PersonAccount, { person: { $in: employeeIds } })
@ -73,17 +91,74 @@
hidden: false
})
await navigate(undefined, undefined, { _id: notifyContextId })
await navigate(undefined, undefined, { _id: notifyContextId, mode: 'direct' })
}
function handleCancel () {
dispatch('close')
}
onMount(() => {
openSelectUsersPopup(true)
})
function addMembersClicked () {
openSelectUsersPopup(false)
}
function openSelectUsersPopup (closeOnClose: boolean) {
showPopup(
SelectUsersPopup,
{
okLabel: presentation.string.Next,
skipCurrentAccount: true,
selected: employeeIds
},
'top',
(result?: Ref<Employee>[]) => {
if (result != null) {
employeeIds = result
hidden = false
} else if (closeOnClose) {
dispatch('close')
}
}
)
hidden = true
}
</script>
<SpaceCreateCard
label={chunter.string.NewDirectMessage}
<Modal
label={chunter.string.NewDirectChat}
type="type-popup"
{hidden}
okLabel={presentation.string.Create}
okAction={createDirectMessage}
canSave={employeeIds.length > 0}
on:close={() => {
dispatch('close')
}}
onCancel={handleCancel}
on:close
>
<UserBoxList label={chunter.string.Members} on:update={(evt) => (employeeIds = evt.detail)} />
</SpaceCreateCard>
<div class="hulyModal-content__titleGroup" style="padding: 0">
<div class="title overflow-label mb-4" title={dmName}>
{dmName}
</div>
<ChannelMembers
ids={employeeIds}
on:add={addMembersClicked}
on:remove={(ev) => {
employeeIds = employeeIds.filter((id) => id !== ev.detail)
}}
/>
</div>
</Modal>
<style lang="scss">
.title {
font-size: 1.25rem;
font-weight: 500;
padding: var(--spacing-1);
max-width: 40rem;
color: var(--global-primary-TextColor);
}
</style>

View File

@ -72,25 +72,6 @@
}
)
function getGroupActions (): Action[] {
const result: Action[] = []
if (model.addLabel !== undefined && model.addComponent !== undefined) {
result.push({
label: model.addLabel,
icon: IconAdd,
action: async (_id: Ref<Doc>): Promise<void> => {
dispatch('open')
if (model.addComponent !== undefined) {
showPopup(model.addComponent, {}, 'top')
}
}
})
}
return result
}
function getPinnedActions (): Action[] {
return [
{
@ -148,7 +129,7 @@
{/if}
<div class="block">
<ChatGroupHeader header={model.label} actions={getGroupActions()} />
<ChatGroupHeader header={model.label} />
{#each contexts as context (context._id)}
<ChatNavItem {context} isSelected={selectedContextId === context._id} on:select />
{/each}

View File

@ -21,15 +21,22 @@
navigate,
location as locationStore,
Scroller,
SearchEdit
SearchEdit,
Label,
Button,
IconAdd,
showPopup,
Menu,
Action
} from '@hcengineering/ui'
import { DocNotifyContext } from '@hcengineering/notification'
import { DocNotifyContext, InboxNotification } from '@hcengineering/notification'
import { SpecialNavModel } from '@hcengineering/workbench'
import { NavLink } from '@hcengineering/view-resources'
import { TreeSeparator } from '@hcengineering/workbench-resources'
import { getResource, type IntlString } from '@hcengineering/platform'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getNotificationsCount, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getClient } from '@hcengineering/presentation'
import activity from '@hcengineering/activity'
import chunter from '../../../plugin'
import ChatNavGroup from './ChatNavGroup.svelte'
@ -47,6 +54,26 @@
const hierarchy = client.getHierarchy()
const notificationClient = InboxNotificationsClientImpl.getClient()
const contextsStore = notificationClient.docNotifyContexts
const notificationsByContextStore = notificationClient.inboxNotificationsByContext
const actions = [
{
label: chunter.string.NewChannel,
icon: chunter.icon.Hashtag,
action: async (_id: Ref<Doc>): Promise<void> => {
showPopup(chunter.component.CreateChannel, {}, 'top')
}
},
{
label: chunter.string.NewDirectChat,
icon: chunter.icon.Thread,
action: async (_id: Ref<Doc>): Promise<void> => {
showPopup(chunter.component.CreateDirectChat, {}, 'top')
}
}
]
const searchValue: string = ''
const modesConfig: Array<[Mode, IntlString, object]> = chatNavGroupsModel.map(({ id, tabLabel }) => [
id,
@ -56,8 +83,7 @@
let modeSelectorProps: IModeSelector
let mode: Mode | undefined
const searchValue: string = ''
let notifyModes: Mode[] = []
$: mode = ($locationStore.query?.mode ?? undefined) as Mode | undefined
@ -73,6 +99,10 @@
}
}
$: getModesWithNotifications($contextsStore, $notificationsByContextStore).then((res) => {
notifyModes = res
})
$: updateSelectedContextMode(selectedObjectClass)
$: model = chatNavGroupsModel.find(({ id }) => id === mode) ?? chatNavGroupsModel[0]
@ -115,9 +145,63 @@
return await getIsVisible(docNotifyContexts as any)
}
async function addButtonClicked (ev: MouseEvent) {
showPopup(Menu, { actions }, ev.target as HTMLElement)
}
async function getModesWithNotifications (
contexts: DocNotifyContext[],
inboxNotificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>
) {
const contextIds = Array.from(inboxNotificationsByContext.keys())
const modes: Mode[] = []
for (const contextId of contextIds) {
if (modes.length === 3) {
break
}
const context = contexts.find(({ _id }) => _id === contextId)
if (
context === undefined ||
hierarchy.classHierarchyMixin(context.attachedToClass, activity.mixin.ActivityDoc) === undefined
) {
continue
}
let tmpMode: Mode = 'activity'
if (hierarchy.isDerived(context.attachedToClass, chunter.class.Channel)) {
tmpMode = 'channels'
} else if (hierarchy.isDerived(context.attachedToClass, chunter.class.DirectMessage)) {
tmpMode = 'direct'
}
if (modes.includes(tmpMode)) {
continue
}
const notificationsCount = await getNotificationsCount(context, inboxNotificationsByContext.get(contextId))
if (notificationsCount > 0) {
modes.push(tmpMode)
}
}
return modes
}
</script>
<Scroller shrink>
<div class="header">
<div class="overflow-label">
<Label label={chunter.string.Chat} />
</div>
<Button icon={IconAdd} kind="primary" size="medium" iconProps={{ size: 'small' }} on:click={addButtonClicked} />
</div>
{#each chatSpecials as special, row}
{#if row > 0 && chatSpecials[row].position !== chatSpecials[row - 1].position}
<TreeSeparator line />
@ -145,12 +229,28 @@
/>
</div>
<ModeSelector props={modeSelectorProps} kind="separated-free" padding="0" expansion="stretch" />
<ModeSelector
props={modeSelectorProps}
kind="separated-free"
padding="0"
expansion="stretch"
notifyFor={notifyModes}
/>
<ChatNavGroup {selectedContextId} {model} on:select />
<div class="antiNav-space" />
</Scroller>
<style lang="scss">
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0.75rem;
margin-left: 1.25rem;
font-weight: 700;
font-size: 1.25rem;
color: var(--theme-content-color);
}
.search {
padding: 12px;
}

View File

@ -187,9 +187,11 @@
color: var(--global-primary-TextColor);
&.withBackground {
background-color: var(--global-ui-highlight-BackgroundColor);
background: linear-gradient(0deg, var(--global-subtle-ui-BorderColor), var(--global-subtle-ui-BorderColor)),
linear-gradient(0deg, var(--global-ui-BackgroundColor), var(--global-ui-BackgroundColor));
padding: 0.375rem;
border-radius: 0.25rem;
border: 1px solid var(--global-subtle-ui-BorderColor);
}
}

View File

@ -38,10 +38,15 @@
} from '@hcengineering/ui'
import { FilterBar, FilterButton, SpacePresenter } from '@hcengineering/view-resources'
import workbench from '@hcengineering/workbench'
import { Channel } from '@hcengineering/chunter'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { get } from 'svelte/store'
import notification from '@hcengineering/notification'
import { getChannelIcon } from '../../../utils'
import { getChannelIcon, joinChannel, leaveChannel } from '../../../utils'
import chunter from './../../../plugin'
export let _class: Ref<Class<Space>>
export let _class: Ref<Class<Channel>> = chunter.class.Channel
export let label: IntlString
export let createItemDialog: AnyComponent | undefined = undefined
export let createItemLabel: IntlString = presentation.string.Create
@ -49,32 +54,37 @@
export let withFilterButton: boolean = true
export let search: string = ''
const me = getCurrentAccount()._id
const client = getClient()
const spaceQuery = createQuery()
const me = getCurrentAccount()._id
const channelsQuery = createQuery()
const notificationClient = InboxNotificationsClientImpl.getClient()
const sort: SortingQuery<Space> = {
name: SortingOrder.Ascending
}
let searchQuery: DocumentQuery<Space>
let resultQuery: DocumentQuery<Space>
let spaces: Space[] = []
let searchQuery: DocumentQuery<Channel>
let resultQuery: DocumentQuery<Channel>
let channels: Channel[] = []
$: updateSearchQuery(search)
$: update(sort, resultQuery)
async function update (sort: SortingQuery<Space>, resultQuery: DocumentQuery<Space>): Promise<void> {
const options: FindOptions<Space> = {
async function update (sort: SortingQuery<Channel>, resultQuery: DocumentQuery<Channel>): Promise<void> {
const options: FindOptions<Channel> = {
sort
}
spaceQuery.query(
channelsQuery.query(
_class,
{
...resultQuery
...resultQuery,
private: false
},
(res) => {
spaces = res
channels = res
},
options
)
@ -88,27 +98,43 @@
showPopup(createItemDialog as AnyComponent, {}, 'middle')
}
async function join (space: Space): Promise<void> {
if (space.members.includes(me)) return
await client.update(space, {
$push: {
members: me
}
})
async function join (channel: Channel): Promise<void> {
if (channel.members.includes(me)) {
return
}
await joinChannel(channel, me)
}
async function leave (space: Space): Promise<void> {
if (!space.members.includes(me)) return
await client.update(space, {
$pull: {
members: me
}
})
async function leave (channel: Channel): Promise<void> {
if (!channel.members.includes(me)) {
return
}
await leaveChannel(channel, me)
}
async function view (space: Space): Promise<void> {
async function view (channel: Channel): Promise<void> {
const loc = getCurrentResolvedLocation()
loc.path[3] = space._id
const context = get(notificationClient.docNotifyContextByDoc).get(channel._id)
let contextId = context?._id
if (contextId === undefined) {
contextId = await client.createDoc(notification.class.DocNotifyContext, channel.space, {
attachedToClass: channel._class,
attachedTo: channel._id,
user: me,
hidden: false,
lastViewedTimestamp: Date.now()
})
}
if (contextId === undefined) {
return
}
loc.path[3] = contextId
navigate(loc)
}
</script>
@ -154,12 +180,13 @@
</div>
</div>
{/if}
<FilterBar {_class} query={searchQuery} space={undefined} on:change={(e) => (resultQuery = e.detail)} />
<Scroller padding={'2.5rem'}>
<div class="spaces-container">
{#each spaces as space (space._id)}
{@const icon = getChannelIcon(space._class)}
{@const joined = space.members.includes(me)}
{#each channels as channel (channel._id)}
{@const icon = getChannelIcon(channel._class)}
{@const joined = channel.members.includes(me)}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="item flex-between" tabindex="0">
<div class="flex-col clear-mins">
@ -167,16 +194,16 @@
{#if icon}
<div class="icon"><Icon {icon} size={'small'} /></div>
{/if}
<SpacePresenter value={space} />
<SpacePresenter value={channel} />
</div>
<div class="flex-row-center">
{#if joined}
<Label label={workbench.string.Joined} />
&#183
{/if}
{space.members.length}
{channel.members.length}
&#183
{space.description}
{channel.description}
</div>
</div>
<div class="tools flex-row-center gap-2">
@ -185,7 +212,7 @@
size={'x-large'}
label={workbench.string.Leave}
on:click={async () => {
await leave(space)
await leave(channel)
}}
/>
{:else}
@ -193,7 +220,7 @@
size={'x-large'}
label={workbench.string.View}
on:click={async () => {
await view(space)
await view(channel)
}}
/>
<Button
@ -201,7 +228,7 @@
kind={'primary'}
label={workbench.string.Join}
on:click={async () => {
await join(space)
await join(channel)
}}
/>
{/if}

View File

@ -28,6 +28,7 @@
import { SearchType } from '../../../utils'
import plugin from '../../../plugin'
import Header from '../../Header.svelte'
import ChannelBrowser from './ChannelBrowser.svelte'
let userSearch_: string = ''
userSearch.subscribe((v) => (userSearch_ = v))
@ -43,7 +44,7 @@
{ searchType: SearchType.Messages, component: MessagesBrowser },
{
searchType: SearchType.Channels,
component: SpaceBrowser,
component: ChannelBrowser,
filterClass: plugin.class.Channel,
props: {
_class: plugin.class.Channel,

View File

@ -15,7 +15,6 @@
import { type IntlString } from '@hcengineering/platform'
import { type DocumentQuery } from '@hcengineering/core'
import { type DocNotifyContext } from '@hcengineering/notification'
import { type AnyComponent } from '@hcengineering/ui'
export type Mode = 'channels' | 'direct' | 'activity'
@ -23,7 +22,5 @@ export interface ChatNavGroupModel {
id: Mode
label: IntlString
tabLabel: IntlString
addLabel?: IntlString
addComponent?: AnyComponent
query: DocumentQuery<DocNotifyContext>
}

View File

@ -46,7 +46,7 @@ export const chatSpecials: SpecialNavModel[] = [
{
id: 'chunterBrowser',
label: chunter.string.ChunterBrowser,
icon: workbench.icon.Search,
icon: view.icon.Database,
component: chunter.component.ChunterBrowser,
position: 'top'
},
@ -80,9 +80,7 @@ export const chatNavGroupsModel: ChatNavGroupModel[] = [
label: chunter.string.AllChannels,
query: {
attachedToClass: { $in: [chunter.class.Channel] }
},
addLabel: chunter.string.CreateChannel,
addComponent: chunter.component.CreateChannel
}
},
{
id: 'direct',
@ -90,9 +88,7 @@ export const chatNavGroupsModel: ChatNavGroupModel[] = [
label: chunter.string.AllContacts,
query: {
attachedToClass: { $in: [chunter.class.DirectMessage] }
},
addLabel: chunter.string.NewDirectMessage,
addComponent: chunter.component.CreateDirectMessage
}
},
{
id: 'activity',

View File

@ -41,7 +41,7 @@ import ChannelPanel from './components/ChannelPanel.svelte'
import ChunterBrowser from './components/chat/specials/ChunterBrowser.svelte'
import ConvertDmToPrivateChannelModal from './components/ConvertDmToPrivateChannel.svelte'
import CreateChannel from './components/chat/create/CreateChannel.svelte'
import CreateDirectMessage from './components/chat/create/CreateDirectMessage.svelte'
import CreateDirectChat from './components/chat/create/CreateDirectChat.svelte'
import DirectMessagePresenter from './components/DirectMessagePresenter.svelte'
import DmHeader from './components/DmHeader.svelte'
import DmPresenter from './components/DmPresenter.svelte'
@ -85,6 +85,7 @@ import {
navigateToThread
} from './utils'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { type Mode } from './components/chat/types'
export { default as ChatMessagesPresenter } from './components/chat-message/ChatMessagesPresenter.svelte'
export { default as ChatMessagePopup } from './components/chat-message/ChatMessagePopup.svelte'
@ -144,19 +145,30 @@ async function ConvertDmToPrivateChannel (dm: DirectMessage): Promise<void> {
})
}
async function OpenChannel (notifyContext?: DocNotifyContext, evt?: Event): Promise<void> {
async function OpenChannel (
notifyContext?: DocNotifyContext,
evt?: Event,
props?: { mode?: Mode, _id: Ref<DocNotifyContext> }
): Promise<void> {
evt?.preventDefault()
closePanel()
const loc = getCurrentLocation()
const id = notifyContext?._id
const id = notifyContext?._id ?? props?._id
if (id === undefined) {
return
}
if (loc.path[3] === id) {
return
}
loc.path[3] = id
loc.path.length = 4
loc.query = { mode: props?.mode ?? loc.query?.mode ?? null, message: null }
loc.fragment = undefined
navigate(loc)
@ -242,7 +254,7 @@ export default async (): Promise<Resources> => ({
},
component: {
CreateChannel,
CreateDirectMessage,
CreateDirectChat,
ThreadParentPresenter,
ThreadViewPanel,
ChannelHeader,

View File

@ -24,7 +24,7 @@ import { type DocNotifyContext, type InboxNotification } from '@hcengineering/no
export default mergeIds(chunterId, chunter, {
component: {
CreateChannel: '' as AnyComponent,
CreateDirectMessage: '' as AnyComponent,
CreateDirectChat: '' as AnyComponent,
ChannelHeader: '' as AnyComponent,
ChannelPanel: '' as AnyComponent,
ThreadViewPanel: '' as AnyComponent,
@ -103,6 +103,7 @@ export default mergeIds(chunterId, chunter, {
NoMessages: '' as IntlString,
On: '' as IntlString,
Mentioned: '' as IntlString,
SentMessage: '' as IntlString
SentMessage: '' as IntlString,
PinnedCount: '' as IntlString
}
})

View File

@ -30,7 +30,8 @@ import {
type Ref,
type Space,
type Class,
type Timestamp
type Timestamp,
type Account
} from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import {
@ -65,6 +66,14 @@ export async function getDmName (client: Client, space?: Space): Promise<string>
const employeeAccounts: PersonAccount[] = await getDmAccounts(client, space)
return await buildDmName(client, employeeAccounts)
}
export async function buildDmName (client: Client, employeeAccounts: PersonAccount[]): Promise<string> {
if (employeeAccounts.length === 0) {
return ''
}
let unsub: Unsubscriber | undefined
const promise = new Promise<IdMap<Employee>>((resolve) => {
unsub = employeeByIdStore.subscribe((p) => {
@ -391,3 +400,27 @@ export function navigateToThread (loc: Location, contextId: Ref<DocNotifyContext
loc.query = { message: _id }
navigate(loc)
}
export async function joinChannel (channel: Channel, value: Ref<Account> | Array<Ref<Account>>): Promise<void> {
const client = getClient()
if (Array.isArray(value)) {
if (value.length > 0) {
await client.update(channel, { $push: { members: { $each: value, $position: 0 } } })
}
} else {
await client.update(channel, { $push: { members: value } })
}
}
export async function leaveChannel (channel: Channel, value: Ref<Account> | Array<Ref<Account>>): Promise<void> {
const client = getClient()
if (Array.isArray(value)) {
if (value.length > 0) {
await client.update(channel, { $pull: { members: { $in: value } } })
}
} else {
await client.update(channel, { $pull: { members: value } })
}
}

View File

@ -165,7 +165,8 @@ export default plugin(chunterId, {
Hashtag: '' as Asset,
Thread: '' as Asset,
Lock: '' as Asset,
ChannelBrowser: '' as Asset
ChannelBrowser: '' as Asset,
Copy: '' as Asset
},
component: {
DmHeader: '' as AnyComponent,
@ -230,7 +231,9 @@ export default plugin(chunterId, {
DescriptionOptional: '' as IntlString,
Visibility: '' as IntlString,
Public: '' as IntlString,
Private: '' as IntlString
Private: '' as IntlString,
NewDirectChat: '' as IntlString,
AddMembers: '' as IntlString
},
ids: {
DMNotification: '' as Ref<NotificationType>,

View File

@ -101,6 +101,7 @@
"HasNewMessagesIn": "has new messages in",
"Employees": "Employees",
"People": "People",
"For": "For"
"For": "For",
"SelectUsers": "Select users"
}
}

View File

@ -101,6 +101,7 @@
"HasNewMessagesIn": "имеет новые сообщения в",
"Employees": "Сотрудники",
"People": "Люди",
"For": "Для"
"For": "Для",
"SelectUsers": "Выберите пользователей"
}
}

View File

@ -0,0 +1,115 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import presentation from '@hcengineering/presentation'
import { deviceOptionsStore, EditWithIcon, IconSearch, Modal, Scroller } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
import { Class, Ref } from '@hcengineering/core'
import { Employee } from '@hcengineering/contact'
import contact from '../plugin'
import UsersList from './UsersList.svelte'
export let _class: Ref<Class<Employee>> = contact.mixin.Employee
export let searchField: string = 'name'
export let searchMode: 'field' | 'fulltext' | 'disabled' = 'field'
export let groupBy = '_class'
export let okLabel: IntlString = presentation.string.Ok
export let placeholder: IntlString = presentation.string.Search
export let selected: Ref<Employee>[] = []
export let skipCurrentAccount = false
export let disableDeselectFor: Ref<Employee>[] = []
const dispatch = createEventDispatcher()
let search: string = ''
let selectedIds: Ref<Employee>[] = selected
function handleCancel () {
dispatch('close')
}
function okAction () {
dispatch('close', selectedIds)
}
function handleSelectionChanged (event: CustomEvent) {
selectedIds = event.detail ?? []
}
</script>
<Modal
label={contact.string.SelectUsers}
type="type-popup"
padding="0"
{okLabel}
{okAction}
canSave={selectedIds.length > 0}
onCancel={handleCancel}
on:close
>
<div class="hulyModal-content__titleGroup">
<div class="search">
<EditWithIcon
icon={IconSearch}
size="large"
width="100%"
autoFocus={!$deviceOptionsStore.isMobile}
bind:value={search}
on:change={() => dispatch('search', search)}
on:input={() => dispatch('search', search)}
{placeholder}
/>
</div>
<div class="line" />
<div class="users">
<Scroller padding="0.75rem 0">
<UsersList
{_class}
{searchField}
{searchMode}
{search}
{groupBy}
selected={selectedIds}
{disableDeselectFor}
{skipCurrentAccount}
on:select={handleSelectionChanged}
/>
</Scroller>
</div>
</div>
</Modal>
<style lang="scss">
.line {
width: 100%;
height: 1px;
background: var(--global-subtle-ui-BorderColor);
}
.search {
padding: 1.25rem;
padding-top: 0.25rem;
}
.users {
display: flex;
flex-direction: column;
max-height: 32rem;
}
</style>

View File

@ -0,0 +1,43 @@
<!--
// Copyright © 2024 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 { getClient } from '@hcengineering/presentation'
import { IconSize } from '@hcengineering/ui'
import { Person, getName } from '@hcengineering/contact'
import Avatar from './Avatar.svelte'
export let person: Person
export let avatarSize: IconSize = 'x-small'
const client = getClient()
const hierarchy = client.getHierarchy()
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex-row-center" on:click>
<Avatar avatar={person.avatar} size={avatarSize} name={person.name} on:accent-color />
<div class="flex-col min-w-0 {avatarSize === 'tiny' || avatarSize === 'inline' ? 'ml-1' : 'ml-3'}">
<div class="label overflow-label text-left">{getName(hierarchy, person)}</div>
</div>
</div>
<style lang="scss">
.label {
color: var(--global-primary-TextColor);
font-weight: 500;
}
</style>

View File

@ -0,0 +1,141 @@
<!--
// Copyright © 2024 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 contact, { Employee, PersonAccount } from '@hcengineering/contact'
import { Class, flipSet, getCurrentAccount, getObjectValue, Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { CheckBox, createFocusManager, FocusHandler, ListView } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import UserDetails from './UserDetails.svelte'
import { personByIdStore } from '../utils'
export let _class: Ref<Class<Employee>> = contact.mixin.Employee
export let searchField: string = 'name'
export let searchMode: 'field' | 'fulltext' | 'disabled' = 'field'
export let groupBy = '_class'
export let search: string = ''
export let selected: Ref<Employee>[] = []
export let skipCurrentAccount = false
export let disableDeselectFor: Ref<Employee>[] = []
const dispatch = createEventDispatcher()
const query = createQuery()
const focusManager = createFocusManager()
const currentAccount = getCurrentAccount() as PersonAccount
let listSelection = 0
let list: ListView
let selectedItems = new Set<Ref<Employee>>(selected)
let persons: Employee[] = []
$: currentPerson = $personByIdStore.get(currentAccount.person)
$: query.query(
_class,
{
...(searchMode !== 'disabled' && search !== ''
? searchMode === 'fulltext'
? { $search: search }
: { [searchField]: { $like: '%' + search + '%' } }
: {}),
...(skipCurrentAccount && currentPerson ? { _id: { $ne: currentPerson._id as Ref<Employee> } } : {})
},
(result) => {
result.sort((a, b) => {
const aval: string = `${getObjectValue(groupBy, a as any)}`
const bval: string = `${getObjectValue(groupBy, b as any)}`
return aval.localeCompare(bval)
})
persons = result
},
{ limit: 200 }
)
function handleSelection (contacts: Employee[], index: number) {
const contact = contacts[index]
if (contact === undefined) {
return
}
selectedItems = flipSet(selectedItems, contact._id)
dispatch('select', Array.from(selectedItems))
}
function onKeydown (key: KeyboardEvent): void {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection + 1)
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
handleSelection(persons, listSelection)
}
}
</script>
<FocusHandler manager={focusManager} />
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="w-full" tabindex="0" on:keydown={onKeydown}>
<ListView bind:this={list} count={persons.length} bind:selection={listSelection} colorsSchema="lumia" noScroll>
<svelte:fragment slot="item" let:item={index}>
{@const person = persons[index]}
{@const disabled = selectedItems.has(person._id) && disableDeselectFor.includes(person._id)}
<button
class="row withList w-full"
class:cursor-default={disabled}
{disabled}
on:click={() => {
handleSelection(persons, index)
}}
>
<UserDetails avatarSize="small" {person} />
<CheckBox
checked={selectedItems.has(person._id)}
readonly={disabled}
kind="primary"
on:value={() => {
handleSelection(persons, index)
}}
/>
</button>
</svelte:fragment>
</ListView>
</div>
<style lang="scss">
.row {
display: flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-2) var(--spacing-1) var(--spacing-1);
flex-grow: 1;
border-radius: var(--small-BorderRadius);
justify-content: space-between;
margin-bottom: 0.125rem;
}
</style>

View File

@ -0,0 +1,32 @@
<!--
// Copyright © 2024 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} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5 8C7.20914 8 9 6.20914 9 4C9 1.79086 7.20914 0 5 0C2.79086 0 1 1.79086 1 4C1 6.20914 2.79086 8 5 8ZM5 7C6.65685 7 8 5.65685 8 4C8 2.34315 6.65685 1 5 1C3.34315 1 2 2.34315 2 4C2 5.65685 3.34315 7 5 7Z"
/>
<path
d="M9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5V11H11.5C11.7761 11 12 11.2239 12 11.5C12 11.7761 11.7761 12 11.5 12H10V13.5C10 13.7761 9.77614 14 9.5 14C9.22386 14 9 13.7761 9 13.5V12H7.5C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11H9V9.5Z"
/>
<path
d="M3.5 9C1.567 9 0 10.567 0 12.5V13.5C0 13.7761 0.223858 14 0.5 14C0.776142 14 1 13.7761 1 13.5V12.5C1 11.1193 2.11929 10 3.5 10H5.5C5.77614 10 6 9.77614 6 9.5C6 9.22386 5.77614 9 5.5 9H3.5Z"
/>
</svg>

View File

@ -99,6 +99,10 @@ import TxNameChange from './components/activity/TxNameChange.svelte'
import NameChangedActivityMessage from './components/activity/NameChangedActivityMessage.svelte'
import SystemAvatar from './components/SystemAvatar.svelte'
import PersonIcon from './components/PersonIcon.svelte'
import UsersList from './components/UsersList.svelte'
import SelectUsersPopup from './components/SelectUsersPopup.svelte'
import IconAddMember from './components/icons/AddMember.svelte'
import UserDetails from './components/UserDetails.svelte'
import contact from './plugin'
import {
@ -160,7 +164,11 @@ export {
MembersBox,
PersonRefPresenter,
SystemAvatar,
PersonIcon
PersonIcon,
UsersList,
SelectUsersPopup,
IconAddMember,
UserDetails
}
const toObjectSearchResult = (e: WithLookup<Contact>): ObjectSearchResult => ({

View File

@ -269,7 +269,8 @@ export const contactPlugin = plugin(contactId, {
PersonLastNamePlaceholder: '' as IntlString,
NumberMembers: '' as IntlString,
Position: '' as IntlString,
For: '' as IntlString
For: '' as IntlString,
SelectUsers: '' as IntlString
},
viewlet: {
TableMember: '' as Ref<Viewlet>,

View File

@ -100,4 +100,7 @@
<path d="M15 4.5C15 4.22386 14.7761 4 14.5 4H12.95C12.7 2.85 11.7 2 10.5 2C9.3 2 8.3 2.85 8.05 4H1.5C1.22386 4 1 4.22386 1 4.5C1 4.77614 1.22386 5 1.5 5H8.05C8.3 6.15 9.3 7 10.5 7C11.7 7 12.7 6.15 12.95 5H14.5C14.7761 5 15 4.77614 15 4.5ZM10.5 6C9.65 6 9 5.35 9 4.5C9 3.65 9.65 3 10.5 3C11.35 3 12 3.65 12 4.5C12 5.35 11.35 6 10.5 6Z"/>
<path d="M1 11.5C1 11.7761 1.22386 12 1.5 12H3.05C3.3 13.15 4.3 14 5.5 14C6.7 14 7.7 13.15 7.95 12H14.5C14.7761 12 15 11.7761 15 11.5C15 11.2239 14.7761 11 14.5 11H7.95C7.7 9.85 6.7 9 5.5 9C4.3 9 3.3 9.85 3.05 11H1.5C1.22386 11 1 11.2239 1 11.5ZM5.5 10C6.35 10 7 10.65 7 11.5C7 12.35 6.35 13 5.5 13C4.65 13 4 12.35 4 11.5C4 10.65 4.65 10 5.5 10Z"/>
</symbol>
<symbol id="database" viewBox="0 0 12 14">
<path d="M6 0.5C3.35105 0.5 0.5 1.126 0.5 2.5V11.5C0.5 12.874 3.35105 13.5 6 13.5C8.64895 13.5 11.5 12.874 11.5 11.5V2.5C11.5 1.126 8.64895 0.5 6 0.5ZM6 1.5C8.8988 1.5 10.3974 2.21705 10.4984 2.5C10.3974 2.78295 8.8988 3.5 6 3.5C3.07935 3.5 1.5803 2.7722 1.5 2.5088V2.50635C1.5803 2.2278 3.07935 1.5 6 1.5ZM1.5 3.71385C2.56395 4.24755 4.3213 4.5 6 4.5C7.6787 4.5 9.43605 4.24755 10.5 3.71385V5.49365C10.4197 5.7722 8.92065 6.5 6 6.5C3.07495 6.5 1.57545 5.77 1.5 5.5V3.71385ZM1.5 6.71385C2.56395 7.24755 4.3213 7.5 6 7.5C7.6787 7.5 9.43605 7.24755 10.5 6.71385V8.49365C10.4197 8.7722 8.92065 9.5 6 9.5C3.07495 9.5 1.57545 8.77 1.5 8.5V6.71385ZM6 12.5C3.07495 12.5 1.57545 11.77 1.5 11.5V9.71385C2.56395 10.2476 4.3213 10.5 6 10.5C7.6787 10.5 9.43605 10.2476 10.5 9.71385V11.4937C10.4197 11.7722 8.92065 12.5 6 12.5Z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -39,5 +39,6 @@ loadMetadata(view.icon, {
DevModel: `${icons}#devmodel`,
ViewButton: `${icons}#viewButton`,
Filter: `${icons}#filter`,
Configure: `${icons}#configure`
Configure: `${icons}#configure`,
Database: `${icons}#database`
})

View File

@ -925,7 +925,8 @@ const view = plugin(viewId, {
DevModel: '' as Asset,
ViewButton: '' as Asset,
Filter: '' as Asset,
Configure: '' as Asset
Configure: '' as Asset,
Database: '' as Asset
},
category: {
General: '' as Ref<ActionCategory>,

View File

@ -15,6 +15,7 @@
import chunter, {
Backlink,
Channel,
ChatMessage,
chunterId,
ChunterSpace,
@ -230,36 +231,47 @@ async function OnChatMessageCreated (tx: TxCUD<Doc>, control: TriggerControl): P
return []
}
const res: Tx[] = []
const targetDoc = (
await control.findAll(chatMessage.attachedToClass, { _id: chatMessage.attachedTo }, { limit: 1 })
)[0]
if (targetDoc !== undefined) {
if (hierarchy.hasMixin(targetDoc, notification.mixin.Collaborators)) {
const collaboratorsMixin = hierarchy.as(targetDoc, notification.mixin.Collaborators)
if (!collaboratorsMixin.collaborators.includes(chatMessage.modifiedBy)) {
res.push(
control.txFactory.createTxMixin(
targetDoc._id,
targetDoc._class,
targetDoc.space,
notification.mixin.Collaborators,
{
$push: {
collaborators: chatMessage.modifiedBy
}
if (targetDoc === undefined) {
return []
}
const res: Tx[] = []
const isChannel = hierarchy.isDerived(targetDoc._class, chunter.class.Channel)
if (hierarchy.hasMixin(targetDoc, notification.mixin.Collaborators)) {
const collaboratorsMixin = hierarchy.as(targetDoc, notification.mixin.Collaborators)
if (!collaboratorsMixin.collaborators.includes(chatMessage.modifiedBy)) {
res.push(
control.txFactory.createTxMixin(
targetDoc._id,
targetDoc._class,
targetDoc.space,
notification.mixin.Collaborators,
{
$push: {
collaborators: chatMessage.modifiedBy
}
)
}
)
}
} else {
const collaborators = await getDocCollaborators(targetDoc, mixin, control)
if (!collaborators.includes(chatMessage.modifiedBy)) {
collaborators.push(chatMessage.modifiedBy)
}
res.push(getMixinTx(tx, control, collaborators))
)
}
} else {
const collaborators = await getDocCollaborators(targetDoc, mixin, control)
if (!collaborators.includes(chatMessage.modifiedBy)) {
collaborators.push(chatMessage.modifiedBy)
}
res.push(getMixinTx(tx, control, collaborators))
}
if (isChannel && !(targetDoc as Channel).members.includes(chatMessage.modifiedBy)) {
res.push(
control.txFactory.createTxUpdateDoc(targetDoc._class, targetDoc.space, targetDoc._id, {
$push: { members: chatMessage.modifiedBy }
})
)
}
return res
@ -532,13 +544,89 @@ async function OnChatMessageRemoved (tx: TxCollectionCUD<Doc, ChatMessage>, cont
return res
}
function combineAttributes (attributes: any[], key: string, operator: string, arrayKey: string): any[] {
return Array.from(
new Set(
attributes.flatMap((attr) =>
Array.isArray(attr[operator]?.[key]?.[arrayKey]) ? attr[operator]?.[key]?.[arrayKey] : attr[operator]?.[key]
)
)
).filter((v) => v != null)
}
async function OnChannelMembersChanged (tx: TxUpdateDoc<Channel>, control: TriggerControl): Promise<Tx[]> {
const changedAttributes = Object.entries(tx.operations)
.flatMap(([id, val]) => (['$push', '$pull'].includes(id) ? Object.keys(val) : id))
.filter((id) => !id.startsWith('$'))
if (!changedAttributes.includes('members')) {
return []
}
const added = combineAttributes([tx.operations], 'members', '$push', '$each')
const removed = combineAttributes([tx.operations], 'members', '$pull', '$in')
const res: Tx[] = []
const allContexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: tx.objectId })
if (removed.length > 0) {
res.push(
control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, {
$pull: {
collaborators: { $in: removed }
}
})
)
}
for (const addedMember of added) {
const context = allContexts.find(({ user }) => user === addedMember)
if (context === undefined) {
res.push(
control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, tx.objectSpace, {
attachedTo: tx.objectId,
attachedToClass: tx.objectClass,
user: addedMember,
hidden: false,
lastViewedTimestamp: tx.modifiedOn
})
)
} else {
res.push(
control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, {
hidden: false,
lastViewedTimestamp: tx.modifiedOn
})
)
}
}
const contextsToRemove = allContexts.filter(({ user }) => removed.includes(user))
if (contextsToRemove.length === 0) {
return res
}
const channel = (await control.findAll(chunter.class.Channel, { _id: tx.objectId }))[0]
if (channel !== undefined) {
for (const context of contextsToRemove) {
res.push(control.txFactory.createTxRemoveDoc(context._class, context.space, context._id))
}
}
return res
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
BacklinkTrigger,
ChunterTrigger,
OnDirectMessageSent,
OnChatMessageRemoved
OnChatMessageRemoved,
OnChannelMembersChanged
},
function: {
CommentRemove,

View File

@ -31,7 +31,8 @@ export default plugin(serverChunterId, {
BacklinkTrigger: '' as Resource<TriggerFunc>,
ChunterTrigger: '' as Resource<TriggerFunc>,
OnDirectMessageSent: '' as Resource<TriggerFunc>,
OnChatMessageRemoved: '' as Resource<TriggerFunc>
OnChatMessageRemoved: '' as Resource<TriggerFunc>,
OnChannelMembersChanged: '' as Resource<TriggerFunc>
},
function: {
CommentRemove: '' as Resource<ObjectDDParticipantFunc>,