Add widgets sidebar (#6578)

This commit is contained in:
Kristina 2024-09-18 06:27:13 +04:00 committed by GitHub
parent d38df9c600
commit a1cee24473
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 2922 additions and 600 deletions

View File

@ -58,6 +58,9 @@ import view, { createAction } from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import setting from '@hcengineering/setting'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import workbench from '@hcengineering/model-workbench'
import { WidgetType } from '@hcengineering/workbench'
import calendar from './plugin'
export * from '@hcengineering/calendar'
@ -166,6 +169,18 @@ export function createModel (builder: Builder): void {
TCalendarEventPresenter
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
{
label: calendar.string.Calendar,
type: WidgetType.Fixed,
icon: calendar.icon.Calendar,
component: calendar.component.CalendarWidget
},
calendar.ids.CalendarWidget
)
builder.mixin(calendar.class.Event, core.class.Class, calendar.mixin.CalendarEventPresenter, {
presenter: calendar.component.CalendarEventPresenter
})

View File

@ -28,6 +28,7 @@ import {
type Viewlet,
type ViewletDescriptor
} from '@hcengineering/view'
import { type Widget } from '@hcengineering/workbench'
export default mergeIds(calendarId, calendar, {
component: {
@ -36,7 +37,8 @@ export default mergeIds(calendarId, calendar, {
EventPresenter: '' as AnyComponent,
CalendarIntegrationIcon: '' as AnyComponent,
CalendarEventPresenter: '' as AnyComponent,
IntegrationConfigure: '' as AnyComponent
IntegrationConfigure: '' as AnyComponent,
CalendarWidget: '' as AnyComponent
},
action: {
SaveEventReminder: '' as Ref<Action>,
@ -66,6 +68,7 @@ export default mergeIds(calendarId, calendar, {
},
ids: {
UpdateRemainderActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
CalendarNotificationGroup: '' as Ref<NotificationGroup>
CalendarNotificationGroup: '' as Ref<NotificationGroup>,
CalendarWidget: '' as Ref<Widget>
}
})

View File

@ -18,6 +18,7 @@ import view, { actionTemplates as viewTemplates, createAction, template } from '
import notification, { notificationActionTemplates } from '@hcengineering/model-notification'
import activity from '@hcengineering/activity'
import workbench from '@hcengineering/model-workbench'
import core from '@hcengineering/model-core'
import chunter from './plugin'
@ -35,6 +36,13 @@ const actionTemplates = template({
})
export function defineActions (builder: Builder): void {
builder.createDoc(
view.class.ActionCategory,
core.space.Model,
{ label: chunter.string.Chat, visible: true },
chunter.category.Chunter
)
createAction(
builder,
{
@ -127,7 +135,7 @@ export function defineActions (builder: Builder): void {
},
context: {
mode: 'context',
group: 'tools'
group: 'remove'
}
},
chunter.action.ArchiveChannel
@ -138,7 +146,7 @@ export function defineActions (builder: Builder): void {
target: chunter.class.Channel,
context: {
mode: ['browser', 'context'],
group: 'create'
group: 'edit'
},
action: workbench.actionImpl.Navigate,
actionProps: {
@ -246,4 +254,24 @@ export function defineActions (builder: Builder): void {
objectClass: { $nin: [chunter.class.DirectMessage, chunter.class.Channel] }
}
})
createAction(builder, {
action: chunter.actionImpl.OpenInSidebar,
label: workbench.string.OpenInSidebar,
icon: view.icon.DetailsFilled,
input: 'focus',
category: chunter.category.Chunter,
target: notification.class.DocNotifyContext,
context: { mode: ['context', 'browser'], group: 'tools' }
})
createAction(builder, {
action: chunter.actionImpl.OpenInSidebarTab,
label: workbench.string.OpenInSidebarNewTab,
icon: view.icon.DetailsFilled,
input: 'focus',
category: chunter.category.Chunter,
target: notification.class.DocNotifyContext,
context: { mode: ['context', 'browser'], group: 'tools' }
})
}

View File

@ -13,164 +13,35 @@
// limitations under the License.
//
import activity, { type ActivityMessage, type ActivityMessageControl } from '@hcengineering/activity'
import {
type Channel,
chunterId,
type DirectMessage,
type ChatMessage,
type ChatMessageViewlet,
type ChunterSpace,
type ObjectChatPanel,
type ThreadMessage,
type ChatSyncInfo,
type InlineButton,
type TypingInfo,
type InlineButtonAction
} from '@hcengineering/chunter'
import activity, { type ActivityMessageControl } from '@hcengineering/activity'
import { chunterId, type ChunterSpace } from '@hcengineering/chunter'
import presentation from '@hcengineering/model-presentation'
import contact, { type ChannelProvider as SocialChannelProvider, type Person } from '@hcengineering/contact'
import {
type Class,
type Doc,
type Domain,
DOMAIN_MODEL,
type Ref,
type Timestamp,
IndexKind,
DOMAIN_TRANSIENT
} from '@hcengineering/core'
import {
type Builder,
Collection as PropCollection,
Index,
Mixin,
Model,
Prop,
TypeMarkup,
TypeRef,
TypeString,
TypeTimestamp,
UX
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import view from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import { type IntlString, type Resource } from '@hcengineering/platform'
import { TActivityMessage } from '@hcengineering/model-activity'
import { type DocNotifyContext } from '@hcengineering/notification'
import { WidgetType } from '@hcengineering/workbench'
import chunter from './plugin'
import { defineActions } from './actions'
import { defineNotifications } from './notifications'
import {
DOMAIN_CHUNTER,
TChannel,
TChatMessage,
TChatMessageViewlet,
TChatSyncInfo,
TChunterSpace,
TDirectMessage,
TInlineButton,
TObjectChatPanel,
TThreadMessage,
TTypingInfo
} from './types'
export { chunterId } from '@hcengineering/chunter'
export { chunterOperation } from './migration'
export const DOMAIN_CHUNTER = 'chunter' as Domain
@Model(chunter.class.ChunterSpace, core.class.Space)
export class TChunterSpace extends TSpace implements ChunterSpace {
@Prop(PropCollection(activity.class.ActivityMessage), chunter.string.Messages)
messages?: number
}
@Model(chunter.class.Channel, chunter.class.ChunterSpace)
@UX(chunter.string.Channel, chunter.icon.Hashtag, undefined, undefined, undefined, chunter.string.Channels)
export class TChannel extends TChunterSpace implements Channel {
@Prop(TypeString(), chunter.string.Topic)
@Index(IndexKind.FullText)
topic?: string
}
@Model(chunter.class.DirectMessage, chunter.class.ChunterSpace)
@UX(chunter.string.DirectMessage, contact.icon.Person, undefined, undefined, undefined, chunter.string.DirectMessages)
export class TDirectMessage extends TChunterSpace implements DirectMessage {}
@Model(chunter.class.ChatMessage, activity.class.ActivityMessage)
@UX(chunter.string.Message, chunter.icon.Thread, undefined, undefined, undefined, chunter.string.Threads)
export class TChatMessage extends TActivityMessage implements ChatMessage {
@Prop(TypeMarkup(), chunter.string.Message)
@Index(IndexKind.FullText)
message!: string
@Prop(TypeTimestamp(), chunter.string.Edit)
editedOn?: Timestamp
@Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
})
attachments?: number
@Prop(TypeRef(contact.class.ChannelProvider), core.string.Object)
provider?: Ref<SocialChannelProvider>
@Prop(PropCollection(chunter.class.InlineButton), core.string.Object)
inlineButtons?: number
}
@Model(chunter.class.ThreadMessage, chunter.class.ChatMessage)
@UX(chunter.string.ThreadMessage, chunter.icon.Thread, undefined, undefined, undefined, chunter.string.Threads)
export class TThreadMessage extends TChatMessage implements ThreadMessage {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
@Index(IndexKind.Indexed)
declare attachedTo: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
declare attachedToClass: Ref<Class<ActivityMessage>>
@Prop(TypeRef(core.class.Doc), core.string.Object)
@Index(IndexKind.Indexed)
objectId!: Ref<Doc>
@Prop(TypeRef(core.class.Class), core.string.Class)
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
}
@Model(chunter.class.ChatMessageViewlet, core.class.Doc, DOMAIN_MODEL)
export class TChatMessageViewlet extends TDoc implements ChatMessageViewlet {
@Prop(TypeRef(core.class.Doc), core.string.Class)
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Doc), core.string.Class)
@Index(IndexKind.Indexed)
messageClass!: Ref<Class<Doc>>
label?: IntlString
onlyWithParent?: boolean
}
@Mixin(chunter.mixin.ObjectChatPanel, core.class.Class)
export class TObjectChatPanel extends TClass implements ObjectChatPanel {
ignoreKeys!: string[]
}
@Model(chunter.class.ChatSyncInfo, core.class.Doc, DOMAIN_CHUNTER)
export class TChatSyncInfo extends TDoc implements ChatSyncInfo {
user!: Ref<Person>
hidden!: Ref<DocNotifyContext>[]
timestamp!: Timestamp
}
@Model(chunter.class.InlineButton, core.class.Doc, DOMAIN_CHUNTER)
export class TInlineButton extends TAttachedDoc implements InlineButton {
name!: string
titleIntl?: IntlString
title?: string
action!: Resource<InlineButtonAction>
}
@Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT)
export class TTypingInfo extends TDoc implements TypingInfo {
objectId!: Ref<Doc>
objectClass!: Ref<Class<Doc>>
person!: Ref<Person>
lastTyping!: Timestamp
}
export * from './types'
export function createModel (builder: Builder): void {
builder.createModel(
@ -185,16 +56,37 @@ export function createModel (builder: Builder): void {
TInlineButton,
TTypingInfo
)
builder.createDoc(
workbench.class.Application,
core.space.Model,
{
label: chunter.string.ApplicationLabelChunter,
icon: chunter.icon.Chunter,
alias: chunterId,
hidden: false,
component: chunter.component.Chat
},
chunter.app.Chunter
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
{
label: chunter.string.Chat,
type: WidgetType.Flexible,
icon: chunter.icon.Chunter,
closeIfNoTabs: true,
onTabClose: chunter.function.CloseChatWidgetTab,
component: chunter.component.ChatWidget,
tabComponent: chunter.component.ChatWidgetTab
},
chunter.ids.ChatWidget
)
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectIcon, {
component: chunter.component.DirectIcon
})
builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ObjectIcon, {
component: chunter.component.ChannelIcon
})
spaceClasses.forEach((spaceClass) => {
builder.mixin(spaceClass, core.class.Class, activity.mixin.ActivityDoc, {})
@ -211,6 +103,15 @@ export function createModel (builder: Builder): void {
})
})
// Presenters
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectIcon, {
component: chunter.component.DirectIcon
})
builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ObjectIcon, {
component: chunter.component.ChannelIcon
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: chunter.function.DirectTitleProvider
})
@ -235,12 +136,21 @@ export function createModel (builder: Builder): void {
header: chunter.component.ChannelHeader
})
builder.createDoc(
view.class.ActionCategory,
core.space.Model,
{ label: chunter.string.Chat, visible: true },
chunter.category.Chunter
)
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectIdentifier, {
provider: chunter.function.DmIdentifierProvider
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, view.mixin.CollectionPresenter, {
presenter: chunter.component.ChatMessagesPresenter
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ChatMessagePresenter
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ThreadMessagePresenter
})
builder.createDoc(
view.class.Viewlet,
@ -257,58 +167,6 @@ export function createModel (builder: Builder): void {
chunter.viewlet.Channels
)
builder.createDoc(
workbench.class.Application,
core.space.Model,
{
label: chunter.string.ApplicationLabelChunter,
icon: chunter.icon.Chunter,
alias: chunterId,
hidden: false,
component: chunter.component.Chat,
aside: chunter.component.ChatAside
},
chunter.app.Chunter
)
builder.mixin(activity.class.ActivityMessage, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetMessageLink
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetThreadLink
})
builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ClassFilters, {
filters: []
})
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: chunter.string.Comments,
position: 60,
filter: chunter.filter.ChatMessagesFilter
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectIdentifier, {
provider: chunter.function.DmIdentifierProvider
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, view.mixin.CollectionPresenter, {
presenter: chunter.component.ChatMessagesPresenter
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ChatMessagePresenter
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, presentation.mixin.InstantTransactions, {
txClasses: [core.class.TxCreateDoc]
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: chunter.component.ThreadMessagePresenter
})
builder.createDoc(
chunter.class.ChatMessageViewlet,
core.space.Model,
@ -320,31 +178,6 @@ export function createModel (builder: Builder): void {
chunter.ids.ThreadMessageViewlet
)
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.Channel,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.DirectMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: activity.class.DocUpdateMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.ChatMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: activity.class.ActivityReference,
components: { input: chunter.component.ChatMessageInput }
})
builder.mixin(chunter.class.Channel, core.class.Class, chunter.mixin.ObjectChatPanel, {
ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description', 'members', 'owners']
})
@ -353,19 +186,6 @@ export function createModel (builder: Builder): void {
ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description', 'members', 'owners']
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, activity.mixin.ActivityMessagePreview, {
presenter: chunter.component.ChatMessagePreview
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, activity.mixin.ActivityMessagePreview, {
presenter: chunter.component.ThreadMessagePreview
})
builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, {
domain: DOMAIN_CHUNTER,
disabled: [{ _class: 1 }, { space: 1 }, { modifiedBy: 1 }, { createdBy: 1 }, { createdOn: -1 }]
})
builder.createDoc(activity.class.ReplyProvider, core.space.Model, {
function: chunter.function.ReplyToThread
})
@ -375,6 +195,11 @@ export function createModel (builder: Builder): void {
strict: true
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, presentation.mixin.InstantTransactions, {
txClasses: [core.class.TxCreateDoc]
})
// Activity
builder.createDoc<ActivityMessageControl<ChunterSpace>>(activity.class.ActivityMessageControl, core.space.Model, {
objectClass: chunter.class.Channel,
skip: [
@ -417,6 +242,59 @@ export function createModel (builder: Builder): void {
}
})
builder.mixin(chunter.class.ChatMessage, core.class.Class, activity.mixin.ActivityMessagePreview, {
presenter: chunter.component.ChatMessagePreview
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, activity.mixin.ActivityMessagePreview, {
presenter: chunter.component.ThreadMessagePreview
})
builder.mixin(activity.class.ActivityMessage, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetMessageLink
})
builder.mixin(chunter.class.ThreadMessage, core.class.Class, view.mixin.LinkProvider, {
encode: chunter.function.GetThreadLink
})
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: chunter.string.Comments,
position: 60,
filter: chunter.filter.ChatMessagesFilter
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.Channel,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.DirectMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: activity.class.DocUpdateMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: chunter.class.ChatMessage,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: activity.class.ActivityReference,
components: { input: chunter.component.ChatMessageInput }
})
// Indexing
builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, {
domain: DOMAIN_CHUNTER,
disabled: [{ _class: 1 }, { space: 1 }, { modifiedBy: 1 }, { createdBy: 1 }, { createdOn: -1 }]
})
defineActions(builder)
defineNotifications(builder)
}

View File

@ -22,6 +22,7 @@ import type { IntlString, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import type { AnyComponent, Location } from '@hcengineering/ui/src/types'
import type { Action, ActionCategory, ViewAction, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import { type WidgetTab } from '@hcengineering/workbench'
export default mergeIds(chunterId, chunter, {
component: {
@ -29,6 +30,8 @@ export default mergeIds(chunterId, chunter, {
DmPresenter: '' as AnyComponent,
ChannelsPanel: '' as AnyComponent,
Chat: '' as AnyComponent,
ChatWidget: '' as AnyComponent,
ChatWidgetTab: '' as AnyComponent,
ChatMessageNotificationLabel: '' as AnyComponent,
ThreadNotificationPresenter: '' as AnyComponent,
JoinChannelNotificationPresenter: '' as AnyComponent
@ -47,7 +50,9 @@ export default mergeIds(chunterId, chunter, {
UnarchiveChannel: '' as ViewAction,
ConvertDmToPrivateChannel: '' as ViewAction,
DeleteChatMessage: '' as ViewAction,
ReplyToThread: '' as ViewAction
ReplyToThread: '' as ViewAction,
OpenInSidebar: '' as ViewAction,
OpenInSidebarTab: '' as ViewAction
},
category: {
Chunter: '' as Ref<ActionCategory>
@ -98,9 +103,10 @@ export default mergeIds(chunterId, chunter, {
CanCopyMessageLink: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
GetChunterSpaceLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
GetThreadLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
ReplyToThread: '' as Resource<(doc: ActivityMessage) => Promise<void>>,
ReplyToThread: '' as Resource<(doc: ActivityMessage, event: MouseEvent) => Promise<void>>,
CanReplyToThread: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
GetMessageLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>
GetMessageLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise<void>>
},
filter: {
ChatMessagesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>

162
models/chunter/src/types.ts Normal file
View File

@ -0,0 +1,162 @@
//
// 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.
//
import {
Collection as PropCollection,
Index,
Mixin,
Model,
Prop,
TypeMarkup,
TypeRef,
TypeString,
TypeTimestamp,
UX
} from '@hcengineering/model'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import type {
Channel,
ChatMessage,
ChatMessageViewlet,
ChatSyncInfo,
ChunterSpace,
DirectMessage,
InlineButton,
InlineButtonAction,
ObjectChatPanel,
ThreadMessage,
TypingInfo
} from '@hcengineering/chunter'
import {
type Class,
type Doc,
type Domain,
DOMAIN_MODEL,
DOMAIN_TRANSIENT,
IndexKind,
type Ref,
type Timestamp
} from '@hcengineering/core'
import contact, { type ChannelProvider as SocialChannelProvider, type Person } from '@hcengineering/contact'
import activity, { type ActivityMessage } from '@hcengineering/activity'
import { TActivityMessage } from '@hcengineering/model-activity'
import attachment from '@hcengineering/model-attachment'
import type { IntlString, Resource } from '@hcengineering/platform'
import type { DocNotifyContext } from '@hcengineering/notification'
import chunter from './plugin'
export const DOMAIN_CHUNTER = 'chunter' as Domain
@Model(chunter.class.ChunterSpace, core.class.Space)
export class TChunterSpace extends TSpace implements ChunterSpace {
@Prop(PropCollection(activity.class.ActivityMessage), chunter.string.Messages)
messages?: number
}
@Model(chunter.class.Channel, chunter.class.ChunterSpace)
@UX(chunter.string.Channel, chunter.icon.Hashtag, undefined, undefined, undefined, chunter.string.Channels)
export class TChannel extends TChunterSpace implements Channel {
@Prop(TypeString(), chunter.string.Topic)
@Index(IndexKind.FullText)
topic?: string
}
@Model(chunter.class.DirectMessage, chunter.class.ChunterSpace)
@UX(chunter.string.DirectMessage, contact.icon.Person, undefined, undefined, undefined, chunter.string.DirectMessages)
export class TDirectMessage extends TChunterSpace implements DirectMessage {}
@Model(chunter.class.ChatMessage, activity.class.ActivityMessage)
@UX(chunter.string.Message, chunter.icon.Thread, undefined, undefined, undefined, chunter.string.Threads)
export class TChatMessage extends TActivityMessage implements ChatMessage {
@Prop(TypeMarkup(), chunter.string.Message)
@Index(IndexKind.FullText)
message!: string
@Prop(TypeTimestamp(), chunter.string.Edit)
editedOn?: Timestamp
@Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
})
attachments?: number
@Prop(TypeRef(contact.class.ChannelProvider), core.string.Object)
provider?: Ref<SocialChannelProvider>
@Prop(PropCollection(chunter.class.InlineButton), core.string.Object)
inlineButtons?: number
}
@Model(chunter.class.ThreadMessage, chunter.class.ChatMessage)
@UX(chunter.string.ThreadMessage, chunter.icon.Thread, undefined, undefined, undefined, chunter.string.Threads)
export class TThreadMessage extends TChatMessage implements ThreadMessage {
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedTo)
@Index(IndexKind.Indexed)
declare attachedTo: Ref<ActivityMessage>
@Prop(TypeRef(activity.class.ActivityMessage), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
declare attachedToClass: Ref<Class<ActivityMessage>>
@Prop(TypeRef(core.class.Doc), core.string.Object)
@Index(IndexKind.Indexed)
objectId!: Ref<Doc>
@Prop(TypeRef(core.class.Class), core.string.Class)
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
}
@Model(chunter.class.ChatMessageViewlet, core.class.Doc, DOMAIN_MODEL)
export class TChatMessageViewlet extends TDoc implements ChatMessageViewlet {
@Prop(TypeRef(core.class.Doc), core.string.Class)
@Index(IndexKind.Indexed)
objectClass!: Ref<Class<Doc>>
@Prop(TypeRef(core.class.Doc), core.string.Class)
@Index(IndexKind.Indexed)
messageClass!: Ref<Class<Doc>>
label?: IntlString
onlyWithParent?: boolean
}
@Mixin(chunter.mixin.ObjectChatPanel, core.class.Class)
export class TObjectChatPanel extends TClass implements ObjectChatPanel {
ignoreKeys!: string[]
}
@Model(chunter.class.ChatSyncInfo, core.class.Doc, DOMAIN_CHUNTER)
export class TChatSyncInfo extends TDoc implements ChatSyncInfo {
user!: Ref<Person>
hidden!: Ref<DocNotifyContext>[]
timestamp!: Timestamp
}
@Model(chunter.class.InlineButton, core.class.Doc, DOMAIN_CHUNTER)
export class TInlineButton extends TAttachedDoc implements InlineButton {
name!: string
titleIntl?: IntlString
title?: string
action!: Resource<InlineButtonAction>
}
@Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT)
export class TTypingInfo extends TDoc implements TypingInfo {
objectId!: Ref<Doc>
objectClass!: Ref<Class<Doc>>
person!: Ref<Person>
lastTyping!: Timestamp
}

View File

@ -14,13 +14,13 @@
//
import contact, { type Employee, type Person } from '@hcengineering/contact'
import { AccountRole, DOMAIN_TRANSIENT, IndexKind, type Domain, type Ref } from '@hcengineering/core'
import { AccountRole, type Domain, DOMAIN_TRANSIENT, IndexKind, type Ref } from '@hcengineering/core'
import {
loveId,
type DevicesPreference,
type Floor,
type Invite,
type JoinRequest,
loveId,
type Meeting,
type Office,
type ParticipantInfo,
@ -30,7 +30,7 @@ import {
type RoomInfo,
type RoomType
} from '@hcengineering/love'
import { Index, Mixin, Model, Prop, TypeRef, type Builder } from '@hcengineering/model'
import { type Builder, Index, Mixin, Model, Prop, TypeRef } from '@hcengineering/model'
import calendar, { TEvent } from '@hcengineering/model-calendar'
import core, { TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
@ -39,7 +39,7 @@ import view, { createAction } from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import { getEmbeddedLabel } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import workbench from '@hcengineering/workbench'
import workbench, { WidgetType } from '@hcengineering/workbench'
import love from './plugin'
export { loveId } from '@hcengineering/love'
@ -165,6 +165,32 @@ export function createModel (builder: Builder): void {
love.app.Love
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
{
label: love.string.Office,
type: WidgetType.Fixed,
icon: love.icon.Love,
component: love.component.LoveWidget,
headerLabel: love.string.Office
},
love.ids.LoveWidget
)
builder.createDoc(
workbench.class.Widget,
core.space.Model,
{
label: love.string.MeetingRoom,
type: WidgetType.Flexible,
icon: love.icon.Cam,
component: love.component.VideoWidget,
size: 'medium'
},
love.ids.VideoWidget
)
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: workbench.extensions.WorkbenchExtensions,
component: love.component.WorkbenchExtension

View File

@ -25,7 +25,9 @@ export default mergeIds(loveId, love, {
component: {
Main: '' as AnyComponent,
WorkbenchExtension: '' as AnyComponent,
Settings: '' as AnyComponent
Settings: '' as AnyComponent,
LoveWidget: '' as AnyComponent,
VideoWidget: '' as AnyComponent
},
app: {
Love: '' as Ref<Doc>

View File

@ -15,7 +15,6 @@
//
import activity, { type ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import { type PersonSpace } from '@hcengineering/contact'
import {
AccountRole,
@ -393,8 +392,7 @@ export function createModel (builder: Builder): void {
alias: notificationId,
hidden: true,
locationResolver: notification.resolver.Location,
component: notification.component.Inbox,
aside: chunter.component.ThreadView
component: notification.component.Inbox
},
notification.app.Inbox
)

View File

@ -17,17 +17,22 @@ import { type Class, DOMAIN_MODEL, type Ref, type Space, type AccountRole } from
import { type Builder, Mixin, Model, Prop, TypeRef, UX } from '@hcengineering/model'
import preference, { TPreference } from '@hcengineering/model-preference'
import { createAction } from '@hcengineering/model-view'
import { getEmbeddedLabel, type Asset, type IntlString } from '@hcengineering/platform'
import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import view, { type KeyBinding } from '@hcengineering/view'
import type {
Application,
ApplicationNavModel,
HiddenApplication,
SpaceView,
ViewConfiguration
ViewConfiguration,
Widget,
WidgetPreference,
WidgetTab,
WidgetType
} from '@hcengineering/workbench'
import { type AnyComponent } from '@hcengineering/ui'
import core, { TClass, TDoc } from '@hcengineering/model-core'
import workbench from './plugin'
export { workbenchId } from '@hcengineering/workbench'
@ -61,8 +66,33 @@ export class TSpaceView extends TClass implements SpaceView {
view!: ViewConfiguration
}
@Model(workbench.class.Widget, core.class.Doc, DOMAIN_MODEL)
@UX(workbench.string.Widget)
export class TWidget extends TDoc implements Widget {
label!: IntlString
icon!: Asset
type!: WidgetType
component!: AnyComponent
tabComponent?: AnyComponent
headerLabel?: IntlString
closeIfNoTabs?: boolean
onTabClose?: Resource<(tab: WidgetTab) => Promise<void>>
}
@Model(workbench.class.WidgetPreference, preference.class.Preference)
@UX(workbench.string.WidgetPreference)
export class TWidgetPreference extends TPreference implements WidgetPreference {
@Prop(TypeRef(workbench.class.Widget), workbench.string.WidgetPreference)
declare attachedTo: Ref<Widget>
enabled!: boolean
}
export function createModel (builder: Builder): void {
builder.createModel(TApplication, TSpaceView, THiddenApplication, TApplicationNavModel)
builder.createModel(TApplication, TSpaceView, THiddenApplication, TApplicationNavModel, TWidget, TWidgetPreference)
builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, {
presenter: workbench.component.ApplicationPresenter
})

View File

@ -19,5 +19,5 @@
</script>
{#each extensions as extension}
<Component is={extension.component} props={{ ...extension.props, ...props }} on:open />
<Component is={extension.component} showLoading={false} props={{ ...extension.props, ...props }} on:open />
{/each}

View File

@ -38,6 +38,7 @@
export let overflowExtra: boolean = false
export let noPrint: boolean = false
export let freezeBefore: boolean = false
export let doubleRowWidth = 768
const dispatch = createEventDispatcher()
@ -76,8 +77,8 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
use:resizeObserver={(element) => {
if (!doubleRow && element.clientWidth <= 768) doubleRow = true
else if (doubleRow && element.clientWidth > 768) doubleRow = false
if (!doubleRow && element.clientWidth <= doubleRowWidth) doubleRow = true
else if (doubleRow && element.clientWidth > doubleRowWidth) doubleRow = false
}}
class="hulyHeader-container"
class:doubleRow={_doubleRow}

View File

@ -0,0 +1,163 @@
<!--
// 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 { Asset, getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import { AnySvelteComponent, ButtonIcon, Icon, IconClose, Label, tooltip } from '../index'
export let label: string | undefined = undefined
export let labelIntl: IntlString | undefined = undefined
export let boldLabel: string | undefined = undefined
export let boldLabelIntl: IntlString | undefined = undefined
export let highlighted: boolean = false
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let iconProps: Record<string, any> | undefined = undefined
export let maxSize: string | undefined = undefined
export let orientation: 'horizontal' | 'vertical' = 'horizontal'
export let kind: 'primary' | 'secondary' = 'primary'
export let canClose = true
const dispatch = createEventDispatcher()
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="container main flex-row-center flex-gap-2 {orientation} {kind}"
style:max-width={orientation === 'horizontal' ? maxSize : 'auto'}
style:max-height={orientation === 'vertical' ? maxSize : 'auto'}
class:active={highlighted}
use:tooltip={{ label: label ? getEmbeddedLabel(label) : labelIntl }}
on:click
on:contextmenu
>
<slot name="prefix" />
{#if icon}
<div class="icon {orientation}">
<Icon {icon} size={'x-small'} {iconProps} />
</div>
{/if}
<span class="overflow-label">
{#if label}
{label}
{:else if labelIntl}
<Label label={labelIntl} />
{/if}
{#if boldLabel}
<span class="label">
{boldLabel}
</span>
{:else if boldLabelIntl}
<span class="label">
<Label label={boldLabelIntl} />
</span>
{/if}
</span>
{#if canClose}
<div class="close-button {orientation}">
<ButtonIcon icon={IconClose} size="min" on:click={() => dispatch('close')} />
</div>
{:else}
<div />
{/if}
</div>
<style lang="scss">
.container {
font-weight: 500;
border: 1px solid transparent;
border-radius: 0.25rem;
cursor: pointer;
overflow: hidden;
&.primary {
background-color: var(--theme-button-pressed);
}
&.secondary {
border-color: var(--highlight-select-border);
}
&.horizontal {
padding: 0.125rem 0.125rem 0.125rem 0.5rem;
height: 1.625rem;
}
&.vertical {
padding: 0.5rem 0.125rem 0.125rem 0.125rem;
width: 1.625rem;
writing-mode: vertical-rl;
text-orientation: sideways;
}
.label {
font-weight: 700;
color: var(--theme-caption-color);
}
&.main {
&.horizontal {
padding-right: 0.25rem;
}
&.vertical {
padding-bottom: 0.25rem;
}
}
&:hover {
&.primary {
background-color: var(--theme-button-hovered);
border-color: var(--theme-navpanel-divider);
}
&.secondary {
border-color: var(--highlight-select-border);
}
}
&.active {
position: relative;
display: flex;
align-items: center;
cursor: default;
background-color: var(--highlight-select);
border-color: var(--highlight-select-border);
&:hover {
background-color: var(--highlight-select);
border-color: var(--highlight-select-border);
}
}
.icon {
&.vertical {
transform: rotate(90deg);
text-orientation: upright;
}
}
.close-button {
display: flex;
&.vertical {
transform: rotate(90deg);
}
}
}
</style>

View File

@ -157,6 +157,7 @@ export { default as Hotkey } from './components/Hotkey.svelte'
export { default as HotkeyGroup } from './components/HotkeyGroup.svelte'
export { default as ModernWizardDialog } from './components/wizard/ModernWizardDialog.svelte'
export { default as ModernWizardBar } from './components/wizard/ModernWizardBar.svelte'
export { default as ModernTab } from './components/ModernTab.svelte'
export { default as IconAdd } from './components/icons/Add.svelte'
export { default as IconCircleAdd } from './components/icons/CircleAdd.svelte'

View File

@ -164,6 +164,11 @@ export const settingsSeparators: DefSeparators = [
{ minSize: 19, size: 30, maxSize: 32, float: 'aside' }
]
export const mainSeparators: DefSeparators = [
{ minSize: 30, size: 'auto', maxSize: 'auto' },
{ minSize: 20, size: 30, maxSize: 45, float: 'sidebar' }
]
export const secondNavSeparators: DefSeparators = [{ minSize: 7, size: 7.5, maxSize: 15, float: 'navigator' }, null]
export const separatorsStore = writable<string[]>([])

View File

@ -84,7 +84,7 @@
if (replyProvider) {
const fn = await getResource(replyProvider.function)
await fn(object)
await fn(object, e)
}
}
</script>

View File

@ -228,7 +228,7 @@ export interface SavedMessage extends Preference {
}
export interface ReplyProvider extends Doc {
function: Resource<(message: ActivityMessage) => Promise<void>>
function: Resource<(message: ActivityMessage, event: MouseEvent) => Promise<void>>
}
export interface UserMentionInfo extends AttachedDoc {

View File

@ -0,0 +1,65 @@
<!--
// 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 { IntlString } from '@hcengineering/platform'
import { CalendarMode } from '../index'
import CalendarNavigation from './CalendarNavigation.svelte'
export let currentDate: Date
export let monthName: string
export let ddItems: {
id: string | number
label: IntlString
mode: CalendarMode
params?: Record<string, any>
}[] = []
export let mode: CalendarMode
export let onToday: () => void
export let onBack: () => void
export let onForward: () => void
</script>
<div class="calendar-header">
<div class="title">
{monthName}
<span>{currentDate.getFullYear()}</span>
</div>
<CalendarNavigation {ddItems} {mode} {onToday} {onBack} {onForward} />
</div>
<style lang="scss">
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
padding: 0.75rem 1.75rem 0.75rem 2.25rem;
min-width: 0;
min-height: 0;
.title {
font-size: 1.25rem;
color: var(--theme-caption-color);
&::first-letter {
text-transform: uppercase;
}
span {
opacity: 0.4;
}
}
}
</style>

View File

@ -0,0 +1,48 @@
<!--
// 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 { IntlString } from '@hcengineering/platform'
import { Button, DropdownLabelsIntl, IconBack, IconForward } from '@hcengineering/ui'
import { CalendarMode } from '../index'
import calendar from '../plugin'
export let ddItems: {
id: string | number
label: IntlString
mode: CalendarMode
params?: Record<string, any>
}[] = []
export let mode: CalendarMode
export let onToday: () => void
export let onBack: () => void
export let onForward: () => void
</script>
<div class="flex-row-center gap-2">
{#if ddItems.length > 1}
<DropdownLabelsIntl
items={ddItems.map((it) => {
return { id: it.id, label: it.label, params: it.params }
})}
size={'medium'}
selected={ddItems.find((it) => it.mode === mode)?.id}
on:selected={(e) => (mode = ddItems.find((it) => it.id === e.detail)?.mode ?? ddItems[0].mode)}
/>
{/if}
<Button label={calendar.string.Today} on:click={onToday} />
<Button icon={IconBack} kind={'ghost'} on:click={onBack} />
<Button icon={IconForward} kind={'ghost'} on:click={onForward} />
</div>

View File

@ -29,19 +29,18 @@
import { createQuery } from '@hcengineering/presentation'
import {
AnyComponent,
Button,
DropdownLabelsIntl,
IconBack,
IconForward,
MonthCalendar,
YearCalendar,
areDatesEqual,
getMonday,
showPopup
showPopup,
AnySvelteComponent
} from '@hcengineering/ui'
import { CalendarMode, DayCalendar, calendarByIdStore, hidePrivateEvents } from '../index'
import calendar from '../plugin'
import Day from './Day.svelte'
import CalendarHeader from './CalendarHeader.svelte'
export let _class: Ref<Class<Doc>> = calendar.class.Event
export let query: DocumentQuery<Event> | undefined = undefined
@ -55,6 +54,7 @@
CalendarMode.Month,
CalendarMode.Year
]
export let headerComponent: AnySvelteComponent | undefined = undefined
const me = getCurrentAccount() as PersonAccount
@ -236,7 +236,7 @@
const dragItemId = 'drag_item' as Ref<Event>
function dragEnter (e: CustomEvent<any>) {
function dragEnter (e: CustomEvent<any>): void {
if (dragItem !== undefined) {
const current = raw.find((p) => p._id === dragItemId)
if (current !== undefined) {
@ -295,47 +295,43 @@
{ id: 'month', label: calendar.string.ModeMonth, mode: CalendarMode.Month },
{ id: 'year', label: calendar.string.ModeYear, mode: CalendarMode.Year }
]
function handleToday (): void {
inc(0)
}
function handleBack (): void {
inc(-1)
}
function handleForward (): void {
inc(1)
}
</script>
<div class="calendar-header">
<div class="title">
{getMonthName(currentDate)}
<span>{currentDate.getFullYear()}</span>
</div>
<div class="flex-row-center gap-2">
{#if ddItems.length > 1}
<DropdownLabelsIntl
items={ddItems.map((it) => {
return { id: it.id, label: it.label, params: it.params }
})}
size={'medium'}
selected={ddItems.find((it) => it.mode === mode)?.id}
on:selected={(e) => (mode = ddItems.find((it) => it.id === e.detail)?.mode ?? ddItems[0].mode)}
/>
{/if}
<Button
label={calendar.string.Today}
on:click={() => {
inc(0)
}}
/>
<Button
icon={IconBack}
kind={'ghost'}
on:click={() => {
inc(-1)
}}
/>
<Button
icon={IconForward}
kind={'ghost'}
on:click={() => {
inc(1)
}}
/>
</div>
</div>
{#if headerComponent}
<svelte:component
this={headerComponent}
{mode}
{currentDate}
{ddItems}
monthName={getMonthName(currentDate)}
onToday={handleToday}
onBack={handleBack}
onForward={handleForward}
on:close
/>
{:else}
<CalendarHeader
{mode}
{currentDate}
{ddItems}
monthName={getMonthName(currentDate)}
onToday={handleToday}
onBack={handleBack}
onForward={handleForward}
/>
{/if}
{#if mode === CalendarMode.Year}
<YearCalendar
{mondayStart}
@ -412,27 +408,3 @@
/>
{/key}
{/if}
<!-- <div class="min-h-4 max-h-4 h-4 flex-no-shrink" /> -->
<style lang="scss">
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.75rem 0.75rem 2.25rem;
min-width: 0;
min-height: 0;
.title {
font-size: 1.25rem;
color: var(--theme-caption-color);
&::first-letter {
text-transform: uppercase;
}
span {
opacity: 0.4;
}
}
}
</style>

View File

@ -0,0 +1,20 @@
<!--
// 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 { CalendarMode, CalendarView } from '../index'
import CalendarWidgetHeader from './CalendarWidgetHeader.svelte'
</script>
<CalendarView allowedModes={[CalendarMode.Day]} headerComponent={CalendarWidgetHeader} on:close />

View File

@ -0,0 +1,61 @@
<!--
// 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 { Breadcrumbs, Header } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
import { CalendarMode } from '../index'
import CalendarNavigation from './CalendarNavigation.svelte'
export let currentDate: Date
export let ddItems: {
id: string | number
label: IntlString
mode: CalendarMode
params?: Record<string, any>
}[] = []
export let mode: CalendarMode
export let monthName: string
export let onToday: () => void
export let onBack: () => void
export let onForward: () => void
</script>
<Header
allowFullsize={false}
type="type-aside"
hideBefore={true}
hideActions={false}
hideDescription={true}
hideExtra={false}
adaptive="autoExtra"
doubleRowWidth={350}
on:close
>
<div class="title">
<Breadcrumbs
items={[
{
title: `${monthName} ${currentDate.getFullYear()}`
}
]}
currentOnly
/>
</div>
<svelte:fragment slot="extra">
<CalendarNavigation {ddItems} {mode} {onToday} {onBack} {onForward} />
</svelte:fragment>
</Header>

View File

@ -41,6 +41,7 @@ import EventReminders from './components/EventReminders.svelte'
import VisibilityEditor from './components/VisibilityEditor.svelte'
import CalendarSelector from './components/CalendarSelector.svelte'
import ConnectApp from './components/ConnectApp.svelte'
import CalendarWidget from './components/CalendarWidget.svelte'
import calendar from './plugin'
import contact from '@hcengineering/contact'
import { deleteObjects } from '@hcengineering/view-resources'
@ -189,7 +190,8 @@ export default async (): Promise<Resources> => ({
CalendarIntegrationIcon,
CalendarEventPresenter,
IntegrationConfigure,
ConnectApp
ConnectApp,
CalendarWidget
},
actionImpl: {
SaveEventReminder: saveEventReminder,

View File

@ -121,6 +121,7 @@
"JoinChannel": "Join channel",
"YouJoinedChannel": "You have been joined to channel",
"AndMore": "and {count} more",
"IsTyping": "{count, plural, =1 {is} other {are}} typing..."
"IsTyping": "{count, plural, =1 {is} other {are}} typing...",
"ThreadIn": "thread in {name}"
}
}

View File

@ -121,6 +121,7 @@
"JoinChannel": "Unirse",
"YouJoinedChannel": "Te has unido al canal",
"AndMore": "y {count} más",
"IsTyping": "está escribiendo..."
"IsTyping": "está escribiendo...",
"ThreadIn": "hilo en {name}"
}
}

View File

@ -121,6 +121,7 @@
"JoinChannel": "Rejoindre",
"YouJoinedChannel": "Vous avez rejoint le canal",
"AndMore": "et {count} de plus",
"IsTyping": "est en train d'écrire..."
"IsTyping": "est en train d'écrire...",
"ThreadIn": "fil de discussion dans {name}"
}
}

View File

@ -121,6 +121,7 @@
"JoinChannel": "Participar no canal",
"YouJoinedChannel": "Entrou no canal",
"AndMore": "e mais {count}",
"IsTyping": "está a escrever..."
"IsTyping": "está a escrever...",
"ThreadIn": "Conversa em cadeia em {name}"
}
}

View File

@ -121,6 +121,7 @@
"JoinChannel": "Приссоединение к каналу",
"YouJoinedChannel": "Вы присоединились к каналу",
"AndMore": "и еще {count}",
"IsTyping": "{count, plural, =1 {печатает} other {печатают}}..."
"IsTyping": "{count, plural, =1 {печатает} other {печатают}}...",
"ThreadIn": "Обсуждение в {name}"
}
}

View File

@ -121,6 +121,7 @@
"JoinChannel": "加入频道",
"YouJoinedChannel": "你已加入频道",
"AndMore": "和 {count} 人",
"IsTyping": "正在输入..."
"IsTyping": "正在输入...",
"ThreadIn": "线程在 {name}"
}
}

View File

@ -69,7 +69,7 @@ export class ChannelDataProvider implements IChannelDataProvider {
private readonly tailQuery = createQuery(true)
private readonly refsQuery = createQuery(true)
private chatId: Ref<Doc> | undefined = undefined
chatId: Ref<Doc> | undefined = undefined
private readonly msgClass: Ref<Class<ActivityMessage>>
private selectedMsgId: Ref<ActivityMessage> | undefined = undefined
private tailStart: Timestamp | undefined = undefined

View File

@ -29,6 +29,7 @@
export let context: DocNotifyContext | undefined
export let filters: Ref<ActivityMessagesFilter>[] = []
export let isAsideOpened = false
export let syncLocation = true
const client = getClient()
const hierarchy = client.getHierarchy()
@ -45,6 +46,9 @@
})
const unsubscribeLocation = locationStore.subscribe((newLocation) => {
if (!syncLocation) {
return
}
const id = getMessageFromLoc(newLocation)
selectedMessageId = id
messageInFocus.set(id)

View File

@ -19,6 +19,7 @@
import { Channel } from '@hcengineering/chunter'
import { ActivityMessagesFilter, WithReferences } from '@hcengineering/activity'
import contact from '@hcengineering/contact'
import view from '@hcengineering/view'
import Header from './Header.svelte'
import chunter from '../plugin'
@ -31,6 +32,7 @@
export let allowClose: boolean = false
export let canOpen: boolean = false
export let withAside: boolean = false
export let withSearch: boolean = true
export let isAsideShown: boolean = false
export let filters: Ref<ActivityMessagesFilter>[] = []
@ -47,12 +49,13 @@
})
async function updateDescription (_id: Ref<Doc>, _class: Ref<Class<Doc>>, object?: Doc): Promise<void> {
if (hierarchy.isDerived(_class, chunter.class.DirectMessage)) {
if (hierarchy.isDerived(_class, chunter.class.DirectMessage) || hierarchy.isDerived(_class, contact.class.Person)) {
description = undefined
} else if (hierarchy.isDerived(_class, chunter.class.Channel)) {
description = (object as Channel)?.topic
} else {
description = await getDocTitle(client, _id, _class, object)
const hasId = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIdentifier) !== undefined
description = hasId ? await getDocTitle(client, _id, _class, object) : undefined
}
}
@ -74,6 +77,7 @@
{canOpen}
{withAside}
{isAsideShown}
{withSearch}
on:aside-toggled
on:close
>

View File

@ -61,6 +61,8 @@
export let skipLabels = false
export let loadMoreAllowed = true
export let isAsideOpened = false
export let initialScrollBottom = true
export let fullHeight = true
const doc = object
@ -450,14 +452,22 @@
isInitialScrolling = false
} else if (separatorIndex === -1) {
await wait()
isScrollInitialized = true
shouldWaitAndRead = true
autoscroll = true
shouldScrollToNew = true
isInitialScrolling = false
waitLastMessageRenderAndRead(() => {
if (initialScrollBottom) {
isScrollInitialized = true
shouldWaitAndRead = true
autoscroll = true
shouldScrollToNew = true
isInitialScrolling = false
waitLastMessageRenderAndRead(() => {
autoscroll = false
})
} else {
isScrollInitialized = true
autoscroll = false
})
updateShouldScrollToNew()
isInitialScrolling = false
readViewportMessages()
}
} else if (separatorElement) {
await wait()
scrollToSeparator()
@ -552,7 +562,7 @@
} else if (dateToJump !== undefined) {
await wait()
scrollToDate(dateToJump)
} else if (newCount > messagesCount) {
} else if (messagesCount > 0 && newCount > messagesCount) {
await wait()
scrollToNewMessages()
}
@ -566,7 +576,7 @@
return
}
if (shouldScrollToNew) {
if (shouldScrollToNew && initialScrollBottom) {
scrollToBottom()
}
@ -703,7 +713,7 @@
{#if isLoading}
<Loading />
{:else}
<div class="flex-col h-full relative">
<div class="flex-col relative" class:h-full={fullHeight}>
{#if startFromBottom}
<div class="grower" />
{/if}

View File

@ -0,0 +1,113 @@
<!--
// 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 { createQuery } from '@hcengineering/presentation'
import { Class, Doc, Ref } from '@hcengineering/core'
import { DocNotifyContext } from '@hcengineering/notification'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { Widget } from '@hcengineering/workbench'
import { ChatWidgetTab } from '@hcengineering/chunter'
import Channel from './Channel.svelte'
import { closeThreadInSidebarChannel } from '../navigation'
import { ThreadView } from '../index'
import ChannelHeader from './ChannelHeader.svelte'
export let widget: Widget
export let tab: ChatWidgetTab
export let height: string
export let width: string
const notificationsClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = notificationsClient.contextByDoc
const objectQuery = createQuery()
let object: Doc | undefined = undefined
let context: DocNotifyContext | undefined = undefined
$: context = object ? $contextByDocStore.get(object._id) : undefined
$: void loadObject(tab.data._id, tab.data._class)
$: threadId = tab.data.thread
async function loadObject (_id?: Ref<Doc>, _class?: Ref<Class<Doc>>): Promise<void> {
if (_id === undefined || _class === undefined) {
object = undefined
objectQuery.unsubscribe()
return
}
objectQuery.query(
_class,
{ _id },
(res) => {
object = res[0]
},
{ limit: 1 }
)
}
</script>
{#if object}
{#key object._id}
<div class="channel" class:invisible={threadId !== undefined} style:height style:width>
<ChannelHeader
_id={object._id}
_class={object._class}
{object}
withAside={false}
withSearch={false}
canOpen={true}
allowClose={true}
on:close
/>
<Channel {object} {context} syncLocation={false} />
</div>
{/key}
{/if}
{#if threadId}
<div class="thread" style:height style:width>
<ThreadView _id={threadId} on:close={() => closeThreadInSidebarChannel(widget, tab)} />
</div>
{/if}
<style lang="scss">
.channel {
display: inline-flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
position: absolute;
top: 0;
left: 0;
&.invisible {
visibility: hidden;
}
}
.thread {
position: absolute;
display: inline-flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
top: 0;
left: 0;
background-color: var(--theme-panel-color);
}
</style>

View File

@ -0,0 +1,58 @@
<!--
// 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 { closeWidget, closeWidgetTab } from '@hcengineering/workbench-resources'
import { Widget } from '@hcengineering/workbench'
import { ChatWidgetTab } from '@hcengineering/chunter'
import ChannelSidebarView from './ChannelSidebarView.svelte'
import chunter from '../plugin'
import ThreadSidebarView from './threads/ThreadSidebarView.svelte'
export let widget: Widget | undefined
export let tab: ChatWidgetTab | undefined
export let height: string
export let width: string
function handleClose (tabId?: string): void {
if (widget === undefined || tabId === undefined) return
void closeWidgetTab(widget, tabId)
}
$: if (widget === undefined || tab === undefined) {
closeWidget(chunter.ids.ChatWidget)
}
</script>
{#if widget && tab && tab.type === 'channel'}
<ChannelSidebarView
{widget}
{tab}
{height}
{width}
on:close={() => {
handleClose(tab?.id)
}}
/>
{:else if widget && tab && tab.type === 'thread'}
<ThreadSidebarView
{tab}
{height}
{width}
on:close={() => {
handleClose(tab?.id)
}}
/>
{/if}

View File

@ -0,0 +1,107 @@
<!--
// 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 { Action, Menu, ModernTab, showPopup } from '@hcengineering/ui'
import { Widget } from '@hcengineering/workbench'
import { getResource } from '@hcengineering/platform'
import { ChatWidgetTab } from '@hcengineering/chunter'
import { InboxNotification } from '@hcengineering/notification'
import {
getNotificationsCount,
InboxNotificationsClientImpl,
isActivityNotification,
isMentionNotification,
NotifyMarker
} from '@hcengineering/notification-resources'
import chunter from '../plugin'
import { onDestroy } from 'svelte'
import { getClient } from '@hcengineering/presentation'
export let tab: ChatWidgetTab
export let widget: Widget
export let selected = false
export let actions: Action[] = []
const client = getClient()
const hierarchy = client.getHierarchy()
const notificationClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = notificationClient.contextByDoc
$: icon = tab.icon ?? widget.icon
$: if (tab.iconComponent) {
void getResource(tab.iconComponent).then((res) => {
icon = res
})
}
let notifications: InboxNotification[] = []
let count: number = 0
$: objectId = tab.type === 'thread' ? tab.data.thread : tab.data._id
$: context = objectId ? $contextByDocStore.get(objectId) : undefined
const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => {
if (context === undefined) {
count = 0
return
}
notifications = (res.get(context._id) ?? []).filter((n) => {
if (isActivityNotification(n)) return true
return isMentionNotification(n) && hierarchy.isDerived(n.mentionedInClass, chunter.class.ChatMessage)
})
})
$: void getNotificationsCount(context, notifications).then((res) => {
count = res
})
onDestroy(() => {
unsubscribe()
})
function handleMenu (event: MouseEvent): void {
if (actions.length === 0) {
return
}
event.preventDefault()
event.stopPropagation()
showPopup(Menu, { actions }, event.target as HTMLElement)
}
</script>
<ModernTab
label={tab.name}
labelIntl={tab.nameIntl ?? widget.label}
highlighted={selected}
orientation="vertical"
kind={tab.isPinned ? 'secondary' : 'primary'}
{icon}
iconProps={tab.iconProps}
canClose={!tab.isPinned}
maxSize="13.5rem"
on:close
on:click
on:contextmenu={handleMenu}
>
<svelte:fragment slot="prefix">
{#if count > 0}
<NotifyMarker kind="simple" size="xx-small" />
{/if}
</svelte:fragment>
</ModernTab>

View File

@ -16,7 +16,7 @@
import { DirectMessage } from '@hcengineering/chunter'
import contact, { Person, PersonAccount } from '@hcengineering/contact'
import { Avatar, CombineAvatars, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { Account, IdMap } from '@hcengineering/core'
import { Account, IdMap, Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Icon, IconSize } from '@hcengineering/ui'
import { classIcon } from '@hcengineering/view-resources'
@ -25,14 +25,24 @@
import { getDmPersons } from '../utils'
export let value: DirectMessage | undefined
export let _id: Ref<DirectMessage> | undefined = undefined
export let size: IconSize = 'small'
export let showStatus = false
export let compact = false
const visiblePersons = 4
const client = getClient()
let persons: Person[] = []
if (_id !== undefined && value === undefined) {
void client.findOne(chunter.class.DirectMessage, { _id }).then((res) => {
value = res
})
} else if (_id && value && value._id !== _id) {
value = undefined
}
$: if (value !== undefined) {
void getDmPersons(client, value, $personByIdStore).then((res) => {
persons = res
@ -50,8 +60,8 @@
}
</script>
{#if persons.length === 0 && value}
<Icon icon={classIcon(client, value._class) ?? chunter.icon.Chunter} {size} />
{#if persons.length === 0}
<Icon icon={classIcon(client, chunter.class.DirectMessage) ?? chunter.icon.Chunter} {size} />
{/if}
{#if persons.length === 1}
@ -83,12 +93,18 @@
{/if}
{#if persons.length > 1 && size !== 'medium'}
<CombineAvatars
_class={contact.class.Person}
items={persons.map(({ _id }) => _id)}
size={avatarSize}
limit={visiblePersons}
/>
{#if compact}
<div class="hulyAvatar-container hulyAvatarSize-{size} group-ava">
{persons.length}
</div>
{:else}
<CombineAvatars
_class={contact.class.Person}
items={persons.map(({ _id }) => _id)}
size={avatarSize}
limit={visiblePersons}
/>
{/if}
{/if}
<style lang="scss">
@ -117,4 +133,19 @@
font-size: 0.688rem;
font-weight: 500;
}
.group-ava {
color: var(--theme-caption-color);
background-color: var(--theme-bg-color);
border: 1px solid var(--theme-divider-color);
border-radius: 0.25rem;
opacity: 0.9;
&.inline,
&.tiny,
&.card,
&.x-small {
font-size: 0.625rem;
}
}
</style>

View File

@ -49,6 +49,7 @@
export let isAsideShown: boolean = false
export let titleKind: 'default' | 'breadcrumbs' = 'default'
export let withFilters: boolean = false
export let withSearch: boolean = true
export let filters: Ref<ActivityMessagesFilter>[] = []
export let adaptive: HeaderAdaptive = 'default'
export let hideActions: boolean = false
@ -110,21 +111,23 @@
</svelte:fragment>
<svelte:fragment slot="search" let:doubleRow>
<SearchInput
collapsed
bind:value={searchValue}
on:change={(ev) => {
userSearch.set(ev.detail)
{#if withSearch}
<SearchInput
collapsed
bind:value={searchValue}
on:change={(ev) => {
userSearch.set(ev.detail)
if (ev.detail !== '') {
navigateToSpecial('chunterBrowser')
}
}}
/>
{#if withFilters}
<ChannelMessagesFilter bind:selectedFilters={filters} />
if (ev.detail !== '') {
navigateToSpecial('chunterBrowser')
}
}}
/>
{#if withFilters}
<ChannelMessagesFilter bind:selectedFilters={filters} />
{/if}
<slot name="search" {doubleRow} />
{/if}
<slot name="search" {doubleRow} />
</svelte:fragment>
<svelte:fragment slot="actions" let:doubleRow>
<slot name="actions" {doubleRow} />

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, Ref, Class, Space } from '@hcengineering/core'
import { Doc, Ref, Class } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import {
Component,
@ -24,8 +24,7 @@
Separator,
Location,
restoreLocation,
deviceOptionsStore as deviceInfo,
type AnyComponent
deviceOptionsStore as deviceInfo
} from '@hcengineering/ui'
import { NavigatorModel, SpecialNavModel } from '@hcengineering/workbench'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
@ -33,16 +32,13 @@
import { chunterId } from '@hcengineering/chunter'
import view, { decodeObjectURI } from '@hcengineering/view'
import { parseLinkId, getObjectLinkId } from '@hcengineering/view-resources'
import { ActivityMessage } from '@hcengineering/activity'
import ChatNavigator from './navigator/ChatNavigator.svelte'
import ChannelView from '../ChannelView.svelte'
import { chatSpecials, loadSavedAttachments } from './utils'
import { SelectChannelEvent } from './types'
import { openChannel } from '../../navigation'
export let currentSpace: Ref<Space> | undefined = undefined
export let asideComponent: AnyComponent | undefined = undefined
export let asideId: string | undefined = undefined
import { openChannel, openThreadInSidebar } from '../../navigation'
const notificationsClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = notificationsClient.contextByDoc
@ -63,9 +59,12 @@
let object: Doc | undefined = undefined
let replacedPanel: HTMLElement
location.subscribe((loc) => {
const unsubcribe = location.subscribe((loc) => {
syncLocation(loc)
})
onDestroy(() => {
unsubcribe()
})
$: void loadObject(selectedData?.id, selectedData?._class)
@ -118,6 +117,12 @@
const [id, _class] = decodeObjectURI(loc.path[3])
selectedData = { id, _class }
}
const thread = loc.path[4] as Ref<ActivityMessage> | undefined
if (thread !== undefined) {
void openThreadInSidebar(thread)
}
}
async function handleChannelSelected (event: CustomEvent): Promise<void> {
@ -175,8 +180,7 @@
short
/>
{/if}
<div bind:this={replacedPanel} class="hulyComponent" class:beforeAside={asideComponent !== undefined && asideId}>
<div bind:this={replacedPanel} class="hulyComponent">
{#if currentSpecial}
<Component
is={currentSpecial.component}
@ -197,10 +201,4 @@
<ChannelView {object} {context} />
{/if}
</div>
{#if asideComponent !== undefined && asideId}
<Separator name={'chat'} index={1} color={'var(--theme-divider-color)'} separatorSize={1} />
<div class="hulyComponent aside">
<Component is={asideComponent} props={{ currentSpace, _id: asideId }} on:close />
</div>
{/if}
</div>

View File

@ -80,6 +80,7 @@
result.push({
icon: view.icon.Open,
label: view.string.Open,
group: 'edit',
action: async () => {
const id = await getObjectLinkId(linkProviders, object._id, object._class, object)
openChannel(id, object._class)
@ -107,9 +108,10 @@
result.push({
icon: action.icon ?? IconEdit,
label: action.label,
group: action.context.group,
action: async (_: any, evt: Event) => {
const impl = await getResource(action.action)
await impl(context, evt, action.actionProps)
await impl(context, evt, { ...action.actionProps, object })
}
})
}

View File

@ -0,0 +1,38 @@
<!--
// 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 { ChatWidgetTab } from '@hcengineering/chunter'
import ThreadView from './ThreadView.svelte'
export let tab: ChatWidgetTab
export let height: string
export let width: string
</script>
{#if tab.data.thread}
{#key tab.data.thread}
<div class="root" style:height style:width>
<ThreadView _id={tab.data.thread} on:close />
</div>
{/key}
{/if}
<style lang="scss">
.root {
display: flex;
flex-direction: column;
}
</style>

View File

@ -16,10 +16,11 @@
import { Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Breadcrumbs, Label, location as locationStore, Header } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
import contact from '@hcengineering/contact'
import attachment from '@hcengineering/attachment'
import chunter from '../../plugin'
import ThreadParentMessage from './ThreadParentPresenter.svelte'
@ -33,6 +34,7 @@
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const messageQuery = createQuery()
const channelQuery = createQuery()
@ -62,9 +64,24 @@
unsubscribeLocation()
})
$: messageQuery.query(activity.class.ActivityMessage, { _id }, (result: ActivityMessage[]) => {
message = result[0] as DisplayActivityMessage
})
$: messageQuery.query(
activity.class.ActivityMessage,
{ _id },
(result: ActivityMessage[]) => {
message = result[0] as DisplayActivityMessage
if (message === undefined) {
dispatch('close')
}
},
{
lookup: {
_id: {
attachments: attachment.class.Attachment
}
}
}
)
$: message &&
channelQuery.query(message.attachedToClass, { _id: message.attachedTo }, (res) => {
@ -118,19 +135,28 @@
<div class="hulyComponent-content hulyComponent-content__container noShrink">
{#if message && dataProvider !== undefined}
<ChannelScrollView bind:selectedMessageId embedded skipLabels object={message} provider={dataProvider}>
<ChannelScrollView
bind:selectedMessageId
embedded
skipLabels
object={message}
provider={dataProvider}
initialScrollBottom={false}
fullHeight={false}
>
<svelte:fragment slot="header">
<div class="mt-3">
<ThreadParentMessage {message} />
</div>
<div class="separator">
{#if message.replies && message.replies > 0}
{#if message.replies && message.replies > 0}
<div class="separator">
<div class="label lower">
<Label label={activity.string.RepliesCount} params={{ replies: message.replies }} />
</div>
{/if}
<div class="line" />
</div>
<div class="line" />
</div>
{/if}
</svelte:fragment>
</ChannelScrollView>
{/if}

View File

@ -32,7 +32,6 @@ import ChatMessagePresenter from './components/chat-message/ChatMessagePresenter
import ChatMessagePreview from './components/chat-message/ChatMessagePreview.svelte'
import ChatMessagesPresenter from './components/chat-message/ChatMessagesPresenter.svelte'
import Chat from './components/chat/Chat.svelte'
import ChatAside from './components/chat/ChatAside.svelte'
import CreateChannel from './components/chat/create/CreateChannel.svelte'
import CreateDirectChat from './components/chat/create/CreateDirectChat.svelte'
import ChunterBrowser from './components/chat/specials/ChunterBrowser.svelte'
@ -51,12 +50,18 @@ import ThreadParentPresenter from './components/threads/ThreadParentPresenter.sv
import Threads from './components/threads/Threads.svelte'
import ThreadView from './components/threads/ThreadView.svelte'
import ThreadViewPanel from './components/threads/ThreadViewPanel.svelte'
import ChatWidget from './components/ChatWidget.svelte'
import ChatWidgetTab from './components/ChatWidgetTab.svelte'
import {
chunterSpaceLinkFragmentProvider,
closeChatWidgetTab,
getMessageLink,
getMessageLocation,
getThreadLink,
openChannelInSidebar,
openChannelInSidebarAction,
openChannelInSidebarTabAction,
replyToThread
} from './navigation'
import {
@ -164,10 +169,11 @@ export default async (): Promise<Resources> => ({
ChannelIcon,
ChatMessageNotificationLabel,
ThreadNotificationPresenter,
ChatAside,
ThreadMessagePreview,
ChatMessagePreview,
JoinChannelNotificationPresenter
JoinChannelNotificationPresenter,
ChatWidget,
ChatWidgetTab
},
activity: {
ChannelCreatedMessage,
@ -188,7 +194,9 @@ export default async (): Promise<Resources> => ({
GetThreadLink: getThreadLink,
ReplyToThread: replyToThread,
CanReplyToThread: canReplyToThread,
GetMessageLink: getMessageLocation
GetMessageLink: getMessageLocation,
CloseChatWidgetTab: closeChatWidgetTab,
OpenChannelInSidebar: openChannelInSidebar
},
actionImpl: {
ArchiveChannel,
@ -197,6 +205,8 @@ export default async (): Promise<Resources> => ({
DeleteChatMessage: deleteChatMessage,
LeaveChannel: leaveChannelAction,
RemoveChannel: removeChannelAction,
ReplyToThread: replyToThread
ReplyToThread: replyToThread,
OpenInSidebar: openChannelInSidebarAction,
OpenInSidebarTab: openChannelInSidebarTabAction
}
})

View File

@ -7,16 +7,27 @@ import {
navigate
} from '@hcengineering/ui'
import { type Ref, type Doc, type Class } from '@hcengineering/core'
import type { ActivityMessage } from '@hcengineering/activity'
import { chunterId, type ChunterSpace, type ThreadMessage } from '@hcengineering/chunter'
import { notificationId } from '@hcengineering/notification'
import { workbenchId } from '@hcengineering/workbench'
import { getObjectLinkId } from '@hcengineering/view-resources'
import activity, { type ActivityMessage } from '@hcengineering/activity'
import {
type Channel,
type ChatWidgetTab,
chunterId,
type ChunterSpace,
type ThreadMessage
} from '@hcengineering/chunter'
import { type DocNotifyContext, notificationId } from '@hcengineering/notification'
import workbench, { type Widget, workbenchId } from '@hcengineering/workbench'
import { classIcon, getObjectLinkId } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
import view, { encodeObjectURI, decodeObjectURI } from '@hcengineering/view'
import { createWidgetTab, isElementFromSidebar, sidebarStore } from '@hcengineering/workbench-resources'
import { type Asset, translate } from '@hcengineering/platform'
import contact from '@hcengineering/contact'
import { get } from 'svelte/store'
import { chatSpecials } from './components/chat/utils'
import { isThreadMessage } from './utils'
import { getChannelName, isThreadMessage } from './utils'
import chunter from './plugin'
export function openChannel (_id: string, _class: Ref<Class<Doc>>, thread?: Ref<ActivityMessage>): void {
const loc = getCurrentLocation()
@ -152,11 +163,23 @@ export async function getThreadLink (doc: ThreadMessage): Promise<Location> {
return await buildThreadLink(loc, doc.objectId, doc.objectClass, doc.attachedTo, doc)
}
export async function replyToThread (message: ActivityMessage): Promise<void> {
export async function replyToThread (message: ActivityMessage, e: Event): Promise<void> {
const fromSidebar = isElementFromSidebar(e.target as HTMLElement)
const loc = getCurrentLocation()
if (loc.path[2] !== notificationId) {
loc.path[2] = chunterId
if (fromSidebar) {
const widget = getClient().getModel().findAllSync(workbench.class.Widget, { _id: chunter.ids.ChatWidget })[0]
const widgetState = get(sidebarStore).widgetsState.get(widget._id)
const tab = widgetState?.tabs.find((it) => it?.data?._id === message.attachedTo)
if (tab !== undefined) {
void openThreadInSidebarChannel(widget, tab as ChatWidgetTab, message)
return
}
}
void openThreadInSidebar(message._id, message)
if (loc.path[2] !== chunterId && loc.path[2] !== notificationId) {
return
}
const newLoc = await buildThreadLink(loc, message.attachedTo, message.attachedToClass, message._id)
@ -194,3 +217,141 @@ export async function resetChunterLocIfEqual (_id: Ref<Doc>, _class: Ref<Class<D
closePanel()
navigate(loc)
}
function getChannelClassIcon (object: Doc): Asset | undefined {
const client = getClient()
const hierarchy = client.getHierarchy()
if (hierarchy.isDerived(object._class, chunter.class.Channel)) {
return (object as Channel).private ? chunter.icon.Lock : chunter.icon.Hashtag
}
return classIcon(client, object._class)
}
export async function openChannelInSidebar (
_id: Ref<Doc>,
_class: Ref<Class<Doc>>,
doc?: Doc,
thread?: Ref<ActivityMessage>,
newTab = true
): Promise<void> {
const client = getClient()
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: chunter.ids.ChatWidget })[0]
if (widget === undefined) return
const object = doc ?? (await client.findOne(_class, { _id }))
if (object === undefined) return
const titleIntl = client.getHierarchy().getClass(object._class).label
const hierarchy = client.getHierarchy()
const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon)
const isPerson = hierarchy.isDerived(_class, contact.class.Person)
const isDirect = hierarchy.isDerived(_class, chunter.class.DirectMessage)
const isChannel = hierarchy.isDerived(_class, chunter.class.Channel)
const name = (await getChannelName(_id, _class, object)) ?? (await translate(titleIntl, {}))
const tab: ChatWidgetTab = {
id: `chunter_${_id}`,
name,
icon: getChannelClassIcon(object),
iconComponent: isChannel ? undefined : iconMixin?.component,
iconProps: {
_id: object._id,
size: isDirect || isPerson ? 'tiny' : 'x-small',
compact: true
},
type: 'channel',
data: {
_id,
_class,
thread
}
}
createWidgetTab(widget, tab, newTab)
}
export async function openChannelInSidebarAction (
context: DocNotifyContext,
_: Event,
props?: { object?: Doc, newTab?: boolean }
): Promise<void> {
await openChannelInSidebar(context.objectId, context.objectClass, props?.object, undefined, props?.newTab ?? false)
}
export async function openChannelInSidebarTabAction (
context: DocNotifyContext,
event: Event,
props?: { object?: Doc }
): Promise<void> {
await openChannelInSidebarAction(context, event, { newTab: true, object: props?.object })
}
export async function openThreadInSidebarChannel (
widget: Widget,
tab: ChatWidgetTab,
message: ActivityMessage
): Promise<void> {
const newTab: ChatWidgetTab = {
...tab,
data: { ...tab.data, thread: message._id }
}
createWidgetTab(widget, newTab)
}
export async function closeThreadInSidebarChannel (widget: Widget, tab: ChatWidgetTab): Promise<void> {
const newTab: ChatWidgetTab = {
...tab,
data: { ...tab.data, thread: undefined }
}
createWidgetTab(widget, newTab)
}
export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: ActivityMessage, doc?: Doc): Promise<void> {
const client = getClient()
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: chunter.ids.ChatWidget })[0]
if (widget === undefined) return
const message = msg ?? (await client.findOne(activity.class.ActivityMessage, { _id }))
if (message === undefined) return
const object = doc ?? (await client.findOne(message.attachedToClass, { _id: message.attachedTo }))
if (object === undefined) return
const titleIntl = client.getHierarchy().getClass(object._class).label
const name = (await getChannelName(object._id, object._class, object)) ?? (await translate(titleIntl, {}))
const tabName = await translate(chunter.string.ThreadIn, { name })
const loc = getCurrentLocation()
const tab: ChatWidgetTab = {
id: 'thread_' + _id,
name: tabName,
icon: chunter.icon.Thread,
allowedPath: loc.path.join('/'),
type: 'thread',
data: {
_id: object?._id,
_class: object?._class,
thread: message._id
}
}
createWidgetTab(widget, tab, true)
}
export function closeChatWidgetTab (tab?: ChatWidgetTab): void {
if (tab?.type === 'thread') {
const loc = getCurrentLocation()
if (loc.path[2] === chunterId || loc.path[2] === notificationId) {
if (loc.path[4] === tab.data.thread) {
loc.path[4] = ''
loc.path.length = 4
navigate(loc)
}
}
}
}

View File

@ -44,6 +44,7 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13",
"@hcengineering/workbench": "^0.6.16",
"fast-equals": "^5.0.1"
},
"repository": "https://github.com/hcengineering/platform",

View File

@ -21,6 +21,7 @@ import { IntlString, plugin } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
import { Action } from '@hcengineering/view'
import { Person, ChannelProvider as SocialChannelProvider } from '@hcengineering/contact'
import { Widget, WidgetTab } from '@hcengineering/workbench'
/**
* @public
@ -98,6 +99,11 @@ export interface InlineButton extends AttachedDoc {
action: Resource<InlineButtonAction>
}
export interface ChatWidgetTab extends WidgetTab {
type: 'channel' | 'thread'
data: { _id?: Ref<Doc>, _class?: Ref<Class<Doc>>, thread?: Ref<ActivityMessage> }
}
/**
* @public
*/
@ -127,7 +133,6 @@ export default plugin(chunterId, {
ChatMessagesPresenter: '' as AnyComponent,
ChatMessagePresenter: '' as AnyComponent,
ThreadMessagePresenter: '' as AnyComponent,
ChatAside: '' as AnyComponent,
ChatMessagePreview: '' as AnyComponent,
ThreadMessagePreview: '' as AnyComponent
},
@ -199,14 +204,16 @@ export default plugin(chunterId, {
CreatedChannelOn: '' as IntlString,
YouJoinedChannel: '' as IntlString,
AndMore: '' as IntlString,
IsTyping: '' as IntlString
IsTyping: '' as IntlString,
ThreadIn: '' as IntlString
},
ids: {
DMNotification: '' as Ref<NotificationType>,
ThreadNotification: '' as Ref<NotificationType>,
ChannelNotification: '' as Ref<NotificationType>,
JoinChannelNotification: '' as Ref<NotificationType>,
ThreadMessageViewlet: '' as Ref<ChatMessageViewlet>
ThreadMessageViewlet: '' as Ref<ChatMessageViewlet>,
ChatWidget: '' as Ref<Widget>
},
app: {
Chunter: '' as Ref<Doc>
@ -216,5 +223,10 @@ export default plugin(chunterId, {
LeaveChannel: '' as Ref<Action>,
RemoveChannel: '' as Ref<Action>,
CloseConversation: '' as Ref<Action>
},
function: {
OpenChannelInSidebar: '' as Resource<
(_id: Ref<Doc>, _class: Ref<Doc>, doc?: Doc, thread?: Ref<ActivityMessage>) => Promise<void>
>
}
})

View File

@ -37,22 +37,24 @@
"svelte-eslint-parser": "^0.33.1"
},
"dependencies": {
"@hcengineering/platform": "^0.6.11",
"svelte": "^4.2.12",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/presentation": "^0.6.3",
"@hcengineering/core": "^0.6.32",
"@hcengineering/login": "^0.6.12",
"@hcengineering/panel": "^0.6.23",
"@hcengineering/love": "^0.6.0",
"livekit-client": "^2.0.10",
"@hcengineering/panel": "^0.6.23",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/presentation": "^0.6.3",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1",
"@livekit/krisp-noise-filter": "~0.2.1",
"@livekit/track-processors": "~0.3.1",
"@hcengineering/view": "^0.6.13"
"livekit-client": "^2.0.10",
"svelte": "^4.2.12"
}
}

View File

@ -13,9 +13,9 @@
// limitations under the License.
-->
<script lang="ts">
import { PersonAccount, formatName } from '@hcengineering/contact'
import { formatName } from '@hcengineering/contact'
import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
import { IdMap, Ref, getCurrentAccount, toIdMap } from '@hcengineering/core'
import { IdMap, Ref, toIdMap } from '@hcengineering/core'
import {
Floor,
Invite,
@ -31,8 +31,6 @@
import { getEmbeddedLabel } from '@hcengineering/platform'
import { MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import {
ActionIcon,
Label,
Location,
PopupResult,
closePopup,
@ -43,6 +41,9 @@
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { onDestroy } from 'svelte'
import workbench from '@hcengineering/workbench'
import { closeWidget, openWidget, sidebarStore } from '@hcengineering/workbench-resources'
import love from '../plugin'
import {
activeFloor,
@ -79,7 +80,6 @@
import RequestPopup from './RequestPopup.svelte'
import RequestingPopup from './RequestingPopup.svelte'
import RoomPopup from './RoomPopup.svelte'
import VideoPopup from './VideoPopup.svelte'
let allowCam: boolean = false
let allowLeave: boolean = false
@ -112,8 +112,6 @@
})
}
const me = (getCurrentAccount() as PersonAccount).person
interface ActiveRoom extends Room {
participants: ParticipantInfo[]
}
@ -285,31 +283,24 @@
showPopup(CamSettingPopup, {}, eventToHTMLElement(e))
}
const videoPopupCategory = 'videoPopup'
let videoPopup: PopupResult | undefined = undefined
function checkActiveVideo (loc: Location, video: boolean, room: Ref<Room> | undefined): void {
if (room === undefined) return
const isOpened = $sidebarStore.widgetsState.get(love.ids.VideoWidget)
if (room === undefined) {
if (isOpened) {
closeWidget(love.ids.VideoWidget)
}
return
}
if (loc.path[2] !== loveId && video) {
if (videoPopup !== undefined) return
videoPopup = showPopup(
VideoPopup,
{
room
},
'movable',
undefined,
undefined,
{
category: videoPopupCategory,
overlay: false,
fixed: true,
refId: videoPopupCategory
}
)
} else if (videoPopup) {
videoPopup.close()
videoPopup = undefined
if (isOpened) return
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: love.ids.VideoWidget })[0]
if (widget === undefined) return
openWidget(widget, {
room
})
} else {
closeWidget(love.ids.VideoWidget)
}
}
@ -326,7 +317,7 @@
closePopup(inviteCategory)
closePopup(joinRequestCategory)
closePopup(myJoinRequestCategory)
closePopup(videoPopupCategory)
closeWidget(love.ids.VideoWidget)
})
const client = getClient()
@ -362,56 +353,56 @@
<div class="flex-row-center flex-gap-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="container main flex-row-center flex-gap-2" bind:this={myOfficeElement} on:click={selectFloor}>
{#if selectedFloor}
<Label label={love.string.Floor} />
<span class="label overflow-label">
{selectedFloor?.name}
</span>
{/if}
<ActionIcon
icon={!$isConnected ? love.icon.Mic : $isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
label={$isMicEnabled ? love.string.Mute : love.string.UnMute}
size={'small'}
action={changeMute}
on:contextmenu={micSettings}
disabled={!$isConnected}
keys={micKeys}
/>
<ActionIcon
icon={!$isConnected || !allowCam
? love.icon.Cam
: $isCameraEnabled
? love.icon.CamEnabled
: love.icon.CamDisabled}
label={$isCameraEnabled ? love.string.StopVideo : love.string.StartVideo}
size={'small'}
action={changeCam}
on:contextmenu={camSettings}
disabled={!$isConnected || !allowCam}
keys={camKeys}
/>
{#if $isConnected}
<ActionIcon
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
label={$isSharingEnabled ? love.string.StopShare : love.string.Share}
disabled={$screenSharing && !$isSharingEnabled}
size={'small'}
action={changeShare}
/>
{/if}
{#if allowLeave}
<ActionIcon
icon={love.icon.LeaveRoom}
iconProps={{ color: '#FF6711' }}
label={love.string.LeaveRoom}
size={'small'}
action={leave}
/>
{/if}
</div>
<!-- <div class="container main flex-row-center flex-gap-2" bind:this={myOfficeElement} on:click={selectFloor}>-->
<!-- {#if selectedFloor}-->
<!-- <Label label={love.string.Floor} />-->
<!-- <span class="label overflow-label">-->
<!-- {selectedFloor?.name}-->
<!-- </span>-->
<!-- {/if}-->
<!-- <ActionIcon-->
<!-- icon={!$isConnected ? love.icon.Mic : $isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}-->
<!-- label={$isMicEnabled ? love.string.Mute : love.string.UnMute}-->
<!-- size={'small'}-->
<!-- action={changeMute}-->
<!-- on:contextmenu={micSettings}-->
<!-- disabled={!$isConnected}-->
<!-- keys={micKeys}-->
<!-- />-->
<!-- <ActionIcon-->
<!-- icon={!$isConnected || !allowCam-->
<!-- ? love.icon.Cam-->
<!-- : $isCameraEnabled-->
<!-- ? love.icon.CamEnabled-->
<!-- : love.icon.CamDisabled}-->
<!-- label={$isCameraEnabled ? love.string.StopVideo : love.string.StartVideo}-->
<!-- size={'small'}-->
<!-- action={changeCam}-->
<!-- on:contextmenu={camSettings}-->
<!-- disabled={!$isConnected || !allowCam}-->
<!-- keys={camKeys}-->
<!-- />-->
<!-- {#if $isConnected}-->
<!-- <ActionIcon-->
<!-- icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}-->
<!-- label={$isSharingEnabled ? love.string.StopShare : love.string.Share}-->
<!-- disabled={$screenSharing && !$isSharingEnabled}-->
<!-- size={'small'}-->
<!-- action={changeShare}-->
<!-- />-->
<!-- {/if}-->
<!-- {#if allowLeave}-->
<!-- <ActionIcon-->
<!-- icon={love.icon.LeaveRoom}-->
<!-- iconProps={{ color: '#FF6711' }}-->
<!-- label={love.string.LeaveRoom}-->
<!-- size={'small'}-->
<!-- action={leave}-->
<!-- />-->
<!-- {/if}-->
<!-- </div>-->
{#if activeRooms.length > 0}
<div class="divider" />
<!-- <div class="divider" />-->
{#each activeRooms as active}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
@ -434,7 +425,9 @@
{/each}
{/if}
{#if reception && receptionParticipants.length > 0}
<div class="divider" />
{#if activeRooms.length > 0}
<div class="divider" />
{/if}
<div class="container flex-row-center flex-gap-2">
<div>{getRoomName(reception, $personByIdStore)}</div>
<div class="flex-row-center avatars">

View File

@ -0,0 +1,98 @@
<!--
// 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 { Floor, Room } from '@hcengineering/love'
import { Ref } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import ui, { Button, IconChevronLeft, ModernButton, Scroller } from '@hcengineering/ui'
import FloorPreview from './FloorPreview.svelte'
import { floors, rooms } from '../stores'
import IconLayers from './icons/Layers.svelte'
import love from '../plugin'
let selectedFloor: Floor | undefined
let floorsSelector: boolean = false
$: if (selectedFloor === undefined && $floors.length > 0) {
selectedFloor = $floors[0]
}
function getRooms (rooms: Room[], floor: Ref<Floor>): Room[] {
return rooms.filter((p) => p.floor === floor)
}
function changeMode (): void {
floorsSelector = !floorsSelector
}
function selectFloor (_id: Ref<Floor>): void {
selectedFloor = $floors.find((p) => p._id === _id)
floorsSelector = false
}
</script>
<div class="root">
{#if floorsSelector}
{#each $floors as floor, i}
<Button
kind={'ghost'}
size={'large'}
on:click={() => {
selectFloor(floor._id)
}}
justify={'left'}
label={getEmbeddedLabel(floor.name)}
/>
{#if i !== $floors.length - 1}<div class="divider" />{/if}
{/each}
<div class="flex-row-center flex-reverse mt-4 w-full">
<ModernButton on:click={changeMode} icon={IconChevronLeft} label={ui.string.Back} />
</div>
{:else}
{#if selectedFloor}
<Scroller>
<FloorPreview
floor={selectedFloor}
showRoomName
rooms={getRooms($rooms, selectedFloor._id)}
selected
isOpen
disabled
cropped
size={'small'}
kind={'no-border'}
background={'var(--theme-popup-color)'}
/>
</Scroller>
{/if}
{#if $floors.length > 1}
<div class="flex-row-center flex-reverse flex-no-shrink w-full mt-4">
<ModernButton on:click={changeMode} icon={IconLayers} label={love.string.ChangeFloor} />
</div>
{/if}
{/if}
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding-right: 0.5rem;
padding-bottom: 1rem;
}
</style>

View File

@ -49,6 +49,7 @@
export let isDock: boolean = false
export let room: Ref<TypeRoom>
export let canUnpin: boolean = true
interface ParticipantData {
_id: string
@ -320,14 +321,16 @@
{/if}
</div>
<div class="flex-row-center flex-gap-2">
<ActionIcon
icon={view.icon.Pin}
label={isDock ? view.string.Unpin : view.string.Pin}
size={'small'}
action={() => {
dispatch('dock')
}}
/>
{#if canUnpin}
<ActionIcon
icon={view.icon.Pin}
label={isDock ? view.string.Unpin : view.string.Pin}
size={'small'}
action={() => {
dispatch('dock')
}}
/>
{/if}
{#if allowLeave}
<ActionIcon
icon={love.icon.LeaveRoom}

View File

@ -0,0 +1,47 @@
<!--
// 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 { Room as TypeRoom } from '@hcengineering/love'
import { Ref } from '@hcengineering/core'
import { closeWidget, WidgetState } from '@hcengineering/workbench-resources'
import love from '../plugin'
import VideoPopup from './VideoPopup.svelte'
export let widgetState: WidgetState
let room: Ref<TypeRoom> | undefined = undefined
$: room = widgetState.data?.room
$: if (widgetState.data?.room === undefined) {
closeWidget(love.ids.VideoWidget)
}
</script>
{#if room}
<div class="root">
<VideoPopup {room} isDock canUnpin={false} />
</div>
{/if}
<style lang="scss">
.root {
display: flex;
flex-direction: column;
flex: 1;
align-items: center;
background-color: var(--theme-statusbar-color);
}
</style>

View File

@ -6,6 +6,8 @@ import MeetingData from './components/MeetingData.svelte'
import SelectScreenSourcePopup from './components/SelectScreenSourcePopup.svelte'
import Settings from './components/Settings.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import LoveWidget from './components/LoveWidget.svelte'
import VideoWidget from './components/VideoWidget.svelte'
import { createMeeting, toggleMic, toggleVideo } from './utils'
export { setCustomCreateScreenTracks } from './utils'
@ -18,7 +20,9 @@ export default async (): Promise<Resources> => ({
WorkbenchExtension,
SelectScreenSourcePopup,
MeetingData,
EditMeetingData
EditMeetingData,
LoveWidget,
VideoWidget
},
function: {
CreateMeeting: createMeeting

View File

@ -46,6 +46,7 @@
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/drive": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/view": "^0.6.13"
"@hcengineering/view": "^0.6.13",
"@hcengineering/workbench": "^0.6.16"
}
}

View File

@ -7,6 +7,7 @@ import { Asset, IntlString, Metadata, Plugin, plugin } from '@hcengineering/plat
import { Preference } from '@hcengineering/preference'
import { AnyComponent } from '@hcengineering/ui/src/types'
import { Action } from '@hcengineering/view'
import { Widget } from '@hcengineering/workbench'
export const loveId = 'love' as Plugin
export type { ScreenSource } from './utils'
@ -128,7 +129,9 @@ const love = plugin(loveId, {
MainFloor: '' as Ref<Floor>,
Reception: '' as Ref<Room>,
InviteNotification: '' as Ref<NotificationType>,
KnockNotification: '' as Ref<NotificationType>
KnockNotification: '' as Ref<NotificationType>,
LoveWidget: '' as Ref<Widget>,
VideoWidget: '' as Ref<Widget>
},
icon: {
Love: '' as Asset,

View File

@ -38,9 +38,9 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/activity": "^0.6.0",
"@hcengineering/activity-resources": "^0.6.1",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/attachment-resources": "^0.6.0",
"@hcengineering/chunter": "^0.6.20",

View File

@ -3,8 +3,12 @@
import { Avatar, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { BrowserNotification } from '@hcengineering/notification'
import { Button, Notification as PlatformNotification, NotificationToast, navigate } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { Button, navigate, Notification as PlatformNotification, NotificationToast } from '@hcengineering/ui'
import view, { decodeObjectURI } from '@hcengineering/view'
import chunter from '@hcengineering/chunter'
import { getResource } from '@hcengineering/platform'
import { ActivityMessage } from '@hcengineering/activity'
import { pushAvailable, subscribePush } from '../utils'
import plugin from '../plugin'
@ -16,6 +20,23 @@
$: senderAccount =
value.senderId !== undefined ? $personAccountByIdStore.get(value.senderId as Ref<PersonAccount>) : undefined
$: sender = senderAccount !== undefined ? $personByIdStore.get(senderAccount.person) : undefined
async function openChannelInSidebar (): Promise<void> {
if (!value.onClickLocation) return
const { onClickLocation } = value
const [_id, _class] = decodeObjectURI(onClickLocation.path[3] ?? '')
onRemove()
if (!_id || !_class || _id === '' || _class === '') {
navigate(onClickLocation)
return
}
const thread = onClickLocation.path[4] as Ref<ActivityMessage> | undefined
const fn = await getResource(chunter.function.OpenChannelInSidebar)
await fn(_id, _class, undefined, thread)
}
</script>
<NotificationToast title={notification.title} severity={notification.severity} onClose={onRemove}>
@ -35,10 +56,7 @@
<Button
label={view.string.Open}
on:click={() => {
if (value.onClickLocation) {
onRemove()
navigate(value.onClickLocation)
}
openChannelInSidebar()
}}
/>
{/if}

View File

@ -14,13 +14,13 @@
-->
<script lang="ts">
export let count: number = 0
export let kind: 'primary' | 'secondary' = 'primary'
export let size: 'x-small' | 'small' | 'medium' = 'small'
export let kind: 'primary' | 'secondary' | 'simple' = 'primary'
export let size: 'xx-small' | 'x-small' | 'small' | 'medium' = 'small'
const maxNumber = 9
</script>
{#if count > 0}
{#if kind === 'primary' && count > 0}
<div class="notifyMarker {size} {kind}">
{#if count > maxNumber}
{maxNumber}+
@ -30,7 +30,11 @@
</div>
{/if}
{#if count === 0 && kind === 'secondary'}
{#if kind === 'secondary'}
<div class="notifyMarker {size} {kind}" />
{/if}
{#if kind === 'simple'}
<div class="notifyMarker {size} {kind}" />
{/if}
@ -43,6 +47,7 @@
border-radius: 50%;
font-weight: 700;
&.simple,
&.primary {
background-color: var(--global-higlight-Color);
color: var(--global-on-accent-TextColor);
@ -52,6 +57,11 @@
background-color: var(--global-subtle-BackgroundColor);
}
&.xx-small {
width: 0.5rem;
height: 0.5rem;
}
&.x-small {
width: 0.75rem;
height: 0.75rem;

View File

@ -15,7 +15,7 @@
<script lang="ts">
import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter from '@hcengineering/chunter'
import { Doc, getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder, Space } from '@hcengineering/core'
import { getCurrentAccount, groupByArray, IdMap, Ref, SortingOrder } from '@hcengineering/core'
import { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import {
@ -47,10 +47,6 @@
import { onDestroy } from 'svelte'
import SettingsButton from './SettingsButton.svelte'
export let currentSpace: Ref<Space> | undefined = undefined
export let asideComponent: AnyComponent | undefined = undefined
export let asideId: string | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const me = getCurrentAccount()
@ -145,7 +141,7 @@
$: filteredData = filterData(filter, selectedTabId, inboxData, $contextByIdStore)
const unsubscribeLoc = locationStore.subscribe((newLocation) => {
void syncLocation(newLocation, $contextByDocStore)
void syncLocation(newLocation)
})
let isContextsLoaded = false
@ -156,11 +152,11 @@
}
const loc = getCurrentLocation()
void syncLocation(loc, docs)
void syncLocation(loc)
isContextsLoaded = true
})
async function syncLocation (newLocation: Location, contextByDoc: Map<Ref<Doc>, DocNotifyContext>): Promise<void> {
async function syncLocation (newLocation: Location): Promise<void> {
const loc = await resolveLocation(newLocation)
if (loc?.loc.path[2] !== notificationId) {
return
@ -174,7 +170,7 @@
const [id, _class] = decodeObjectURI(loc?.loc.path[3] ?? '')
const _id = await parseLinkId(linkProviders, id, _class)
const context = _id ? contextByDoc.get(_id) : undefined
const context = _id ? $contextByDocStore.get(_id) : undefined
selectedContextId = context?._id
@ -423,7 +419,7 @@
short
/>
{/if}
<div bind:this={replacedPanel} class="hulyComponent" class:beforeAside={asideComponent !== undefined && asideId}>
<div bind:this={replacedPanel} class="hulyComponent">
{#if selectedContext && selectedComponent}
<Component
is={selectedComponent}
@ -438,12 +434,6 @@
/>
{/if}
</div>
{#if asideComponent !== undefined && asideId}
<Separator name={'inbox'} index={1} color={'var(--theme-divider-color)'} separatorSize={1} />
<div class="hulyComponent aside">
<Component is={asideComponent} props={{ currentSpace, _id: asideId }} on:close />
</div>
{/if}
</div>
<style lang="scss">

View File

@ -180,4 +180,7 @@
<rect width="18" height="18" x="3" y="3" rx="2"/>
<path d="m9 12 2 2 4-4"/>
</symbol>
<symbol id="details-filled" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 8.00049C2 5.79135 3.79086 4.00049 6 4.00049H26C28.2091 4.00049 30 5.79135 30 8.00049V24.0005C30 26.2096 28.2091 28.0005 26 28.0005H6C3.79086 28.0005 2 26.2096 2 24.0005V8.00049ZM20 6.00049H6C4.89543 6.00049 4 6.89592 4 8.00049V24.0005C4 25.1051 4.89543 26.0005 6 26.0005H20V6.00049Z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -55,5 +55,6 @@ loadMetadata(view.icon, {
Circle: `${icons}#circle`,
Join: `${icons}#join`,
Leave: `${icons}#leave`,
Copy: `${icons}#copy`
Copy: `${icons}#copy`,
DetailsFilled: `${icons}#details-filled`
})

View File

@ -251,7 +251,8 @@ const view = plugin(viewId, {
Join: '' as Asset,
Leave: '' as Asset,
Copy: '' as Asset,
TodoList: '' as Asset
TodoList: '' as Asset,
DetailsFilled: '' as Asset
},
category: {
General: '' as Ref<ActionCategory>,

View File

@ -29,6 +29,11 @@
"LogInAnyway": "Log in anyway",
"WorkspaceCreating": "Creation in progress...",
"AccessDenied": "Object doesn't exist or you are not permitted to access it.",
"UpgradeDownloadProgress": "Downloading upgrade: {percent}%"
"UpgradeDownloadProgress": "Downloading upgrade: {percent}%",
"Widget": "Widget",
"WidgetPreferences": "Widget preferences",
"OpenInSidebar": "Open in sidebar",
"OpenInSidebarNewTab": "Open in sidebar new tab",
"ConfigureWidgets": "Configure widgets"
}
}

View File

@ -29,6 +29,11 @@
"LogInAnyway": "Iniciar sesión de todas formas",
"WorkspaceCreating": "Creation in progress...",
"AccessDenied": "El objeto no existe o no tienes permiso para acceder a él.",
"UpgradeDownloadProgress": "Descargando actualización: {percent}%"
"UpgradeDownloadProgress": "Descargando actualización: {percent}%",
"Widget": "Widget",
"WidgetPreferences": "Preferencias del widget",
"OpenInSidebar": "Abrir en la barra lateral",
"OpenInSidebarNewTab": "Abrir en una nueva pestaña de la barra lateral",
"ConfigureWidgets": "Configurar widgets"
}
}

View File

@ -29,6 +29,11 @@
"LogInAnyway": "Se connecter quand même",
"WorkspaceCreating": "Création en cours...",
"AccessDenied": "L'objet n'existe pas ou vous n'êtes pas autorisé à y accéder.",
"UpgradeDownloadProgress": "Téléchargement de la mise à jour: {percent}%"
"UpgradeDownloadProgress": "Téléchargement de la mise à jour: {percent}%",
"Widget": "Widget",
"WidgetPreferences": "Préférences du widget",
"OpenInSidebar": "Ouvrir dans la barre latérale",
"OpenInSidebarNewTab": "Ouvrir dans un nouvel onglet de la barre latérale",
"ConfigureWidgets": "Configurer les widgets"
}
}

View File

@ -29,6 +29,11 @@
"LogInAnyway": "Entrar de qualquer maneira",
"WorkspaceCreating": "Creation in progress...",
"AccessDenied": "O objeto não existe ou você não tem permissão para acessá-lo.",
"UpgradeDownloadProgress": "Baixando atualização: {percent}%"
"UpgradeDownloadProgress": "Baixando atualização: {percent}%",
"Widget": "Widget",
"WidgetPreferences": "Preferências do widget",
"OpenInSidebar": "Abrir na barra lateral",
"OpenInSidebarNewTab": "Abrir em uma nova aba da barra lateral",
"ConfigureWidgets": "Configurar widgets"
}
}

View File

@ -29,6 +29,11 @@
"LogInAnyway": "Все равно войти",
"WorkspaceCreating": "Пространство создается...",
"AccessDenied": "Объект не существует или у вас нет прав доступа.",
"UpgradeDownloadProgress": "Загрузка обновления: {percent}%"
"UpgradeDownloadProgress": "Загрузка обновления: {percent}%",
"Widget": "Виджет",
"WidgetPreferences": "Настройки виджета",
"OpenInSidebar": "Открыть в боковой панели",
"OpenInSidebarNewTab": "Открыть в новой вкладке боковой панели",
"ConfigureWidgets": "Настроить виджеты"
}
}

View File

@ -29,6 +29,11 @@
"LogInAnyway": "仍然登录",
"WorkspaceCreating": "创建进行中...",
"AccessDenied": "对象不存在或您无权访问",
"UpgradeDownloadProgress": "正在下载更新:{percent}%"
"UpgradeDownloadProgress": "正在下载更新:{percent}%",
"Widget": "小部件",
"WidgetPreferences": "小部件首选项",
"OpenInSidebar": "在侧边栏中打开",
"OpenInSidebarNewTab": "在侧边栏新标签页中打开",
"ConfigureWidgets": "配置小部件"
}
}

View File

@ -65,7 +65,8 @@
rootBarExtensions,
setResolvedLocation,
showPopup,
workbenchSeparators
workbenchSeparators,
mainSeparators
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import {
@ -93,6 +94,8 @@
import SelectWorkspaceMenu from './SelectWorkspaceMenu.svelte'
import SpaceView from './SpaceView.svelte'
import TopMenu from './icons/TopMenu.svelte'
import WidgetsBar from './sidebar/Sidebar.svelte'
import { sidebarStore, SidebarVariant, syncSidebarState } from '../sidebar'
let contentPanel: HTMLElement
@ -150,6 +153,7 @@
$workspacesStore = await getWorkspaceFn()
await updateWindowTitle(getLocation())
})
syncSidebarState()
})
const account = getCurrentAccount() as PersonAccount
@ -607,6 +611,7 @@
let lastLoc: Location | undefined = undefined
defineSeparators('workbench', workbenchSeparators)
defineSeparators('main', mainSeparators)
$: mainNavigator = currentApplication && navigatorModel && navigator && $deviceInfo.navigator.visible
$: elementPanel = $deviceInfo.replacedPanel ?? contentPanel
@ -615,6 +620,14 @@
person && client.getHierarchy().hasMixin(person, contact.mixin.Employee)
? !client.getHierarchy().as(person, contact.mixin.Employee).active
: false
let asideComponent: AnyComponent | undefined
$: if (asideId !== undefined && navigatorModel !== undefined) {
asideComponent = navigatorModel?.aside ?? currentApplication?.aside
} else {
asideComponent = undefined
}
</script>
{#if person && deactivated && !isAdminUser()}
@ -852,17 +865,18 @@
<SpaceView {currentSpace} {currentView} {createItemDialog} {createItemLabel} />
{/if}
</div>
{#if asideId && navigatorModel !== undefined}
{@const asideComponent = navigatorModel?.aside ?? currentApplication?.aside}
{#if asideComponent !== undefined}
<Separator name={'workbench'} index={1} color={'transparent'} separatorSize={0} short />
<div class="antiPanel-component antiComponent aside" bind:this={aside}>
<Component is={asideComponent} props={{ currentSpace, _id: asideId }} on:close={closeAside} />
</div>
{/if}
{#if asideComponent !== undefined}
<Separator name={'workbench'} index={1} color={'transparent'} separatorSize={0} short />
<div class="antiPanel-component antiComponent aside" bind:this={aside}>
<Component is={asideComponent} props={{ currentSpace, _id: asideId }} on:close={closeAside} />
</div>
{/if}
</div>
</div>
{#if $sidebarStore.variant === SidebarVariant.EXPANDED}
<Separator name={'main'} index={0} color={'transparent'} separatorSize={0} short />
{/if}
<WidgetsBar />
<Dock />
<div bind:this={cover} class="cover" />
<TooltipInstance />

View File

@ -0,0 +1,70 @@
<!--
// 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 { createQuery, getClient } from '@hcengineering/presentation'
import { WidgetPreference } from '@hcengineering/workbench'
import workbench from '../../plugin'
import { sidebarStore, SidebarVariant } from '../../sidebar'
import WidgetsBarMini from './SidebarMini.svelte'
import WidgetsBarExpanded from './SidebarExpanded.svelte'
const client = getClient()
const widgets = client.getModel().findAllSync(workbench.class.Widget, {})
const preferencesQuery = createQuery()
let preferences: WidgetPreference[] = []
$: preferencesQuery.query(workbench.class.WidgetPreference, {}, (res) => {
preferences = res
})
$: widgetId = $sidebarStore.widget
$: widget = widgets.find((it) => it._id === widgetId)
$: size = $sidebarStore.variant === SidebarVariant.MINI ? 'mini' : widget?.size
</script>
<div class="antiPanel-component antiComponent root size-{size}" id="sidebar">
{#if $sidebarStore.variant === SidebarVariant.MINI}
<WidgetsBarMini {widgets} {preferences} />
{:else if $sidebarStore.variant === SidebarVariant.EXPANDED}
<WidgetsBarExpanded {widgets} {preferences} />
{/if}
</div>
<style lang="scss">
.root {
position: relative;
background-color: var(--theme-panel-color);
&.size-mini {
width: 3.5rem !important;
min-width: 3.5rem !important;
max-width: 3.5rem !important;
}
&.size-small {
width: 10rem !important;
min-width: 10rem !important;
max-width: 10rem !important;
}
&.size-medium {
width: 25rem !important;
min-width: 25rem !important;
max-width: 25rem !important;
}
}
</style>

View File

@ -0,0 +1,172 @@
<!--
// 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 { Widget, WidgetPreference, WidgetTab } from '@hcengineering/workbench'
import { Ref } from '@hcengineering/core'
import {
Component,
resizeObserver,
location as locationStore,
Location,
Header,
Breadcrumbs
} from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { closeWidgetTab, sidebarStore, SidebarVariant, WidgetState, openWidgetTab, closeWidget } from '../../sidebar'
import WidgetsBar from './widgets/WidgetsBar.svelte'
import SidebarTabs from './SidebarTabs.svelte'
export let widgets: Widget[] = []
export let preferences: WidgetPreference[] = []
let widgetId: Ref<Widget> | undefined = undefined
let widget: Widget | undefined = undefined
let widgetState: WidgetState | undefined = undefined
let tabId: string | undefined = undefined
let tab: WidgetTab | undefined = undefined
let tabs: WidgetTab[] = []
$: widgetId = $sidebarStore.widget
$: widget = widgetId !== undefined ? widgets.find((it) => it._id === widgetId) : undefined
$: widgetState = widget !== undefined ? $sidebarStore.widgetsState.get(widget._id) : undefined
$: tabId = widgetState?.tab
$: tabs = widgetState?.tabs ?? []
$: tab = tabId !== undefined ? tabs.find((it) => it.id === tabId) ?? tabs[0] : tabs[0]
$: if ($sidebarStore.widget === undefined) {
sidebarStore.update((s) => ({ ...s, variant: SidebarVariant.MINI }))
}
const unsubscribe = locationStore.subscribe((loc: Location) => {
if (widget === undefined) return
for (const tab of tabs) {
if (tab.allowedPath !== undefined && !tab.isPinned) {
const path = loc.path.join('/')
if (!path.startsWith(tab.allowedPath)) {
void handleTabClose(tab.id, widget)
}
}
}
})
onDestroy(() => {
unsubscribe()
})
async function handleTabClose (tabId: string, widget?: Widget): Promise<void> {
if (widget === undefined) return
await closeWidgetTab(widget, tabId)
}
function handleTabOpen (tabId: string, widget?: Widget): void {
if (widget === undefined) return
openWidgetTab(widget._id, tabId)
}
let componentHeight = '0px'
let componentWidth = '0px'
function resize (element: Element): void {
componentHeight = `${element.clientHeight}px`
componentWidth = `${element.clientWidth}px`
}
</script>
<div class="root">
<div class="content">
{#if widget?.component}
<div class="component" use:resizeObserver={resize}>
{#if widget.headerLabel}
<Header
allowFullsize={false}
type="type-aside"
hideBefore={true}
hideActions={false}
hideDescription={true}
adaptive="disabled"
on:close={() => {
if (widget !== undefined) {
closeWidget(widget._id)
}
}}
>
<Breadcrumbs items={[{ label: widget.headerLabel }]} currentOnly />
</Header>
{/if}
<Component
is={widget?.component}
props={{ tab, widgetState, height: componentHeight, width: componentWidth, widget }}
on:close={() => {
if (widget !== undefined) {
closeWidget(widget._id)
}
}}
/>
</div>
{/if}
</div>
{#if widget !== undefined && tabs.length > 0}
<SidebarTabs
{tabs}
selected={tab?.id}
{widget}
on:close={(e) => {
void handleTabClose(e.detail, widget)
}}
on:open={(e) => {
handleTabOpen(e.detail, widget)
}}
/>
{/if}
<WidgetsBar {widgets} {preferences} selected={widgetId} />
</div>
<style lang="scss">
.root {
display: flex;
flex: 1;
height: 100%;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
border-top: 1px solid var(--theme-divider-color);
overflow: auto;
flex: 1;
width: calc(100% - 3.5rem);
height: 100%;
background: var(--theme-panel-color);
border-right: 1px solid var(--global-ui-BorderColor);
border-top-left-radius: var(--small-focus-BorderRadius);
border-bottom-left-radius: var(--small-focus-BorderRadius);
}
.component {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,26 @@
<!--
// 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 { Widget, WidgetPreference } from '@hcengineering/workbench'
import { Ref } from '@hcengineering/core'
import WidgetsBar from './widgets/WidgetsBar.svelte'
export let widgets: Widget[] = []
export let preferences: WidgetPreference[] = []
export let selected: Ref<Widget> | undefined = undefined
</script>
<WidgetsBar {widgets} {preferences} {selected} />

View File

@ -0,0 +1,57 @@
<!--
// 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 { Action, Menu, ModernTab, showPopup } from '@hcengineering/ui'
import { Widget, WidgetTab } from '@hcengineering/workbench'
import { getResource } from '@hcengineering/platform'
export let tab: WidgetTab
export let widget: Widget
export let selected = false
export let actions: Action[] = []
$: icon = tab.icon ?? widget.icon
$: if (tab.iconComponent) {
void getResource(tab.iconComponent).then((res) => {
icon = res
})
}
function handleMenu (event: MouseEvent): void {
if (actions.length === 0) {
return
}
event.preventDefault()
event.stopPropagation()
showPopup(Menu, { actions }, event.target as HTMLElement)
}
</script>
<ModernTab
label={tab.name}
labelIntl={tab.nameIntl ?? widget.label}
highlighted={selected}
orientation="vertical"
kind={tab.isPinned ? 'secondary' : 'primary'}
{icon}
iconProps={tab.iconProps}
canClose={!tab.isPinned}
maxSize="13.5rem"
on:close
on:click
on:contextmenu={handleMenu}
/>

View File

@ -0,0 +1,99 @@
<!--
// 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 { Widget, WidgetTab } from '@hcengineering/workbench'
import { createEventDispatcher } from 'svelte'
import presentation from '@hcengineering/presentation'
import { Action, Component } from '@hcengineering/ui'
import view from '@hcengineering/view'
import SidebarTab from './SidebarTab.svelte'
import { closeWidgetTab, pinWidgetTab, unpinWidgetTab } from '../../sidebar'
export let tabs: WidgetTab[] = []
export let widget: Widget
export let selected: string | undefined = undefined
const dispatch = createEventDispatcher()
function getActions (tab: WidgetTab): Action[] {
const pinAction: Action = {
label: view.string.Pin,
icon: view.icon.Pin,
action: async () => {
pinWidgetTab(widget, tab.id)
}
}
const unpinAction: Action = {
label: view.string.Unpin,
icon: view.icon.Pin,
action: async () => {
unpinWidgetTab(widget, tab.id)
}
}
const closeAction: Action = {
label: presentation.string.Close,
icon: view.icon.Delete,
action: async () => {
void closeWidgetTab(widget, tab.id)
}
}
return [tab.isPinned ? unpinAction : pinAction, closeAction]
}
</script>
<div class="tabs">
{#each tabs as tab}
{#if widget.tabComponent}
<Component
is={widget.tabComponent}
props={{ tab, widget, selected: tab.id === selected, actions: getActions(tab) }}
on:close={() => dispatch('close', tab.id)}
on:click={() => {
dispatch('open', tab.id)
}}
/>
{:else}
<SidebarTab
{tab}
{widget}
actions={getActions(tab)}
selected={tab.id === selected}
on:close={() => dispatch('close', tab.id)}
on:click={() => {
dispatch('open', tab.id)
}}
/>
{/if}
{/each}
</div>
<style lang="scss">
.tabs {
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
width: 30px;
min-width: 30px;
max-width: 30px;
flex: 1;
border-right: 1px solid var(--theme-divider-color);
border-top: 1px solid var(--theme-divider-color);
gap: 0.25rem;
align-items: center;
padding: 0.25rem 0;
}
</style>

View File

@ -0,0 +1,104 @@
<!--
// 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 { Widget, WidgetPreference, WidgetType } from '@hcengineering/workbench'
import { CheckBox, Grid, Modal } from '@hcengineering/ui'
import core, { Ref } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import WidgetPresenter from './WidgetPresenter.svelte'
import workbench from '../../../plugin'
export let widgets: Widget[] = []
const dispatch = createEventDispatcher()
const client = getClient()
const preferencesQuery = createQuery()
const widgetsUpdates = new Map<Ref<Widget>, boolean>()
let preferences: WidgetPreference[] = []
$: preferencesQuery.query(workbench.class.WidgetPreference, {}, (res) => {
preferences = res
})
function handleClose (): void {
dispatch('close')
}
function isEnabled (
widget: Widget,
widgetsUpdates: Map<Ref<Widget>, boolean>,
preference?: WidgetPreference
): boolean {
return widgetsUpdates.get(widget._id) ?? preference?.enabled ?? false
}
function handleCheck (widget: Widget, preference?: WidgetPreference): void {
const newValue = !isEnabled(widget, widgetsUpdates, preference)
widgetsUpdates.set(widget._id, newValue)
}
async function handleApply (): Promise<void> {
for (const [widget, enabled] of widgetsUpdates) {
const preference = preferences.find((it) => it.attachedTo === widget)
if (preference !== undefined) {
await client.diffUpdate(preference, { enabled })
} else {
await client.createDoc(workbench.class.WidgetPreference, core.space.Workspace, { attachedTo: widget, enabled })
}
}
dispatch('close')
}
</script>
<Modal
label={workbench.string.ConfigureWidgets}
type="type-popup"
okLabel={presentation.string.Save}
okAction={handleApply}
canSave={true}
onCancel={handleClose}
on:close
>
<Grid column={1} rowGap={0.5}>
{#each widgets as widget}
{#if widget.type === WidgetType.Configurable}
{@const preference = preferences.find((it) => it.attachedTo === widget._id)}
<div class="item">
<CheckBox
size="small"
checked={isEnabled(widget, widgetsUpdates, preference)}
on:value={() => {
handleCheck(widget, preference)
}}
/>
<WidgetPresenter {widget} withLabel />
</div>
{/if}
{/each}
</Grid>
</Modal>
<style lang="scss">
.item {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View File

@ -0,0 +1,38 @@
<!--
// 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 { Widget } from '@hcengineering/workbench'
import { Label } from '@hcengineering/ui'
import AppItem from '../../AppItem.svelte'
export let widget: Widget
export let withLabel = false
export let highlighted = false
</script>
<div class="root">
<AppItem label={widget.label} icon={widget.icon} selected={highlighted} size="small" on:click />
{#if withLabel}
<Label label={widget.label} />
{/if}
</div>
<style lang="scss">
.root {
display: flex;
align-items: center;
}
</style>

View File

@ -0,0 +1,120 @@
<!--
// 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 { Widget, WidgetPreference, WidgetType } from '@hcengineering/workbench'
import { IconSettings, ModernButton, showPopup } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import WidgetPresenter from './/WidgetPresenter.svelte'
import AddWidgetsPopup from './AddWidgetsPopup.svelte'
import { openWidget, sidebarStore, SidebarVariant } from '../../../sidebar'
export let widgets: Widget[] = []
export let preferences: WidgetPreference[] = []
export let selected: Ref<Widget> | undefined = undefined
function handleAddWidget (): void {
showPopup(AddWidgetsPopup, { widgets })
}
function handleSelectWidget (widget: Widget): void {
if (selected === widget._id) {
sidebarStore.update((state) => ({ ...state, widget: undefined, variant: SidebarVariant.MINI }))
} else {
openWidget(widget)
}
}
$: fixedWidgets = widgets.filter((widget) => widget.type === WidgetType.Fixed)
$: flexibleWidgets = widgets.filter(
(widget) => widget.type === WidgetType.Flexible && $sidebarStore.widgetsState.has(widget._id)
)
$: configurableWidgets = preferences
.filter((it) => it.enabled)
.sort((a, b) => a.modifiedOn - b.modifiedOn)
.map((it) => widgets.find((widget) => widget._id === it.attachedTo))
.filter((widget): widget is Widget => widget !== undefined && widget.type === WidgetType.Configurable)
</script>
<div class="root">
<div class="block">
{#each fixedWidgets as widget}
<WidgetPresenter
{widget}
highlighted={widget._id === selected}
on:click={() => {
handleSelectWidget(widget)
}}
/>
{/each}
{#if configurableWidgets.length > 0}
<div class="separator" />
{#each configurableWidgets as widget}
<WidgetPresenter
{widget}
highlighted={widget._id === selected}
on:click={() => {
handleSelectWidget(widget)
}}
/>
{/each}
{/if}
{#if flexibleWidgets.length > 0}
<div class="separator" />
{#each flexibleWidgets as widget}
<WidgetPresenter
{widget}
highlighted={widget._id === selected}
on:click={() => {
handleSelectWidget(widget)
}}
/>
{/each}
{/if}
{#if widgets.some((widget) => widget.type === WidgetType.Configurable)}
<div class="separator" />
<ModernButton icon={IconSettings} size="small" on:click={handleAddWidget} />
{/if}
</div>
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding: 0.5rem 0;
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
border-top: 1px solid var(--theme-divider-color);
overflow-y: auto;
}
.block {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.separator {
width: 2rem;
height: 1px;
background-color: var(--global-ui-BorderColor);
}
</style>

View File

@ -37,6 +37,7 @@ export { default as TreeSeparator } from './components/navigator/TreeSeparator.s
export { SpecialView }
export * from './utils'
export * from './sidebar'
export default async (): Promise<Resources> => ({
component: {
WorkbenchApp,

View File

@ -45,7 +45,9 @@ export default mergeIds(workbenchId, workbench, {
MobileNotSupported: '' as IntlString,
LogInAnyway: '' as IntlString,
WorkspaceCreating: '' as IntlString,
AccessDenied: '' as IntlString
AccessDenied: '' as IntlString,
Widget: '' as IntlString,
WidgetPreference: '' as IntlString
},
metadata: {
MobileAllowed: '' as Metadata<boolean>

View File

@ -0,0 +1,294 @@
//
// 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.
//
import { type Widget, type WidgetTab } from '@hcengineering/workbench'
import { getCurrentAccount, type Ref } from '@hcengineering/core'
import { get, writable } from 'svelte/store'
import { getCurrentLocation } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform'
import { workspaceStore } from './utils'
export enum SidebarVariant {
MINI = 'mini',
EXPANDED = 'expanded'
}
export interface WidgetState {
_id: Ref<Widget>
data?: Record<string, any>
tabs: WidgetTab[]
tab?: string
}
export interface SidebarState {
variant: SidebarVariant
widgetsState: Map<Ref<Widget>, WidgetState>
widget?: Ref<Widget>
}
export const defaultSidebarState: SidebarState = {
variant: SidebarVariant.MINI,
widgetsState: new Map()
}
export const sidebarStore = writable<SidebarState>(defaultSidebarState)
workspaceStore.subscribe((workspace) => {
sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? ''))
})
sidebarStore.subscribe(setSidebarStateToLocalStorage)
export function syncSidebarState (): void {
const workspace = get(workspaceStore)
sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? ''))
}
function getSideBarLocalStorageKey (workspace: string): string | undefined {
const me = getCurrentAccount()
if (me == null || workspace === '') return undefined
return `workbench.${workspace}.${me.person}.sidebar.state.`
}
function getSidebarStateFromLocalStorage (workspace: string): SidebarState {
const sidebarStateLocalStorageKey = getSideBarLocalStorageKey(workspace)
if (sidebarStateLocalStorageKey === undefined) return defaultSidebarState
const state = window.localStorage.getItem(sidebarStateLocalStorageKey)
if (state == null || state === '') return defaultSidebarState
try {
const parsed = JSON.parse(state)
return {
...defaultSidebarState,
...parsed,
widgetsState: new Map(Object.entries(parsed.widgetsState ?? {}))
}
} catch (e) {
console.error(e)
setSidebarStateToLocalStorage(defaultSidebarState)
return defaultSidebarState
}
}
function setSidebarStateToLocalStorage (state: SidebarState): void {
const workspace = get(workspaceStore)
if (workspace == null || workspace === '') return
const sidebarStateLocalStorageKey = getSideBarLocalStorageKey(workspace)
if (sidebarStateLocalStorageKey === undefined) return
window.localStorage.setItem(
sidebarStateLocalStorageKey,
JSON.stringify({ ...state, widgetsState: Object.fromEntries(state.widgetsState.entries()) })
)
}
export function openWidget (widget: Widget, data?: Record<string, any>): void {
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
widgetsState.set(widget._id, { _id: widget._id, data, tab: widgetState?.tab, tabs: widgetState?.tabs ?? [] })
sidebarStore.set({
...state,
widgetsState,
variant: SidebarVariant.EXPANDED,
widget: widget._id
})
}
export function closeWidget (widget: Ref<Widget>): void {
const state = get(sidebarStore)
const { widgetsState } = state
widgetsState.delete(widget)
if (state.widget === widget) {
sidebarStore.set({
...state,
widgetsState,
variant: SidebarVariant.MINI,
widget: undefined
})
} else {
sidebarStore.set({
...state,
widgetsState
})
}
}
export async function closeWidgetTab (widget: Widget, tab: string): Promise<void> {
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
if (widgetState === undefined) return
const tabs = widgetState.tabs
const newTabs = tabs.filter((it) => it.id !== tab)
const closedTab = tabs.find((it) => it.id === tab)
if (widget.onTabClose !== undefined && closedTab !== undefined) {
const fn = await getResource(widget.onTabClose)
void fn(closedTab)
}
if (newTabs.length === 0) {
if (widget.closeIfNoTabs === true) {
widgetsState.delete(widget._id)
sidebarStore.set({ ...state, widgetsState, variant: SidebarVariant.MINI })
} else {
widgetsState.set(widget._id, { ...widgetState, tabs: [], tab: undefined })
sidebarStore.set({ ...state, widgetsState })
}
return
}
const shouldReplace = widgetState.tab === tab
if (!shouldReplace) {
widgetsState.set(widget._id, { ...widgetState, tabs: newTabs })
} else {
const index = tabs.findIndex((it) => it.id === widgetState.tab)
const newTab = index === -1 ? newTabs[0] : tabs[index + 1] ?? tabs[index - 1] ?? newTabs[0]
widgetsState.set(widget._id, { ...widgetState, tabs: newTabs, tab: newTab.id })
}
sidebarStore.set({
...state,
...widgetsState
})
}
export function openWidgetTab (widget: Ref<Widget>, tab: string): void {
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget)
if (widgetState === undefined) return
const newTab = widgetState.tabs.find((it) => it.id === tab)
if (newTab === undefined) return
widgetsState.set(widget, { ...widgetState, tab })
sidebarStore.set({
...state,
widgetsState
})
}
export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false): void {
openWidget(widget)
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
const currentTabs = widgetState?.tabs ?? []
const opened = currentTabs.some(({ id }) => id === tab.id) ?? false
let newTabs: WidgetTab[]
if (opened) {
newTabs = currentTabs.map((it) => (it.id === tab.id ? { ...tab, isPinned: it.isPinned } : it))
} else if (newTab || currentTabs.length === 0) {
newTabs = [...currentTabs, tab]
} else {
const current =
currentTabs.find(({ id }) => id === widgetState?.tab) ?? currentTabs.find(({ isPinned }) => isPinned === false)
const shouldReplace = current !== undefined && current.isPinned !== true
newTabs = shouldReplace ? currentTabs.map((it) => (it.id === current?.id ? tab : it)) : [...currentTabs, tab]
}
widgetsState.set(widget._id, {
_id: widget._id,
tabs: newTabs,
tab: tab.id
})
sidebarStore.set({
...state,
widgetsState
})
}
export function pinWidgetTab (widget: Widget, tabId: string): void {
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
if (widgetState === undefined) return
const tabs = widgetState.tabs
.map((it) => (it.id === tabId ? { ...it, isPinned: true } : it))
.sort((a, b) => (a.isPinned === b.isPinned ? 0 : a.isPinned === true ? -1 : 1))
widgetsState.set(widget._id, { ...widgetState, tabs })
sidebarStore.set({
...state,
widgetsState
})
}
export function unpinWidgetTab (widget: Widget, tabId: string): void {
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
if (widgetState === undefined) return
const tab = widgetState.tabs.find((it) => it.id === tabId)
if (tab?.allowedPath !== undefined) {
const loc = getCurrentLocation()
const path = loc.path.join('/')
if (!path.startsWith(tab.allowedPath)) {
void closeWidgetTab(widget, tabId)
}
}
const tabs = widgetState.tabs
.map((it) => (it.id === tabId ? { ...it, isPinned: false } : it))
.sort((a, b) => (a.isPinned === b.isPinned ? 0 : a.isPinned === true ? -1 : 1))
widgetsState.set(widget._id, { ...widgetState, tabs })
sidebarStore.set({
...state,
widgetsState
})
}
function isDescendant (parent: HTMLElement, child: HTMLElement): boolean {
let node = child.parentNode
while (node != null) {
if (node === parent) {
return true
}
node = node.parentNode
}
return false
}
export function isElementFromSidebar (element: HTMLElement): boolean {
const sidebarElement = document.getElementById('sidebar')
if (sidebarElement == null) {
return false
}
return isDescendant(sidebarElement, element)
}

View File

@ -25,12 +25,14 @@ import {
closePanel,
fetchMetadataLocalStorage,
getCurrentLocation,
type Location,
navigate,
setMetadataLocalStorage
setMetadataLocalStorage,
location
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench, { type Application, type NavigatorModel } from '@hcengineering/workbench'
import { writable } from 'svelte/store'
import { derived, writable } from 'svelte/store'
export const workspaceCreating = writable<number | undefined>(undefined)
@ -155,6 +157,7 @@ export async function showApplication (app: Application): Promise<void> {
}
export const workspacesStore = writable<Workspace[]>([])
export const workspaceStore = derived(location, (loc: Location) => loc.path[1])
/**
* @public

View File

@ -18,7 +18,13 @@ import { DocNotifyContext, InboxNotification } from '@hcengineering/notification
import type { Asset, IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { Preference } from '@hcengineering/preference'
import { AnyComponent, ComponentExtensionId, Location, ResolvedLocation } from '@hcengineering/ui'
import {
AnyComponent,
type AnySvelteComponent,
ComponentExtensionId,
Location,
ResolvedLocation
} from '@hcengineering/ui'
import { ViewAction } from '@hcengineering/view'
/**
@ -45,6 +51,43 @@ export interface Application extends Doc {
navFooterComponent?: AnyComponent
}
export enum WidgetType {
Fixed = 'fixed', // Fixed sidebar are always visible
Flexible = 'flexible', // Flexible sidebar are visible only in special cases (during the meeting, etc.)
Configurable = 'configurable ' // Configurable might be fixed in sidebar by user in preferences
}
export interface Widget extends Doc {
label: IntlString
icon: Asset
type: WidgetType
size?: 'small' | 'medium'
component: AnyComponent
tabComponent?: AnyComponent
headerLabel?: IntlString
closeIfNoTabs?: boolean
onTabClose?: Resource<(tab: WidgetTab) => Promise<void>>
}
export interface WidgetPreference extends Preference {
enabled: boolean
}
export interface WidgetTab {
id: string
name?: string
nameIntl?: IntlString
icon?: Asset | AnySvelteComponent
iconComponent?: AnyComponent
iconProps?: Record<string, any>
widget?: Ref<Widget>
isPinned?: boolean
allowedPath?: string
data?: Record<string, any>
}
/**
* @public
*/
@ -141,7 +184,9 @@ export default plugin(workbenchId, {
class: {
Application: '' as Ref<Class<Application>>,
ApplicationNavModel: '' as Ref<Class<ApplicationNavModel>>,
HiddenApplication: '' as Ref<Class<HiddenApplication>>
HiddenApplication: '' as Ref<Class<HiddenApplication>>,
Widget: '' as Ref<Class<Widget>>,
WidgetPreference: '' as Ref<Class<WidgetPreference>>
},
mixin: {
SpaceView: '' as Ref<Mixin<SpaceView>>
@ -156,7 +201,10 @@ export default plugin(workbenchId, {
Archive: '' as IntlString,
View: '' as IntlString,
ServerUnderMaintenance: '' as IntlString,
UpgradeDownloadProgress: '' as IntlString
UpgradeDownloadProgress: '' as IntlString,
OpenInSidebar: '' as IntlString,
OpenInSidebarNewTab: '' as IntlString,
ConfigureWidgets: '' as IntlString
},
icon: {
Search: '' as Asset

View File

@ -257,14 +257,14 @@ test.describe('Channel tests', () => {
await channelPage.checkIfMessageExist(true, 'Test message')
})
test('Check if user can pin message', async () => {
test('Check if user can reply message', async () => {
await leftSideMenuPage.clickChunter()
await channelPage.clickChannel('random')
await channelPage.sendMessage('Test message')
await channelPage.replyToMessage('Test message', 'Reply message')
await channelPage.checkIfMessageExist(true, 'Reply message')
await channelPage.checkIfMessageExistInSidebar(true, 'Reply message')
await channelPage.closeAndOpenReplyMessage()
await channelPage.checkIfMessageExist(true, 'Reply message')
await channelPage.checkIfMessageExistInSidebar(true, 'Reply message')
})
test('Check if user can edit message', async ({ page }) => {
@ -498,13 +498,6 @@ test.describe('Channel tests', () => {
await channelPage.clickChannelTab()
})
await test.step('Search channel by first 3 char and find nothing', async () => {
await channelPage.searchChannel(data.channelName.slice(0, 3))
await channelPage.page.keyboard.press('Enter')
await channelPage.checkIfChannelTableExist(data.channelName, false)
await channelPage.checkIfChannelTableExist('general', false)
})
await test.step('Search channel by fillName and find channel', async () => {
await channelPage.searchChannel(data.channelName)
await channelPage.page.keyboard.press('Enter')

View File

@ -15,6 +15,9 @@ export class ChannelPage extends CommonPage {
readonly textMessage = (messageText: string): Locator =>
this.page.locator('.hulyComponent .activityMessage', { hasText: messageText })
readonly textMessageInSidebar = (messageText: string): Locator =>
this.page.locator('#sidebar .activityMessage', { hasText: messageText })
readonly channelName = (channel: string): Locator => this.page.getByText('general random').getByText(channel)
readonly channelTab = (): Locator => this.page.getByRole('link', { name: 'Channels' }).getByRole('button')
readonly channelTable = (): Locator => this.page.getByRole('table')
@ -280,6 +283,14 @@ export class ChannelPage extends CommonPage {
}
}
async checkIfMessageExistInSidebar (messageExists: boolean, messageText: string): Promise<void> {
if (messageExists) {
await expect(this.textMessageInSidebar(messageText)).toBeVisible()
} else {
await expect(this.textMessageInSidebar(messageText)).toBeHidden()
}
}
async checkIfEmojiIsAdded (emoji: string): Promise<void> {
await expect(this.selectEmoji(emoji + ' 1')).toBeVisible()
}

View File

@ -232,19 +232,15 @@ export class PlanningPage extends CalendarPage {
}
async openToDoByName (toDoName: string): Promise<void> {
await this.page.locator('button.hulyToDoLine-container div[class$="overflow-label"]', { hasText: toDoName }).click()
await this.page.locator(`button.hulyToDoLine-container:has-text("${toDoName}")`).click()
}
async checkToDoNotExist (toDoName: string): Promise<void> {
await expect(
this.page.locator('button.hulyToDoLine-container div[class$="overflow-label"]', { hasText: toDoName })
).toHaveCount(0)
await expect(this.page.locator(`button.hulyToDoLine-container:has-text("${toDoName}")`)).toHaveCount(0)
}
async checkToDoExist (toDoName: string): Promise<void> {
await expect(
this.page.locator('button.hulyToDoLine-container div[class$="overflow-label"]', { hasText: toDoName })
).toHaveCount(1)
await expect(this.page.locator(`button.hulyToDoLine-container:has-text("${toDoName}")`)).toHaveCount(1)
}
async checkToDo (data: NewToDo): Promise<void> {