Change room ui to document view (#7167)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-11-13 18:42:14 +04:00 committed by GitHub
parent e493d35ac6
commit 99a35e08db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 1451 additions and 620 deletions

View File

@ -28,6 +28,7 @@
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"dependencies": { "dependencies": {
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/activity": "^0.6.0", "@hcengineering/activity": "^0.6.0",
"@hcengineering/chunter": "^0.6.20", "@hcengineering/chunter": "^0.6.20",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",

View File

@ -14,7 +14,14 @@
// //
import contact, { type Employee, type Person } from '@hcengineering/contact' import contact, { type Employee, type Person } from '@hcengineering/contact'
import { AccountRole, type Domain, DOMAIN_TRANSIENT, IndexKind, type Ref } from '@hcengineering/core' import {
AccountRole,
type Domain,
DOMAIN_TRANSIENT,
IndexKind,
type Ref,
type CollaborativeDoc
} from '@hcengineering/core'
import { import {
type DevicesPreference, type DevicesPreference,
type Floor, type Floor,
@ -34,18 +41,21 @@ import {
} from '@hcengineering/love' } from '@hcengineering/love'
import { import {
type Builder, type Builder,
Collection,
Collection as PropCollection, Collection as PropCollection,
Hidden, Hidden,
Index, Index,
Mixin, Mixin,
Model, Model,
Prop, Prop,
ReadOnly,
TypeCollaborativeDoc,
TypeRef, TypeRef,
TypeString, TypeString,
UX UX
} from '@hcengineering/model' } from '@hcengineering/model'
import calendar, { TEvent } from '@hcengineering/model-calendar' import calendar, { TEvent } from '@hcengineering/model-calendar'
import core, { TDoc } from '@hcengineering/model-core' import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference' import preference, { TPreference } from '@hcengineering/model-preference'
import presentation from '@hcengineering/model-presentation' import presentation from '@hcengineering/model-presentation'
import view, { createAction } from '@hcengineering/model-view' import view, { createAction } from '@hcengineering/model-view'
@ -55,22 +65,32 @@ import setting from '@hcengineering/setting'
import workbench, { WidgetType } from '@hcengineering/workbench' import workbench, { WidgetType } from '@hcengineering/workbench'
import activity from '@hcengineering/activity' import activity from '@hcengineering/activity'
import chunter from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import attachment from '@hcengineering/attachment'
import love from './plugin' import love from './plugin'
export { loveId } from '@hcengineering/love' export { loveId } from '@hcengineering/love'
export * from './migration' export * from './migration'
export const DOMAIN_LOVE = 'love' as Domain export const DOMAIN_LOVE = 'love' as Domain
export const DOMAIN_MEETING_MINUTES = 'meeting-minutes' as Domain
@Model(love.class.Room, core.class.Doc, DOMAIN_LOVE) @Model(love.class.Room, core.class.Doc, DOMAIN_LOVE)
@UX(love.string.Room, love.icon.Love)
export class TRoom extends TDoc implements Room { export class TRoom extends TDoc implements Room {
@Prop(TypeString(), core.string.Name)
@Index(IndexKind.FullText)
name!: string name!: string
@Prop(TypeCollaborativeDoc(), core.string.Description)
@Index(IndexKind.FullText)
description!: CollaborativeDoc
type!: RoomType type!: RoomType
access!: RoomAccess access!: RoomAccess
@Prop(TypeRef(love.class.Floor), love.string.Floor) @Prop(TypeRef(love.class.Floor), love.string.Floor)
@ReadOnly()
// @Index(IndexKind.Indexed) // @Index(IndexKind.Indexed)
floor!: Ref<Floor> floor!: Ref<Floor>
@ -81,12 +101,20 @@ export class TRoom extends TDoc implements Room {
language!: RoomLanguage language!: RoomLanguage
startWithTranscription!: boolean startWithTranscription!: boolean
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
attachments?: number
@Prop(PropCollection(love.class.MeetingMinutes), love.string.MeetingMinutes)
meetings?: number
} }
@Model(love.class.Office, love.class.Room) @Model(love.class.Office, love.class.Room)
@UX(love.string.Office, love.icon.Love)
export class TOffice extends TRoom implements Office { export class TOffice extends TRoom implements Office {
@Prop(TypeRef(contact.mixin.Employee), contact.string.Employee) @Prop(TypeRef(contact.mixin.Employee), contact.string.Employee)
@Index(IndexKind.Indexed) @Index(IndexKind.Indexed)
@ReadOnly()
person!: Ref<Employee> | null person!: Ref<Employee> | null
} }
@ -155,22 +183,27 @@ export class TMeeting extends TEvent implements Meeting {
room!: Ref<Room> room!: Ref<Room>
} }
@Model(love.class.MeetingMinutes, core.class.Doc, DOMAIN_LOVE) @Model(love.class.MeetingMinutes, core.class.Doc, DOMAIN_MEETING_MINUTES)
@UX(love.string.Meeting) @UX(love.string.MeetingMinutes, love.icon.Cam)
export class TMeetingMinutes extends TDoc implements MeetingMinutes { export class TMeetingMinutes extends TAttachedDoc implements MeetingMinutes {
@Hidden() @Hidden()
sid!: string sid!: string
@Prop(TypeString(), view.string.Title) @Prop(TypeString(), view.string.Title)
@Index(IndexKind.FullText)
title!: string title!: string
@Prop(TypeRef(love.class.Room), love.string.Room) @Prop(TypeCollaborativeDoc(), core.string.Description)
room!: Ref<Room> @Index(IndexKind.FullText)
description!: CollaborativeDoc
@Prop(PropCollection(activity.class.ActivityMessage), love.string.Transcription) @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
attachments?: number
@Prop(PropCollection(chunter.class.ChatMessage), love.string.Transcription)
transcription?: number transcription?: number
@Prop(PropCollection(activity.class.ActivityMessage), activity.string.Messages) @Prop(PropCollection(chunter.class.ChatMessage), activity.string.Messages)
messages?: number messages?: number
} }
@ -383,15 +416,150 @@ export function createModel (builder: Builder): void {
} }
}) })
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: love.class.Room,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: love.class.Office,
components: { input: chunter.component.ChatMessageInput }
})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, { builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: love.class.MeetingMinutes, ofClass: love.class.MeetingMinutes,
components: { input: chunter.component.ChatMessageInput } components: { input: chunter.component.ChatMessageInput }
}) })
builder.mixin(love.class.MeetingMinutes, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(love.class.Room, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.ObjectPresenter, { builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.ObjectPresenter, {
presenter: love.component.MeetingMinutesPresenter presenter: love.component.MeetingMinutesPresenter
}) })
builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.CollectionEditor, {
editor: love.component.MeetingMinutesSection
})
builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: love.function.MeetingMinutesTitleProvider
})
builder.mixin(love.class.Room, core.class.Class, view.mixin.ObjectEditor, {
editor: love.component.EditRoom
})
builder.mixin(love.class.MeetingMinutes, core.class.Class, view.mixin.ObjectEditor, {
editor: love.component.EditMeetingMinutes
})
builder.mixin(love.class.Floor, core.class.Class, view.mixin.AttributeEditor, {
inlineEditor: love.component.FloorAttributePresenter
})
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: love.class.MeetingMinutes,
descriptor: view.viewlet.Table,
config: [
'',
{ key: 'messages', displayProps: { key: 'messages', suffix: true } },
{ key: 'transcription', displayProps: { key: 'transcription', suffix: true } },
'modifiedOn',
'modifiedBy'
],
configOptions: {
hiddenKeys: ['description'],
sortable: true
},
options: {}
},
love.viewlet.TableMeetingMinutes
)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: love.string.Floor,
icon: love.icon.Love,
component: love.component.FloorView
},
love.viewlet.FloorDescriptor
)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: love.string.MeetingMinutes,
icon: view.icon.Table,
component: love.component.MeetingMinutesTable
},
love.viewlet.MeetingMinutesDescriptor
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: love.class.Floor,
descriptor: love.viewlet.FloorDescriptor,
config: []
},
love.viewlet.Floor
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: love.class.Floor,
descriptor: love.viewlet.MeetingMinutesDescriptor,
config: []
},
love.viewlet.FloorMeetingMinutes
)
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
{
label: chunter.string.Chat,
generated: false,
hidden: false,
txClasses: [core.class.TxCreateDoc],
objectClass: chunter.class.ChatMessage,
attachedToClass: love.class.MeetingMinutes,
txMatch: {
'attributes.collection': 'messages'
},
defaultEnabled: false,
group: love.ids.LoveNotificationGroup
},
love.ids.MeetingMinutesChatNotification
)
builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, {
provider: notification.providers.InboxNotificationProvider,
ignoredTypes: [],
enabledTypes: [love.ids.MeetingMinutesChatNotification]
})
builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, {
provider: notification.providers.PushNotificationProvider,
ignoredTypes: [],
enabledTypes: [love.ids.MeetingMinutesChatNotification]
})
builder.mixin(love.class.MeetingMinutes, core.class.Class, notification.mixin.ClassCollaborators, {
fields: ['createdBy']
})
builder.mixin(love.class.Room, core.class.Class, core.mixin.IndexConfiguration, { builder.mixin(love.class.Room, core.class.Class, core.mixin.IndexConfiguration, {
indexes: [], indexes: [],
searchDisabled: true searchDisabled: true

View File

@ -14,9 +14,9 @@
// //
import contact from '@hcengineering/contact' import contact from '@hcengineering/contact'
import { type Space, TxOperations, type Ref } from '@hcengineering/core' import { type Space, TxOperations, type Ref, makeCollaborativeDoc } from '@hcengineering/core'
import drive from '@hcengineering/drive' import drive from '@hcengineering/drive'
import { RoomAccess, RoomType, createDefaultRooms, isOffice, loveId, type Floor } from '@hcengineering/love' import { RoomAccess, RoomType, createDefaultRooms, isOffice, loveId, type Floor, type Room } from '@hcengineering/love'
import { import {
createDefaultSpace, createDefaultSpace,
migrateSpace, migrateSpace,
@ -28,7 +28,7 @@ import {
} from '@hcengineering/model' } from '@hcengineering/model'
import core from '@hcengineering/model-core' import core from '@hcengineering/model-core'
import love from './plugin' import love from './plugin'
import { DOMAIN_LOVE } from '.' import { DOMAIN_LOVE, DOMAIN_MEETING_MINUTES } from '.'
async function createDefaultFloor (tx: TxOperations): Promise<void> { async function createDefaultFloor (tx: TxOperations): Promise<void> {
const current = await tx.findOne(love.class.Floor, { const current = await tx.findOne(love.class.Floor, {
@ -56,7 +56,7 @@ async function createRooms (client: MigrationUpgradeClient): Promise<void> {
const data = createDefaultRooms(employees.map((p) => p._id)) const data = createDefaultRooms(employees.map((p) => p._id))
for (const room of data) { for (const room of data) {
const _class = isOffice(room) ? love.class.Office : love.class.Room const _class = isOffice(room) ? love.class.Office : love.class.Room
await tx.createDoc(_class, core.space.Workspace, room) await tx.createDoc(_class, core.space.Workspace, room, room._id)
} }
} }
@ -79,7 +79,8 @@ async function createReception (client: MigrationUpgradeClient): Promise<void> {
x: 0, x: 0,
y: 0, y: 0,
language: 'en', language: 'en',
startWithTranscription: false startWithTranscription: false,
description: makeCollaborativeDoc(love.ids.Reception, 'description')
}, },
love.ids.Reception love.ids.Reception
) )
@ -109,15 +110,38 @@ export const loveOperation: MigrateOperation = {
) )
await client.update( await client.update(
DOMAIN_LOVE, DOMAIN_LOVE,
{ _class: love.class.Room, startWithTranscription: { $exists: false } }, { _class: love.class.Room, type: RoomType.Video, startWithTranscription: { $exists: false } },
{ startWithTranscription: true } { startWithTranscription: true }
) )
await client.update(
DOMAIN_LOVE,
{ _class: love.class.Room, startWithTranscription: { $exists: false } },
{ startWithTranscription: false }
)
await client.update( await client.update(
DOMAIN_LOVE, DOMAIN_LOVE,
{ _class: love.class.Office, startWithTranscription: { $exists: false } }, { _class: love.class.Office, startWithTranscription: { $exists: false } },
{ startWithTranscription: false } { startWithTranscription: false }
) )
} }
},
{
state: 'move-meeting-minutes',
func: async (client) => {
await client.move(DOMAIN_LOVE, { _class: love.class.MeetingMinutes }, DOMAIN_MEETING_MINUTES)
}
},
{
state: 'create-description-collaborative',
func: async (client) => {
const rooms = await client.find<Room>(DOMAIN_LOVE, { _class: { $in: [love.class.Room, love.class.Office] } })
for (const room of rooms) {
const description = room.description
if (description == null) {
await client.update(DOMAIN_LOVE, room, { description: makeCollaborativeDoc(room._id, 'description') })
}
}
}
} }
]) ])
}, },

View File

@ -13,9 +13,9 @@
// limitations under the License. // limitations under the License.
// //
import { type Doc, type Ref } from '@hcengineering/core' import { type Client, type Doc, type Ref } from '@hcengineering/core'
import { type NotificationGroup } from '@hcengineering/notification' import { type NotificationType, type NotificationGroup } from '@hcengineering/notification'
import { mergeIds } from '@hcengineering/platform' import { type Resource, mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui' import { type AnyComponent } from '@hcengineering/ui'
import { type ActionCategory, type ViewAction } from '@hcengineering/view' import { type ActionCategory, type ViewAction } from '@hcengineering/view'
import { loveId } from '@hcengineering/love' import { loveId } from '@hcengineering/love'
@ -43,6 +43,10 @@ export default mergeIds(loveId, love, {
}, },
ids: { ids: {
Settings: '' as Ref<Doc>, Settings: '' as Ref<Doc>,
LoveNotificationGroup: '' as Ref<NotificationGroup> LoveNotificationGroup: '' as Ref<NotificationGroup>,
MeetingMinutesChatNotification: '' as Ref<NotificationType>
},
function: {
MeetingMinutesTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
} }
}) })

View File

@ -27,14 +27,15 @@
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"dependencies": { "dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/love": "^0.6.0",
"@hcengineering/model": "^0.6.11", "@hcengineering/model": "^0.6.11",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/model-core": "^0.6.0", "@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-love": "^0.6.0", "@hcengineering/model-love": "^0.6.0",
"@hcengineering/love": "^0.6.0", "@hcengineering/platform": "^0.6.11",
"@hcengineering/server-love": "^0.6.0" "@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-love": "^0.6.0",
"@hcengineering/server-notification": "^0.6.1"
} }
} }

View File

@ -19,10 +19,19 @@ import { type Builder } from '@hcengineering/model'
import serverCore from '@hcengineering/server-core' import serverCore from '@hcengineering/server-core'
import love from '@hcengineering/love' import love from '@hcengineering/love'
import serverLove from '@hcengineering/server-love' import serverLove from '@hcengineering/server-love'
import serverNotification from '@hcengineering/server-notification'
export { serverLoveId } from '@hcengineering/server-love' export { serverLoveId } from '@hcengineering/server-love'
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.mixin(love.class.MeetingMinutes, core.class.Class, serverNotification.mixin.HTMLPresenter, {
presenter: serverLove.function.MeetingMinutesHTMLPresenter
})
builder.mixin(love.class.MeetingMinutes, core.class.Class, serverNotification.mixin.TextPresenter, {
presenter: serverLove.function.MeetingMinutesTextPresenter
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, { builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverLove.trigger.OnEmployee, trigger: serverLove.trigger.OnEmployee,
txMatch: { txMatch: {

View File

@ -371,6 +371,12 @@ export function fitPopupElement (
newProps.left = '1px' newProps.left = '1px'
newProps.right = '1px' newProps.right = '1px'
show = true show = true
} else if (element === 'full-centered') {
newProps.top = '20px'
newProps.bottom = '20px'
newProps.left = '20px'
newProps.right = '20px'
show = true
} else if (element === 'content' && contentPanel !== undefined) { } else if (element === 'content' && contentPanel !== undefined) {
const rect = contentPanel.getBoundingClientRect() const rect = contentPanel.getBoundingClientRect()
newProps.top = `${rect.top}px` newProps.top = `${rect.top}px`

View File

@ -231,7 +231,8 @@ export const posAlignment = [
'centered', 'centered',
'center', 'center',
'status', 'status',
'movable' 'movable',
'full-centered'
] as const ] as const
export type PopupPosAlignment = (typeof posAlignment)[number] export type PopupPosAlignment = (typeof posAlignment)[number]

View File

@ -15,24 +15,36 @@
<script lang="ts"> <script lang="ts">
import workbench, { Widget, WidgetTab } from '@hcengineering/workbench' import workbench, { Widget, WidgetTab } from '@hcengineering/workbench'
import { FilePreview, DownloadFileButton, FilePreviewPopup, FileTypeIcon } from '@hcengineering/presentation' import { FilePreview, DownloadFileButton, FilePreviewPopup, FileTypeIcon } from '@hcengineering/presentation'
import { Breadcrumbs, Button, closeTooltip, Header, IconOpen, showPopup } from '@hcengineering/ui' import { Breadcrumbs, Button, closeTooltip, Header, showPopup } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import attachment from '../plugin' import attachment from '../plugin'
export let widget: Widget export let widget: Widget
export let tab: WidgetTab export let tab: WidgetTab | undefined
$: file = tab.data?.file $: file = tab?.data?.file
$: fileName = tab.data?.name ?? '' $: fileName = tab?.data?.name ?? ''
$: contentType = tab.data?.contentType $: contentType = tab?.data?.contentType
$: metadata = tab.data?.metadata $: metadata = tab?.data?.metadata
async function closeTab (): Promise<void> { async function closeTab (): Promise<void> {
if (tab === undefined) return
const fn = await getResource(workbench.function.CloseWidgetTab) const fn = await getResource(workbench.function.CloseWidgetTab)
await fn(widget, tab.id) await fn(widget, tab.id)
} }
async function close (): Promise<void> {
const fn = await getResource(workbench.function.CloseWidget)
await fn(attachment.ids.PreviewWidget)
}
$: if (tab === undefined) {
void close()
} else if (tab.data === undefined) {
void closeTab()
}
</script> </script>
<Header <Header

View File

@ -18,7 +18,7 @@
export let icon: Asset export let icon: Asset
export let header: IntlString export let header: IntlString
export let label: IntlString export let label: IntlString | undefined = undefined
</script> </script>
<div class="root"> <div class="root">
@ -26,9 +26,11 @@
<div class="an-element__label header"> <div class="an-element__label header">
<Label label={header} /> <Label label={header} />
</div> </div>
{#if label}
<span class="an-element__label"> <span class="an-element__label">
<Label {label} /> <Label {label} />
</span> </span>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { Doc, getCurrentAccount, Ref } from '@hcengineering/core' import { Doc, getCurrentAccount, Ref } from '@hcengineering/core'
import notification, { DocNotifyContext } from '@hcengineering/notification' import notification, { DocNotifyContext } from '@hcengineering/notification'
import activity, { ActivityMessage, ActivityMessagesFilter, WithReferences } from '@hcengineering/activity' import activity, { ActivityMessage, WithReferences } from '@hcengineering/activity'
import { getClient, isSpace } from '@hcengineering/presentation' import { getClient, isSpace } from '@hcengineering/presentation'
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources' import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
import { location as locationStore } from '@hcengineering/ui' import { location as locationStore } from '@hcengineering/ui'
@ -26,10 +26,11 @@
import ReverseChannelScrollView from './ReverseChannelScrollView.svelte' import ReverseChannelScrollView from './ReverseChannelScrollView.svelte'
export let object: Doc export let object: Doc
export let context: DocNotifyContext | undefined export let context: DocNotifyContext | undefined = undefined
export let syncLocation = true export let syncLocation = true
export let autofocus = true export let autofocus = true
export let freeze = false export let freeze = false
export let readonly = false
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let collection: string | undefined = undefined export let collection: string | undefined = undefined
export let withInput: boolean = true export let withInput: boolean = true
@ -113,6 +114,7 @@
{autofocus} {autofocus}
loadMoreAllowed={!isDocChannel} loadMoreAllowed={!isDocChannel}
{withInput} {withInput}
{readonly}
{onReply} {onReply}
/> />
{/if} {/if}

View File

@ -27,6 +27,7 @@
export let threadId: Ref<ActivityMessage> | undefined export let threadId: Ref<ActivityMessage> | undefined
export let collection: string | undefined = undefined export let collection: string | undefined = undefined
export let withInput: boolean = true export let withInput: boolean = true
export let readonly: boolean = false
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
const notificationsClient = InboxNotificationsClientImpl.getClient() const notificationsClient = InboxNotificationsClientImpl.getClient()
@ -43,6 +44,7 @@
{#if renderChannel && visible} {#if renderChannel && visible}
<div class="channel" class:invisible={threadId !== undefined} style:height style:width> <div class="channel" class:invisible={threadId !== undefined} style:height style:width>
{#key object._id} {#key object._id}
<slot name="header" />
<Channel <Channel
{object} {object}
{context} {context}
@ -51,13 +53,14 @@
{collection} {collection}
{withInput} {withInput}
{onReply} {onReply}
{readonly}
/> />
{/key} {/key}
</div> </div>
{/if} {/if}
{#if threadId && visible} {#if threadId && visible}
<div class="thread" style:height style:width> <div class="thread" style:height style:width>
<ThreadView _id={threadId} syncLocation={false} {onReply} on:channel on:close /> <ThreadView _id={threadId} syncLocation={false} {onReply} {readonly} on:channel on:close />
</div> </div>
{/if} {/if}

View File

@ -42,6 +42,7 @@
export let context: DocNotifyContext | undefined export let context: DocNotifyContext | undefined
export let autofocus = true export let autofocus = true
export let embedded: boolean = false export let embedded: boolean = false
export let readonly: boolean = false
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -54,7 +55,7 @@
isThreadOpened = newLocation.path[4] != null isThreadOpened = newLocation.path[4] != null
}) })
$: readonly = hierarchy.isDerived(object._class, core.class.Space) ? (object as Space).archived : false $: readonly = hierarchy.isDerived(object._class, core.class.Space) ? readonly || (object as Space).archived : readonly
$: showJoinOverlay = shouldShowJoinOverlay(object) $: showJoinOverlay = shouldShowJoinOverlay(object)
$: isDocChat = !hierarchy.isDerived(object._class, chunter.class.ChunterSpace) $: isDocChat = !hierarchy.isDerived(object._class, chunter.class.ChunterSpace)
$: withAside = $: withAside =

View File

@ -54,6 +54,7 @@
export let loadMoreAllowed = true export let loadMoreAllowed = true
export let autofocus = true export let autofocus = true
export let withInput: boolean = true export let withInput: boolean = true
export let readonly: boolean = false
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
const minMsgHeightRem = 2 const minMsgHeightRem = 2
@ -113,7 +114,9 @@
$: notifyContext = $contextByDocStore.get(doc._id) $: notifyContext = $contextByDocStore.get(doc._id)
$: isThread = hierarchy.isDerived(doc._class, activity.class.ActivityMessage) $: isThread = hierarchy.isDerived(doc._class, activity.class.ActivityMessage)
$: isChunterSpace = hierarchy.isDerived(doc._class, chunter.class.ChunterSpace) $: isChunterSpace = hierarchy.isDerived(doc._class, chunter.class.ChunterSpace)
$: readonly = hierarchy.isDerived(channel._class, core.class.Space) ? (channel as Space).archived : false $: readonly = hierarchy.isDerived(channel._class, core.class.Space)
? readonly || (channel as Space).archived
: readonly
$: separatorIndex = $: separatorIndex =
$newTimestampStore !== undefined $newTimestampStore !== undefined
@ -575,7 +578,7 @@
removeTxListener(newMessageTxListener) removeTxListener(newMessageTxListener)
}) })
$: showBlankView = !$isLoadingStore && messages.length === 0 && !isThread && !readonly $: showBlankView = !$isLoadingStore && messages.length === 0 && !isThread
</script> </script>
<div class="flex-col relative" class:h-full={fullHeight}> <div class="flex-col relative" class:h-full={fullHeight}>
@ -597,7 +600,7 @@
<BlankView <BlankView
icon={chunter.icon.Thread} icon={chunter.icon.Thread}
header={chunter.string.NoMessagesInChannel} header={chunter.string.NoMessagesInChannel}
label={chunter.string.SendMessagesInChannel} label={readonly ? undefined : chunter.string.SendMessagesInChannel}
/> />
{/if} {/if}
@ -644,7 +647,7 @@
{#if loadMoreAllowed && $canLoadNextForwardStore} {#if loadMoreAllowed && $canLoadNextForwardStore}
<HistoryLoading isLoading={$isLoadingMoreStore} /> <HistoryLoading isLoading={$isLoadingMoreStore} />
{/if} {/if}
{#if !fixedInput && withInput} {#if !fixedInput && withInput && !readonly}
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} /> <ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} />
{/if} {/if}
</BaseChatScroller> </BaseChatScroller>
@ -661,10 +664,14 @@
{/if} {/if}
</div> </div>
{#if fixedInput && withInput} {#if fixedInput && withInput && !readonly}
<ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} /> <ChannelInput {object} {readonly} boundary={scrollDiv} {collection} {isThread} {autofocus} />
{/if} {/if}
{#if readonly}
<div class="h-6" />
{/if}
<style lang="scss"> <style lang="scss">
.selectedDate { .selectedDate {
position: absolute; position: absolute;

View File

@ -14,6 +14,7 @@
export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined export let selectedMessageId: Ref<ActivityMessage> | undefined = undefined
export let message: ActivityMessage export let message: ActivityMessage
export let autofocus = true export let autofocus = true
export let readonly: boolean = false
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
const client = getClient() const client = getClient()
@ -56,8 +57,8 @@
$: messagesStore = dataProvider?.messagesStore $: messagesStore = dataProvider?.messagesStore
$: readonly = hierarchy.isDerived(message.attachedToClass, core.class.Space) $: readonly = hierarchy.isDerived(message.attachedToClass, core.class.Space)
? (channel as Space)?.archived ?? false ? (readonly || (channel as Space)?.archived) ?? false
: false : readonly
</script> </script>
<div class="hulyComponent-content hulyComponent-content__container noShrink"> <div class="hulyComponent-content hulyComponent-content__container noShrink">

View File

@ -32,6 +32,7 @@
export let showHeader: boolean = true export let showHeader: boolean = true
export let syncLocation = true export let syncLocation = true
export let autofocus = true export let autofocus = true
export let readonly: boolean = false
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
const client = getClient() const client = getClient()
@ -145,7 +146,7 @@
{#if message} {#if message}
{#key _id} {#key _id}
<ThreadContent bind:selectedMessageId {message} {autofocus} {onReply} /> <ThreadContent bind:selectedMessageId {message} {autofocus} {readonly} {onReply} />
{/key} {/key}
{:else if isLoading} {:else if isLoading}
<Loading /> <Loading />

View File

@ -67,6 +67,10 @@
"Meeting": "Meeting", "Meeting": "Meeting",
"Transcription": "Transcription", "Transcription": "Transcription",
"StartWithTranscription": "Start with transcription", "StartWithTranscription": "Start with transcription",
"MeetingMinutes": "Meeting minutes" "MeetingMinutes": "Meeting minutes",
"StartMeeting": "Start meeting",
"Video": "Video",
"NoMeetingMinutes": "No meeting minutes",
"JoinMeeting": "Join meeting"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "Reunión", "Meeting": "Reunión",
"Transcription": "Transcripción", "Transcription": "Transcripción",
"StartWithTranscription": "Iniciar con transcripción", "StartWithTranscription": "Iniciar con transcripción",
"MeetingMinutes": "Minutos de la reunión" "MeetingMinutes": "Minutos de la reunión",
"StartMeeting": "Iniciar reunión",
"Video": "Video",
"NoMeetingMinutes": "Sin minutos de reunión",
"JoinMeeting": "Unirse a la reunión"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "Réunion", "Meeting": "Réunion",
"Transcription": "Transcription", "Transcription": "Transcription",
"StartWithTranscription": "Démarrer avec la transcription", "StartWithTranscription": "Démarrer avec la transcription",
"MeetingMinutes": "Minutes de la réunion" "MeetingMinutes": "Minutes de la réunion",
"StartMeeting": "Démarrer la réunion",
"Video": "Vidéo",
"NoMeetingMinutes": "Pas de minutes de réunion",
"JoinMeeting": "Rejoindre la réunion"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "Riunione", "Meeting": "Riunione",
"Transcription": "Trascrizione", "Transcription": "Trascrizione",
"StartWithTranscription": "Inizia con la trascrizione", "StartWithTranscription": "Inizia con la trascrizione",
"MeetingMinutes": "Verbale della riunione" "MeetingMinutes": "Verbale della riunione",
"StartMeeting": "Inizia riunione",
"Video": "Video",
"NoMeetingMinutes": "Nessun verbale della riunione",
"JoinMeeting": "Unisciti alla riunione"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "Reunião", "Meeting": "Reunião",
"Transcription": "Transcrição", "Transcription": "Transcrição",
"StartWithTranscription": "Começar com transcrição", "StartWithTranscription": "Começar com transcrição",
"MeetingMinutes": "Minutos da reunião" "MeetingMinutes": "Minutos da reunião",
"StartMeeting": "Iniciar reunião",
"Video": "Vídeo",
"NoMeetingMinutes": "Sem minutos de reunião",
"JoinMeeting": "Participar na reunião"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "Встреча", "Meeting": "Встреча",
"Transcription": "Транскрипция", "Transcription": "Транскрипция",
"StartWithTranscription": "Начинать с транскрипцией", "StartWithTranscription": "Начинать с транскрипцией",
"MeetingMinutes": "Протоколы встреч" "MeetingMinutes": "Результаты встреч",
"StartMeeting": "Начать встречу",
"Video": "Видео",
"NoMeetingMinutes": "Нет результатов встреч",
"JoinMeeting": "Присоединиться к встрече"
} }
} }

View File

@ -67,6 +67,10 @@
"Meeting": "会议", "Meeting": "会议",
"Transcription": "转录", "Transcription": "转录",
"StartWithTranscription": "开始转录", "StartWithTranscription": "开始转录",
"MeetingMinutes": "会议记录" "MeetingMinutes": "会议记录",
"StartMeeting": "开始会议",
"Video": "视频",
"NoMeetingMinutes": "无会议记录",
"JoinMeeting": "加入会议"
} }
} }

View File

@ -49,6 +49,8 @@
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/login": "^0.6.12", "@hcengineering/login": "^0.6.12",
"@hcengineering/love": "^0.6.0", "@hcengineering/love": "^0.6.0",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/notification-resources": "^0.6.0",
"@hcengineering/panel": "^0.6.23", "@hcengineering/panel": "^0.6.23",
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"@hcengineering/presentation": "^0.6.3", "@hcengineering/presentation": "^0.6.3",

View File

@ -0,0 +1,90 @@
<!--
// 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 { personByIdStore } from '@hcengineering/contact-resources'
import { Room as TypeRoom } from '@hcengineering/love'
import { getMetadata } from '@hcengineering/platform'
import { Label, Loading, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { onDestroy, onMount } from 'svelte'
import presentation, { getClient } from '@hcengineering/presentation'
import { EditDoc } from '@hcengineering/view-resources'
import { subscribeDoc } from '@hcengineering/notification-resources'
import love from '../plugin'
import { storePromise, currentRoom, infos, invites, myInfo, myRequests, meetingMinutesStore } from '../stores'
import { awaitConnect, isConnected, isCurrentInstanceConnected, isFullScreen, tryConnect } from '../utils'
import ControlBar from './ControlBar.svelte'
export let room: TypeRoom
let loading: boolean = false
let configured: boolean = false
onMount(async () => {
loading = true
const wsURL = getMetadata(love.metadata.WebSocketURL)
if (wsURL === undefined) {
return
}
configured = true
await $storePromise
if (
!$isConnected &&
!$isCurrentInstanceConnected &&
$myInfo?.sessionId === getMetadata(presentation.metadata.SessionId)
) {
const info = $infos.filter((p) => p.room === room._id)
await tryConnect($personByIdStore, $myInfo, room, info, $myRequests, $invites)
}
await awaitConnect()
loading = false
})
let replacedPanel: HTMLElement
$: $deviceInfo.replacedPanel = replacedPanel
onDestroy(() => ($deviceInfo.replacedPanel = undefined))
$: if ($meetingMinutesStore) {
void subscribeDoc(getClient(), $meetingMinutesStore._class, $meetingMinutesStore._id, 'add', $meetingMinutesStore)
}
</script>
<div class="antiPanel-component filledNav" bind:this={replacedPanel}>
<div class="hulyComponent">
{#if $isConnected && !$isCurrentInstanceConnected}
<div class="flex-center justify-center error h-full w-full clear-mins">
<Label label={love.string.AnotherWindowError} />
</div>
{:else if !configured}
<div class="flex-center justify-center error h-full w-full clear-mins">
<Label label={love.string.ServiceNotConfigured} />
</div>
{:else if loading || !$currentRoom || !$meetingMinutesStore}
<Loading />
{:else}
<EditDoc _id={$meetingMinutesStore._id} _class={$meetingMinutesStore._class} embedded selectedAside={false} />
{/if}
{#if $currentRoom}
<div class="flex-grow flex-shrink" />
<ControlBar room={$currentRoom} fullScreen={$isFullScreen} />
{/if}
</div>
</div>

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import core, { Class, Data, Ref } from '@hcengineering/core' import core, { Class, Data, generateId, makeCollaborativeDoc, Ref } from '@hcengineering/core'
import { translate } from '@hcengineering/platform' import { translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Button, DropdownIntlItem } from '@hcengineering/ui' import { Button, DropdownIntlItem } from '@hcengineering/ui'
@ -51,6 +51,7 @@
const client = getClient() const client = getClient()
const floorRooms = $rooms.filter((r) => r.floor === floor) const floorRooms = $rooms.filter((r) => r.floor === floor)
const pos = getFreePosition(floorRooms, 2, 1) const pos = getFreePosition(floorRooms, 2, 1)
const _id = generateId<Room>()
const data: Data<Room> = { const data: Data<Room> = {
floor, floor,
name: val._class === love.class.Office ? '' : await translate(val.label, {}), name: val._class === love.class.Office ? '' : await translate(val.label, {}),
@ -61,12 +62,13 @@
type: val.type, type: val.type,
access: val.access, access: val.access,
language: 'en', language: 'en',
startWithTranscription: val._class !== love.class.Office startWithTranscription: val._class !== love.class.Office && val.type === RoomType.Video,
description: makeCollaborativeDoc(_id, 'description')
} }
if (val._class === love.class.Office) { if (val._class === love.class.Office) {
;(data as Data<Office>).person = null ;(data as Data<Office>).person = null
} }
await client.createDoc(val._class, core.space.Workspace, data) await client.createDoc(val._class, core.space.Workspace, data, _id)
dispatch('close') dispatch('close')
} }
</script> </script>

View File

@ -27,14 +27,12 @@
showPopup, showPopup,
type AnySvelteComponent, type AnySvelteComponent,
type CompAndProps, type CompAndProps,
resizeObserver,
IconMoreV, IconMoreV,
ButtonMenu, ButtonMenu,
DropdownIntlItem DropdownIntlItem
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view, { Action } from '@hcengineering/view' import view, { Action } from '@hcengineering/view'
import { getActions } from '@hcengineering/view-resources' import { getActions } from '@hcengineering/view-resources'
import { afterUpdate } from 'svelte'
import love from '../plugin' import love from '../plugin'
import { currentRoom, myInfo, myOffice } from '../stores' import { currentRoom, myInfo, myOffice } from '../stores'
@ -61,19 +59,17 @@
import MicSettingPopup from './MicSettingPopup.svelte' import MicSettingPopup from './MicSettingPopup.svelte'
import RoomAccessPopup from './RoomAccessPopup.svelte' import RoomAccessPopup from './RoomAccessPopup.svelte'
import RoomLanguageSelector from './RoomLanguageSelector.svelte' import RoomLanguageSelector from './RoomLanguageSelector.svelte'
import ControlBarContainer from './ControlBarContainer.svelte'
export let room: Room export let room: Room
export let fullScreen: boolean = false export let fullScreen: boolean = false
export let onFullScreen: (() => void) | undefined = undefined
let allowCam: boolean = false let allowCam: boolean = false
const allowShare: boolean = true const allowShare: boolean = true
let allowLeave: boolean = false let allowLeave: boolean = false
let popup: CompAndProps | undefined = undefined let popup: CompAndProps | undefined = undefined
let grow: HTMLElement
let leftPanel: HTMLElement
let leftPanelSize: number = 0
let noLabel: boolean = false let noLabel: boolean = false
let combinePanel: boolean = false
$: allowCam = $currentRoom?.type === RoomType.Video $: allowCam = $currentRoom?.type === RoomType.Video
$: allowLeave = $myInfo?.room !== ($myOffice?._id ?? love.ids.Reception) $: allowLeave = $myInfo?.room !== ($myOffice?._id ?? love.ids.Reception)
@ -138,17 +134,6 @@
const camKeys = client.getModel().findAllSync(view.class.Action, { _id: love.action.ToggleVideo })?.[0]?.keyBinding const camKeys = client.getModel().findAllSync(view.class.Action, { _id: love.action.ToggleVideo })?.[0]?.keyBinding
const micKeys = client.getModel().findAllSync(view.class.Action, { _id: love.action.ToggleMic })?.[0]?.keyBinding const micKeys = client.getModel().findAllSync(view.class.Action, { _id: love.action.ToggleMic })?.[0]?.keyBinding
const checkBar = (): void => {
if (grow === undefined || leftPanel === undefined) return
if (!noLabel && leftPanel.clientWidth > leftPanelSize) leftPanelSize = leftPanel.clientWidth
if (grow.clientWidth - 16 < leftPanel.clientWidth && !noLabel && !combinePanel) noLabel = true
else if (grow.clientWidth - 16 < leftPanel.clientWidth && noLabel && !combinePanel) combinePanel = true
else if (grow.clientWidth * 2 - 32 > leftPanel.clientWidth && noLabel && combinePanel) combinePanel = false
else if (grow.clientWidth - 32 >= leftPanelSize && noLabel && !combinePanel) noLabel = false
}
afterUpdate(() => {
checkBar()
})
let actions: Action[] = [] let actions: Action[] = []
let moreItems: DropdownIntlItem[] = [] let moreItems: DropdownIntlItem[] = []
@ -173,15 +158,16 @@
const fn = await getResource(action.action) const fn = await getResource(action.action)
await fn(room) await fn(room)
} }
$: withVideo = $screenSharing || room.type === RoomType.Video
</script> </script>
<div class="bar w-full flex-center flex-gap-2 flex-no-shrink" class:combinePanel use:resizeObserver={checkBar}> <ControlBarContainer bind:noLabel>
<div class="bar__right-panel flex-gap-2 flex-center"> <svelte:fragment slot="right">
{#if $isConnected && isTranscriptionAllowed() && $isTranscription} {#if $isConnected && isTranscriptionAllowed() && $isTranscription}
<RoomLanguageSelector {room} kind="icon" /> <RoomLanguageSelector {room} kind="icon" />
{/if} {/if}
</div> </svelte:fragment>
<div bind:this={grow} class="flex-grow" /> <svelte:fragment slot="center">
{#if room._id !== love.ids.Reception} {#if room._id !== love.ids.Reception}
<ModernButton <ModernButton
icon={roomAccessIcon[room.access]} icon={roomAccessIcon[room.access]}
@ -251,8 +237,9 @@
/> />
{/if} {/if}
{/if} {/if}
<div bind:this={leftPanel} class="bar__left-panel flex-gap-2 flex-center"> </svelte:fragment>
{#if $isConnected} <svelte:fragment slot="left">
{#if $isConnected && withVideo && onFullScreen}
<ModernButton <ModernButton
icon={$isFullScreen ? love.icon.ExitFullScreen : love.icon.FullScreen} icon={$isFullScreen ? love.icon.ExitFullScreen : love.icon.FullScreen}
tooltip={{ tooltip={{
@ -287,8 +274,9 @@
on:click={leave} on:click={leave}
/> />
{/if} {/if}
</div> </svelte:fragment>
<div class="flex-grow" />
<svelte:fragment slot="extra">
{#if popup && fullScreen} {#if popup && fullScreen}
<PopupInstance <PopupInstance
is={popup.is} is={popup.is}
@ -304,37 +292,5 @@
{popup} {popup}
/> />
{/if} {/if}
</div> </svelte:fragment>
</ControlBarContainer>
<style lang="scss">
.bar {
overflow-x: auto;
position: relative;
padding: 1rem;
border-top: 1px solid var(--theme-divider-color);
&__left-panel {
position: absolute;
top: 0;
bottom: 0;
right: 1rem;
height: 100%;
}
&__right-panel {
position: absolute;
top: 0;
bottom: 0;
left: 1rem;
height: 100%;
}
&.combinePanel .bar__left-panel {
position: static;
}
&.combinePanel .bar__right-panel {
position: static;
}
}
</style>

View File

@ -0,0 +1,84 @@
<!--
// 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 { resizeObserver } from '@hcengineering/ui'
import { afterUpdate } from 'svelte'
export let noLabel: boolean = false
let grow: HTMLElement
let leftPanel: HTMLElement
let leftPanelSize: number = 0
let combinePanel: boolean = false
const checkBar = (): void => {
if (grow === undefined || leftPanel === undefined) return
if (!noLabel && leftPanel.clientWidth > leftPanelSize) leftPanelSize = leftPanel.clientWidth
if (grow.clientWidth - 16 < leftPanel.clientWidth && !noLabel && !combinePanel) noLabel = true
else if (grow.clientWidth - 16 < leftPanel.clientWidth && noLabel && !combinePanel) combinePanel = true
else if (grow.clientWidth * 2 - 32 > leftPanel.clientWidth && noLabel && combinePanel) combinePanel = false
else if (grow.clientWidth - 32 >= leftPanelSize && noLabel && !combinePanel) noLabel = false
}
afterUpdate(() => {
checkBar()
})
</script>
<div class="bar w-full flex-center flex-gap-2 flex-no-shrink" class:combinePanel use:resizeObserver={checkBar}>
<div class="bar__right-panel flex-gap-2 flex-center">
<slot name="right" />
</div>
<div bind:this={grow} class="flex-grow" />
<slot name="center" />
<div bind:this={leftPanel} class="bar__left-panel flex-gap-2 flex-center">
<slot name="left" />
</div>
<div class="flex-grow" />
<slot name="extra" />
</div>
<style lang="scss">
.bar {
overflow-x: auto;
position: relative;
padding: 1rem;
border-top: 1px solid var(--theme-divider-color);
&__left-panel {
position: absolute;
top: 0;
bottom: 0;
right: 1rem;
height: 100%;
}
&__right-panel {
position: absolute;
top: 0;
bottom: 0;
left: 1rem;
height: 100%;
}
&.combinePanel .bar__left-panel {
position: static;
}
&.combinePanel .bar__right-panel {
position: static;
}
}
</style>

View File

@ -20,7 +20,6 @@
Invite, Invite,
isOffice, isOffice,
JoinRequest, JoinRequest,
loveId,
Office, Office,
ParticipantInfo, ParticipantInfo,
RequestStatus, RequestStatus,
@ -40,14 +39,7 @@
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte'
import workbench from '@hcengineering/workbench' import workbench from '@hcengineering/workbench'
import { import { closeWidget, closeWidgetTab, sidebarStore } from '@hcengineering/workbench-resources'
closeWidget,
closeWidgetTab,
minimizeSidebar,
sidebarStore,
SidebarVariant,
updateWidgetState
} from '@hcengineering/workbench-resources'
import love from '../plugin' import love from '../plugin'
import { activeInvites, currentRoom, infos, myInfo, myInvites, myOffice, myRequests, rooms } from '../stores' import { activeInvites, currentRoom, infos, myInfo, myInvites, myOffice, myRequests, rooms } from '../stores'
@ -69,37 +61,6 @@
const client = getClient() const client = getClient()
// let allowCam: boolean = false
// let allowLeave: boolean = false
//
// $: allowCam = $currentRoom?.type === RoomType.Video
// $: allowLeave = $myInfo !== undefined && $myInfo.room !== ($myOffice?._id ?? love.ids.Reception)
// async function changeMute (): Promise<void> {
// if (!$isConnected || $currentRoom?.type === RoomType.Reception) return
// await setMic(!$isMicEnabled)
// }
//
// async function changeCam (): Promise<void> {
// if (!$isConnected || !allowCam) return
// await setCam(!$isCameraEnabled)
// }
//
// async function changeShare (): Promise<void> {
// if (!$isConnected) return
// await setShare(!$isSharingEnabled)
// }
//
// async function leave (): Promise<void> {
// showPopup(MessageBox, {
// label: love.string.LeaveRoom,
// message: love.string.LeaveRoomConfirmation,
// action: async () => {
// await leaveRoom($myInfo, $myOffice)
// }
// })
// }
interface ActiveRoom extends Room { interface ActiveRoom extends Room {
participants: ParticipantInfo[] participants: ParticipantInfo[]
} }
@ -124,18 +85,8 @@
return arr return arr
} }
// let selectedFloor: Floor | undefined = $floors.find((f) => f._id === $activeFloor)
// $: selectedFloor = $floors.find((f) => f._id === $activeFloor)
$: activeRooms = getActiveRooms($rooms, $infos) $: activeRooms = getActiveRooms($rooms, $infos)
// function selectFloor (): void {
// showPopup(FloorPopup, { selectedFloor }, myOfficeElement, (res) => {
// if (res === undefined) return
// selectedFloor = $floors.find((p) => p._id === res)
// })
// }
const query = createQuery() const query = createQuery()
let requests: JoinRequest[] = [] let requests: JoinRequest[] = []
query.query(love.class.JoinRequest, { status: RequestStatus.Pending }, (res) => { query.query(love.class.JoinRequest, { status: RequestStatus.Pending }, (res) => {
@ -261,32 +212,6 @@
$: checkActiveInvites($activeInvites) $: checkActiveInvites($activeInvites)
// function micSettings (e: MouseEvent): void {
// e.preventDefault()
// showPopup(MicSettingPopup, {}, eventToHTMLElement(e))
// }
//
// function camSettings (e: MouseEvent): void {
// e.preventDefault()
// showPopup(CamSettingPopup, {}, eventToHTMLElement(e))
// }
let prevLocation: Location = $location
$: isMeetingWidgetOpened = $sidebarStore.widgetsState.has(love.ids.MeetingWidget)
$: widgetState = $sidebarStore.widgetsState.get(love.ids.MeetingWidget)
$: if (
isMeetingWidgetOpened &&
$sidebarStore.widget === undefined &&
$location.path[2] !== loveId &&
widgetState !== undefined &&
widgetState.closedByUser !== true &&
widgetState.tabs.some(({ id }) => id === 'video')
) {
sidebarStore.update((s) => ({ ...s, widget: love.ids.MeetingWidget, variant: SidebarVariant.EXPANDED }))
updateWidgetState(love.ids.MeetingWidget, { openedByUser: false, tab: 'video' })
}
function checkActiveVideo (loc: Location, video: boolean, room: Ref<Room> | undefined): void { function checkActiveVideo (loc: Location, video: boolean, room: Ref<Room> | undefined): void {
const meetingWidgetState = $sidebarStore.widgetsState.get(love.ids.MeetingWidget) const meetingWidgetState = $sidebarStore.widgetsState.get(love.ids.MeetingWidget)
const isMeetingWidgetCreated = meetingWidgetState !== undefined const isMeetingWidgetCreated = meetingWidgetState !== undefined
@ -302,44 +227,18 @@
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: love.ids.MeetingWidget })[0] const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: love.ids.MeetingWidget })[0]
if (widget === undefined) return if (widget === undefined) return
// Create widget in sidebar if not created
if (!isMeetingWidgetCreated) { if (!isMeetingWidgetCreated) {
prevLocation = loc createMeetingWidget(widget, room, video)
createMeetingWidget(widget, room, loc, video)
} else if (video && !meetingWidgetState.tabs.some(({ id }) => id === 'video')) { } else if (video && !meetingWidgetState.tabs.some(({ id }) => id === 'video')) {
createMeetingVideoWidgetTab(widget, loc) createMeetingVideoWidgetTab(widget)
} else if (!video && meetingWidgetState.tabs.some(({ id }) => id === 'video')) { } else if (!video && meetingWidgetState.tabs.some(({ id }) => id === 'video')) {
void closeWidgetTab(widget, 'video') void closeWidgetTab(widget, 'video')
} }
// Show video in sidebar when leave office
if (
$sidebarStore.widget === love.ids.MeetingWidget &&
prevLocation.path[2] === loveId &&
loc.path[2] !== loveId &&
widgetState !== undefined &&
widgetState.tabs.some(({ id }) => id === 'video')
) {
updateWidgetState(love.ids.MeetingWidget, { openedByUser: false, tab: 'video' })
}
// Hide video in sidebar when open office app
if (
loc.path[2] === loveId &&
prevLocation.path[2] !== loveId &&
$sidebarStore.widget === love.ids.MeetingWidget &&
widgetState !== undefined &&
widgetState.tab === 'video'
) {
minimizeSidebar()
}
} else { } else {
if (isMeetingWidgetCreated) { if (isMeetingWidgetCreated) {
closeWidget(love.ids.MeetingWidget) closeWidget(love.ids.MeetingWidget)
} }
} }
prevLocation = loc
} }
$: checkActiveVideo( $: checkActiveVideo(
@ -384,56 +283,6 @@
</script> </script>
<div class="flex-row-center flex-gap-2"> <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>-->
{#if activeRooms.length > 0} {#if activeRooms.length > 0}
<!-- <div class="divider" />--> <!-- <div class="divider" />-->
{#each activeRooms as active} {#each activeRooms as active}

View File

@ -0,0 +1,52 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getClient } from '@hcengineering/presentation'
import { EditBox } from '@hcengineering/ui'
import { MeetingMinutes } from '@hcengineering/love'
import love from '../plugin'
export let object: MeetingMinutes
export let readonly: boolean = false
const client = getClient()
async function changeTitle (): Promise<void> {
await client.diffUpdate(object, { title: object.title })
}
</script>
<div class="flex-row-stretch">
<div class="flex-col flex-grow">
<div class="title">
<EditBox
disabled={readonly}
placeholder={love.string.MeetingMinutes}
bind:value={object.title}
on:change={changeTitle}
focusIndex={1}
/>
</div>
</div>
</div>
<style lang="scss">
.title {
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
}
</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 { getClient } from '@hcengineering/presentation'
import { EditBox, ModernButton, closePanel } from '@hcengineering/ui'
import { Room, isOffice } from '@hcengineering/love'
import { createEventDispatcher, onMount } from 'svelte'
import { personByIdStore } from '@hcengineering/contact-resources'
import { IntlString } from '@hcengineering/platform'
import love from '../plugin'
import { getRoomName, tryConnect } from '../utils'
import { infos, invites, myInfo, myRequests, selectedRoomPlace } from '../stores'
export let object: Room
export let readonly: boolean = false
const client = getClient()
const dispatch = createEventDispatcher()
let newName = getRoomName(object, $personByIdStore)
let connecting = false
async function changeName (): Promise<void> {
if (isOffice(object)) {
return
}
await client.diffUpdate(object, { name: newName })
}
onMount(() => {
dispatch('open', { ignoreKeys: ['name'] })
})
async function connect (): Promise<void> {
connecting = true
const place = $selectedRoomPlace
await tryConnect(
$personByIdStore,
$myInfo,
object,
$infos,
$myRequests,
$invites,
place?._id === object._id ? { x: place.x, y: place.y } : undefined
)
connecting = false
selectedRoomPlace.set(undefined)
closePanel()
dispatch('close')
}
let connectLabel: IntlString = love.string.StartMeeting
$: if ($infos.some(({ room }) => room === object._id) && !connecting) {
connectLabel = love.string.JoinMeeting
} else if (!connecting) {
connectLabel = love.string.StartMeeting
}
</script>
<div class="flex-row-stretch">
<div class="row flex-grow">
<div class="name">
<EditBox
disabled={readonly || isOffice(object)}
placeholder={love.string.Room}
on:change={changeName}
bind:value={newName}
focusIndex={1}
/>
</div>
<ModernButton label={connectLabel} size="large" kind={'primary'} on:click={connect} loading={connecting} />
</div>
</div>
<style lang="scss">
.name {
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
width: 100%;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-1);
justify-content: space-between;
}
</style>

View File

@ -13,23 +13,25 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { AccountRole, Ref, getCurrentAccount, hasAccountRole } from '@hcengineering/core' import { AccountRole, Ref, getCurrentAccount, hasAccountRole, WithLookup } from '@hcengineering/core'
import { Breadcrumb, Header, IconEdit, ModernButton, Switcher } from '@hcengineering/ui' import { Breadcrumb, Header, IconEdit, ModernButton, Component } from '@hcengineering/ui'
import { Floor, Room } from '@hcengineering/love' import { Floor, Room } from '@hcengineering/love'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { ViewletSelector } from '@hcengineering/view-resources'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
import lovePlg from '../plugin' import lovePlg from '../plugin'
import { currentRoom, floors } from '../stores' import { currentRoom, floors } from '../stores'
import ControlBar from './ControlBar.svelte' import ControlBar from './ControlBar.svelte'
import MeetingsTable from './MeetingMinutesTable.svelte'
import FloorView from './FloorView.svelte'
export let rooms: Room[] = [] export let rooms: Room[] = []
export let floor: Ref<Floor> export let floor: Ref<Floor>
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let selectedViewlet: 'meetingMinutes' | 'floor' = 'floor' let viewlet: WithLookup<Viewlet> | undefined
let preference: ViewletPreference | undefined
let loading = false
$: selectedFloor = $floors.filter((fl) => fl._id === floor)[0] $: selectedFloor = $floors.filter((fl) => fl._id === floor)[0]
@ -43,18 +45,7 @@
<Header allowFullsize adaptive={'disabled'}> <Header allowFullsize adaptive={'disabled'}>
<Breadcrumb title={selectedFloor?.name ?? ''} size={'large'} isCurrent /> <Breadcrumb title={selectedFloor?.name ?? ''} size={'large'} isCurrent />
<svelte:fragment slot="beforeTitle"> <svelte:fragment slot="beforeTitle">
<Switcher <ViewletSelector bind:viewlet bind:preference bind:loading viewletQuery={{ attachTo: lovePlg.class.Floor }} />
selected={selectedViewlet}
items={[
{ id: 'floor', icon: lovePlg.icon.Love, tooltip: lovePlg.string.Floor },
{ id: 'meetingMinutes', icon: view.icon.Table, tooltip: lovePlg.string.MeetingMinutes }
]}
kind="subtle"
name="selector"
on:select={(e) => {
selectedViewlet = e.detail.id
}}
/>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
{#if editable} {#if editable}
@ -68,10 +59,8 @@
</svelte:fragment> </svelte:fragment>
</Header> </Header>
<div class="hulyComponent-content__column content"> <div class="hulyComponent-content__column content">
{#if selectedViewlet === 'meetingMinutes'} {#if viewlet?.$lookup?.descriptor?.component}
<MeetingsTable /> <Component is={viewlet.$lookup.descriptor.component} props={{ floor, rooms }} on:open />
{:else}
<FloorView {rooms} />
{/if} {/if}
</div> </div>
{#if $currentRoom} {#if $currentRoom}

View File

@ -0,0 +1,36 @@
<!--
// 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 { ObjectMention } from '@hcengineering/view-resources'
import { Floor } from '@hcengineering/love'
import { Ref } from '@hcengineering/core'
import { floors } from '../stores'
export let value: Ref<Floor>
export let inline: boolean = false
$: floor = $floors.find((f) => f._id === value)
</script>
{#if floor}
{#if inline}
<ObjectMention object={floor} />
{:else}
<div class="flex-presenter overflow-label sm-tool-icon">
{floor.name}
</div>
{/if}
{/if}

View File

@ -35,7 +35,7 @@
<Scroller padding="1rem" bottomPadding="4rem" horizontal> <Scroller padding="1rem" bottomPadding="4rem" horizontal>
<FloorGrid bind:floorContainer {rows} preview> <FloorGrid bind:floorContainer {rows} preview>
{#each rooms as room} {#each rooms as room}
<RoomPreview {room} info={getInfo(room._id, $infos)} /> <RoomPreview {room} info={getInfo(room._id, $infos)} on:open />
{/each} {/each}
</FloorGrid> </FloorGrid>
</Scroller> </Scroller>

View File

@ -83,6 +83,11 @@
on:configure={() => (configure = false)} on:configure={() => (configure = false)}
/> />
{:else} {:else}
<Floor rooms={getRooms($rooms, selectedFloor)} floor={selectedFloor} on:configure={() => (configure = true)} /> <Floor
rooms={getRooms($rooms, selectedFloor)}
floor={selectedFloor}
on:configure={() => (configure = true)}
on:open
/>
{/if} {/if}
</div> </div>

View File

@ -13,13 +13,13 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { RoomType } from '@hcengineering/love'
import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { currentRoom } from '../stores' import { onDestroy } from 'svelte'
import { screenSharing } from '../utils'
import Hall from './Hall.svelte' import Hall from './Hall.svelte'
import RoomComponent from './Room.svelte' import { currentRoom } from '../stores'
import { onMount, onDestroy } from 'svelte' import { isConnected } from '../utils'
import ActiveMeeting from './ActiveMeeting.svelte'
const localNav: boolean = $deviceInfo.navigator.visible const localNav: boolean = $deviceInfo.navigator.visible
const savedNav = localStorage.getItem('love-visibleNav') const savedNav = localStorage.getItem('love-visibleNav')
@ -31,9 +31,9 @@
}) })
</script> </script>
<div class="hulyPanels-container" class:left-divider={$screenSharing || $currentRoom?.type === RoomType.Video}> <div class="hulyPanels-container">
{#if ($currentRoom !== undefined && $screenSharing) || $currentRoom?.type === RoomType.Video} {#if $currentRoom && $isConnected}
<RoomComponent withVideo={$currentRoom.type === RoomType.Video} room={$currentRoom} /> <ActiveMeeting room={$currentRoom} />
{:else} {:else}
<Hall /> <Hall />
{/if} {/if}

View File

@ -0,0 +1,46 @@
<!--
// 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 type { Class, Doc, Ref, Space } from '@hcengineering/core'
import { Label, Section } from '@hcengineering/ui'
import { Table } from '@hcengineering/view-resources'
import love from '@hcengineering/love'
export let objectId: Ref<Doc>
export let space: Ref<Space>
export let _class: Ref<Class<Doc>>
export let readonly: boolean = false
export let meetings: number
</script>
<Section label={love.string.MeetingMinutes} icon={love.icon.Cam}>
<svelte:fragment slot="content">
{#if meetings > 0}
<Table
_class={love.class.MeetingMinutes}
config={['', 'transcription', 'messages']}
query={{ attachedTo: objectId }}
loadingProps={{ length: meetings }}
{readonly}
/>
{:else}
<div class="antiSection-empty solid flex-col mt-3">
<span class="content-dark-color">
<Label label={love.string.NoMeetingMinutes} />
</span>
</div>
{/if}
</svelte:fragment>
</Section>

View File

@ -1,21 +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"> <script lang="ts">
import { TableBrowser } from '@hcengineering/view-resources' import { Floor, Room } from '@hcengineering/love'
import { Component } from '@hcengineering/ui'
import view, { Viewlet, ViewletPreference, ViewOptions } from '@hcengineering/view'
import core, { WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import love from '../plugin' import lovePlg from '../plugin'
export let floor: Floor
export let rooms: Room[] = []
const client = getClient()
let viewlet: WithLookup<Viewlet> | undefined
let viewOptions: ViewOptions | undefined
let preference: ViewletPreference | undefined
const preferenceQuery = createQuery()
void client
.findAll(
view.class.Viewlet,
{ _id: lovePlg.viewlet.TableMeetingMinutes },
{ lookup: { descriptor: view.class.ViewletDescriptor } }
)
.then((res) => {
viewlet = res[0]
})
$: preferenceQuery.query(
view.class.ViewletPreference,
{
space: core.space.Workspace,
attachedTo: lovePlg.viewlet.TableMeetingMinutes
},
(res) => {
preference = res[0]
},
{ limit: 1 }
)
</script> </script>
<TableBrowser _class={love.class.MeetingMinutes} query={{}} config={['', 'modifiedOn']} /> {#if viewlet?.$lookup?.descriptor?.component}
<Component
is={viewlet.$lookup.descriptor.component}
props={{
_class: lovePlg.class.MeetingMinutes,
config: preference?.config ?? viewlet.config,
options: viewlet.options,
query: { attachedTo: { $in: rooms.map((p) => p._id) } },
viewlet,
viewOptions,
viewOptionsConfig: viewlet.viewOptions?.other,
enableChecking: false
}}
/>
{/if}

View File

@ -327,21 +327,57 @@
const handleFullScreen = () => ($isFullScreen = document.fullscreenElement != null) const handleFullScreen = () => ($isFullScreen = document.fullscreenElement != null)
function toggleFullscreen () { function checkFullscreen (): void {
if (!document.fullscreenElement) { const needFullScreen = $isFullScreen
if (document.fullscreenElement && !needFullScreen) {
document
.exitFullscreen()
.then(() => {
$isFullScreen = false
})
.catch((err) => {
console.log(`Error exiting fullscreen mode: ${err.message} (${err.name})`)
$isFullScreen = false
})
} else if (!document.fullscreenElement && needFullScreen) {
roomEl roomEl
.requestFullscreen() .requestFullscreen()
.then(() => ($isFullScreen = true)) .then(() => {
$isFullScreen = true
})
.catch((err) => { .catch((err) => {
console.log(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`) console.log(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`)
$isFullScreen = false $isFullScreen = false
}) })
} else { }
document.exitFullscreen() }
function onFullScreen (): void {
const needFullScreen = !$isFullScreen
if (!document.fullscreenElement && needFullScreen) {
roomEl
.requestFullscreen()
.then(() => {
$isFullScreen = true
})
.catch((err) => {
console.log(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`)
$isFullScreen = false $isFullScreen = false
})
} else if (!needFullScreen) {
document
.exitFullscreen()
.then(() => {
$isFullScreen = false
})
.catch((err) => {
console.log(`Error exiting fullscreen mode: ${err.message} (${err.name})`)
$isFullScreen = false
})
} }
} }
$: if (((document.fullscreenElement && !$isFullScreen) || $isFullScreen) && roomEl) toggleFullscreen()
$: if (((document.fullscreenElement && !$isFullScreen) || $isFullScreen) && roomEl) checkFullscreen()
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] { function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
return participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id)) return participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id))
@ -396,7 +432,7 @@
{/if} {/if}
</div> </div>
{#if $currentRoom} {#if $currentRoom}
<ControlBar room={$currentRoom} fullScreen={$isFullScreen} /> <ControlBar room={$currentRoom} fullScreen={$isFullScreen} {onFullScreen} />
{/if} {/if}
</div> </div>

View File

@ -0,0 +1,43 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import presentation from '@hcengineering/presentation'
import { Modal } from '@hcengineering/ui'
import love, { RoomType } from '@hcengineering/love'
import { currentRoom } from '../stores'
import RoomComponent from './Room.svelte'
import { screenSharing } from '../utils'
const dispatch = createEventDispatcher()
$: if ($currentRoom === undefined || (!$screenSharing && $currentRoom.type !== RoomType.Video)) {
dispatch('close')
}
</script>
{#if ($currentRoom !== undefined && $screenSharing) || $currentRoom?.type === RoomType.Video}
<Modal
label={love.string.Room}
type="type-popup"
okLabel={presentation.string.Create}
hideFooter
padding="0"
on:close={() => dispatch('close')}
>
<RoomComponent withVideo={$currentRoom.type === RoomType.Video} room={$currentRoom} />
</Modal>
{/if}

View File

@ -17,11 +17,15 @@
import { Avatar, personByIdStore } from '@hcengineering/contact-resources' import { Avatar, personByIdStore } from '@hcengineering/contact-resources'
import { IdMap, getCurrentAccount } from '@hcengineering/core' import { IdMap, getCurrentAccount } from '@hcengineering/core'
import { isOffice, ParticipantInfo, Room, RoomAccess, RoomType } from '@hcengineering/love' import { isOffice, ParticipantInfo, Room, RoomAccess, RoomType } from '@hcengineering/love'
import { Icon, Label, eventToHTMLElement, showPopup, DropdownIntlItem } from '@hcengineering/ui' import { Icon, Label, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { getClient } from '@hcengineering/presentation'
import { openDoc } from '@hcengineering/view-resources'
import { get } from 'svelte/store'
import love from '../plugin' import love from '../plugin'
import { invites, myInfo, myRequests } from '../stores' import { myInfo, selectedRoomPlace, currentRoom, meetingMinutesStore } from '../stores'
import { getRoomLabel, tryConnect } from '../utils' import { getRoomLabel, lk } from '../utils'
import PersonActionPopup from './PersonActionPopup.svelte' import PersonActionPopup from './PersonActionPopup.svelte'
import RoomLanguage from './RoomLanguage.svelte' import RoomLanguage from './RoomLanguage.svelte'
@ -36,7 +40,6 @@
const meName = $personByIdStore.get(me.person)?.name const meName = $personByIdStore.get(me.person)?.name
const meAvatar = $personByIdStore.get(me.person) const meAvatar = $personByIdStore.get(me.person)
let container: HTMLDivElement
let hoveredRoomX: number | undefined = undefined let hoveredRoomX: number | undefined = undefined
let hoveredRoomY: number | undefined = undefined let hoveredRoomY: number | undefined = undefined
@ -61,12 +64,23 @@
hovered = false hovered = false
} }
function clickHandler (e: MouseEvent, x: number, y: number, person: Person | undefined): void { async function clickHandler (e: MouseEvent, x: number, y: number, person: Person | undefined): Promise<void> {
if (person !== undefined) { if (person !== undefined) {
if (room._id === $myInfo?.room || $myInfo === undefined) return if (room._id === $myInfo?.room || $myInfo === undefined) return
showPopup(PersonActionPopup, { room, person: person._id }, eventToHTMLElement(e)) showPopup(PersonActionPopup, { room, person: person._id }, eventToHTMLElement(e))
} else { } else {
void tryConnect($personByIdStore, $myInfo, room, info, $myRequests, $invites, { x, y }) const client = getClient()
const hierarchy = client.getHierarchy()
if ($currentRoom?._id === room._id) {
const sid = await lk.getSid()
const meetingMinutes =
get(meetingMinutesStore) ?? (await client.findOne(love.class.MeetingMinutes, { sid, attachedTo: room._id }))
if (meetingMinutes === undefined) return
await openDoc(hierarchy, meetingMinutes)
} else {
selectedRoomPlace.set({ _id: room._id, x, y })
await openDoc(hierarchy, room)
}
} }
} }
@ -112,7 +126,6 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-mouse-events-have-key-events --> <!-- svelte-ignore a11y-mouse-events-have-key-events -->
<div <div
bind:this={container}
class="floorGrid-room" class="floorGrid-room"
class:preview class:preview
class:hovered class:hovered

View File

@ -28,6 +28,12 @@
TrackPublication TrackPublication
} from 'livekit-client' } from 'livekit-client'
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte' import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { Ref } from '@hcengineering/core'
import { MessageBox } from '@hcengineering/presentation'
import { Person, PersonAccount } from '@hcengineering/contact'
import aiBot from '@hcengineering/ai-bot'
import { personIdByAccountId } from '@hcengineering/contact-resources'
import love from '../plugin' import love from '../plugin'
import { currentRoom, infos, myInfo, myOffice } from '../stores' import { currentRoom, infos, myInfo, myOffice } from '../stores'
import { import {
@ -44,8 +50,6 @@
setShare setShare
} from '../utils' } from '../utils'
import ParticipantView from './ParticipantView.svelte' import ParticipantView from './ParticipantView.svelte'
import { Ref } from '@hcengineering/core'
import { MessageBox } from '@hcengineering/presentation'
export let isDock: boolean = false export let isDock: boolean = false
export let room: Ref<TypeRoom> export let room: Ref<TypeRoom>
@ -57,8 +61,12 @@
muted: boolean muted: boolean
mirror: boolean mirror: boolean
connecting: boolean connecting: boolean
isAgent: boolean
} }
let aiPersonId: Ref<Person> | undefined = undefined
$: aiPersonId = $personIdByAccountId.get(aiBot.account.AIBot as Ref<PersonAccount>)
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let participants: ParticipantData[] = [] let participants: ParticipantData[] = []
@ -126,7 +134,8 @@
name: participant.name ?? '', name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled, muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal, mirror: participant.isLocal,
connecting: false connecting: false,
isAgent: participant.isAgent
}) })
} }
participants = participants participants = participants
@ -149,7 +158,8 @@
name: participant.name ?? '', name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled, muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal, mirror: participant.isLocal,
connecting: false connecting: false,
isAgent: participant.isAgent
} }
participants.push(value) participants.push(value)
participants = participants participants = participants
@ -227,7 +237,8 @@
name: info.name, name: info.name,
muted: true, muted: true,
mirror: false, mirror: false,
connecting: true connecting: true,
isAgent: info.person === aiPersonId
} }
participants.push(value) participants.push(value)
} }
@ -291,6 +302,12 @@
} }
}, 10) }, 10)
} }
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
return participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id))
}
$: activeParticipants = getActiveParticipants(participants)
</script> </script>
<div class="antiPopup videoPopup-container" class:isDock> <div class="antiPopup videoPopup-container" class:isDock>
@ -346,7 +363,7 @@
<video class="screen" bind:this={screen}></video> <video class="screen" bind:this={screen}></video>
</div> </div>
<Scroller bind:divScroll noStretch padding={'0 .5rem'} gap={'flex-gap-2'} onResize={dispatchFit} stickedScrollBars> <Scroller bind:divScroll noStretch padding={'0 .5rem'} gap={'flex-gap-2'} onResize={dispatchFit} stickedScrollBars>
{#each participants as participant, i (participant._id)} {#each activeParticipants as participant, i (participant._id)}
<div class="video"> <div class="video">
<ParticipantView bind:this={participantElements[i]} {...participant} small /> <ParticipantView bind:this={participantElements[i]} {...participant} small />
</div> </div>

View File

@ -13,13 +13,15 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import love, { MeetingMinutes } from '@hcengineering/love' import love, { MeetingMinutes, Room } from '@hcengineering/love'
import { ChannelEmbeddedContent } from '@hcengineering/chunter-resources' import { ChannelEmbeddedContent } from '@hcengineering/chunter-resources'
import { ActivityMessage } from '@hcengineering/activity' import { ActivityMessage } from '@hcengineering/activity'
import { updateTabData, WidgetState } from '@hcengineering/workbench-resources' import { updateTabData, WidgetState } from '@hcengineering/workbench-resources'
import MeetingWidgetHeader from './MeetingWidgetHeader.svelte'
export let widgetState: WidgetState export let widgetState: WidgetState
export let meetingMinutes: MeetingMinutes export let meetingMinutes: MeetingMinutes
export let room: Room
export let height: string export let height: string
export let width: string export let width: string
@ -28,7 +30,6 @@
} }
function closeThread (): void { function closeThread (): void {
console.log('closeThread')
updateTabData(love.ids.MeetingWidget, 'chat', { thread: undefined }) updateTabData(love.ids.MeetingWidget, 'chat', { thread: undefined })
} }
</script> </script>
@ -42,4 +43,8 @@
on:channel={closeThread} on:channel={closeThread}
onReply={replyToThread} onReply={replyToThread}
on:close on:close
/> >
<svelte:fragment slot="header">
<MeetingWidgetHeader doc={meetingMinutes} {room} on:close />
</svelte:fragment>
</ChannelEmbeddedContent>

View File

@ -14,15 +14,14 @@
--> -->
<script lang="ts"> <script lang="ts">
import { closeWidget, minimizeSidebar, WidgetState } from '@hcengineering/workbench-resources' import { closeWidget, minimizeSidebar, WidgetState } from '@hcengineering/workbench-resources'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery } from '@hcengineering/presentation'
import core, { Ref } from '@hcengineering/core'
import { MeetingMinutes, Room } from '@hcengineering/love' import { MeetingMinutes, Room } from '@hcengineering/love'
import { Loading } from '@hcengineering/ui' import { Loading } from '@hcengineering/ui'
import love from '../../plugin' import love from '../../plugin'
import VideoTab from './VideoTab.svelte' import VideoTab from './VideoTab.svelte'
import { isCurrentInstanceConnected, lk } from '../../utils' import { isCurrentInstanceConnected, lk } from '../../utils'
import { rooms } from '../../stores' import { currentRoom, meetingMinutesStore } from '../../stores'
import ChatTab from './ChatTab.svelte' import ChatTab from './ChatTab.svelte'
import TranscriptionTab from './TranscriptionTab.svelte' import TranscriptionTab from './TranscriptionTab.svelte'
@ -31,39 +30,38 @@
export let width: string export let width: string
const meetingQuery = createQuery() const meetingQuery = createQuery()
const client = getClient()
let meetingMinutes: MeetingMinutes | undefined = undefined let meetingMinutes: MeetingMinutes | undefined = undefined
let isMeetingMinutesLoaded = false let isMeetingMinutesLoaded = false
let roomId: Ref<Room> | undefined = undefined
let room: Room | undefined = undefined let room: Room | undefined = undefined
let sid: string | undefined = undefined let sid: string | undefined = undefined
$: roomId = widgetState?.data?.room $: room = $currentRoom
$: room = roomId !== undefined ? $rooms.find((r) => r._id === roomId) : undefined
void lk.getSid().then((res) => { void lk.getSid().then((res) => {
sid = res sid = res
}) })
$: if (!$isCurrentInstanceConnected || widgetState?.data?.room === undefined) { $: if (
!$isCurrentInstanceConnected ||
widgetState?.data?.room === undefined ||
$currentRoom === undefined ||
$currentRoom._id !== widgetState?.data?.room
) {
closeWidget(love.ids.MeetingWidget) closeWidget(love.ids.MeetingWidget)
} }
$: if (roomId !== meetingMinutes?.room) { $: if (meetingMinutes?.sid !== sid) {
meetingMinutes = undefined meetingMinutes = undefined
isMeetingMinutesLoaded = false isMeetingMinutesLoaded = false
} }
$: if ($isCurrentInstanceConnected && room && sid) { $: if (sid != null && room !== undefined) {
meetingQuery.query(love.class.MeetingMinutes, { room: room._id, sid }, async (res) => { meetingQuery.query(love.class.MeetingMinutes, { sid, attachedTo: room._id }, async (res) => {
meetingMinutes = res[0] meetingMinutes = res[0]
if (meetingMinutes !== undefined) { meetingMinutesStore.set(meetingMinutes)
isMeetingMinutesLoaded = true isMeetingMinutesLoaded = true
} else {
void createMeetingMinutes()
}
}) })
} else { } else {
meetingQuery.unsubscribe() meetingQuery.unsubscribe()
@ -71,16 +69,6 @@
isMeetingMinutesLoaded = sid !== undefined isMeetingMinutesLoaded = sid !== undefined
} }
async function createMeetingMinutes (): Promise<void> {
if (sid === undefined || room === undefined) return
const dateStr = new Date().toISOString().replace('T', '_').slice(0, 19)
await client.createDoc(love.class.MeetingMinutes, core.space.Workspace, {
title: room.name + '_' + dateStr,
room: room._id,
sid
})
}
function handleClose (): void { function handleClose (): void {
minimizeSidebar() minimizeSidebar()
} }
@ -88,18 +76,18 @@
{#if widgetState && room} {#if widgetState && room}
{#if widgetState.tab === 'video'} {#if widgetState.tab === 'video'}
<VideoTab {room} /> <VideoTab {room} doc={meetingMinutes} on:close={handleClose} />
{:else if widgetState.tab === 'chat'} {:else if widgetState.tab === 'chat'}
{#if !isMeetingMinutesLoaded} {#if !isMeetingMinutesLoaded}
<Loading /> <Loading />
{:else if meetingMinutes} {:else if meetingMinutes}
<ChatTab {meetingMinutes} {widgetState} {height} {width} on:close={handleClose} /> <ChatTab {meetingMinutes} {room} {widgetState} {height} {width} on:close={handleClose} />
{/if} {/if}
{:else if widgetState.tab === 'transcription'} {:else if widgetState.tab === 'transcription'}
{#if !isMeetingMinutesLoaded} {#if !isMeetingMinutesLoaded}
<Loading /> <Loading />
{:else if meetingMinutes} {:else if meetingMinutes}
<TranscriptionTab {meetingMinutes} {widgetState} {height} {width} on:close={handleClose} /> <TranscriptionTab {meetingMinutes} {room} {widgetState} {height} {width} on:close={handleClose} />
{/if} {/if}
{/if} {/if}
{/if} {/if}

View File

@ -0,0 +1,63 @@
<!--
// 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,
BreadcrumbItem,
IconMaximize,
ButtonIcon,
showPopup,
PopupResult
} from '@hcengineering/ui'
import { MeetingMinutes, Room, RoomType } from '@hcengineering/love'
import { onDestroy } from 'svelte'
import love from '../../plugin'
import RoomModal from '../RoomModal.svelte'
import { currentRoom } from '../../stores'
import { screenSharing } from '../../utils'
export let room: Room
export let doc: MeetingMinutes | undefined = undefined
let breadcrumbs: BreadcrumbItem[]
let popup: PopupResult | undefined
$: breadcrumbs = [
{
id: 'meeting',
icon: love.icon.Cam,
title: doc?.title ?? room.name
}
]
function maximize (): void {
popup = showPopup(RoomModal, { room }, 'full-centered')
}
onDestroy(() => {
popup?.close()
})
</script>
<Header type={'type-aside'} adaptive={'disabled'} closeOnEscape={false} on:close>
<Breadcrumbs items={breadcrumbs} currentOnly />
<svelte:fragment slot="actions">
{#if ($currentRoom !== undefined && $screenSharing) || $currentRoom?.type === RoomType.Video}
<ButtonIcon icon={IconMaximize} kind="tertiary" size="small" noPrint on:click={maximize} />
{/if}
</svelte:fragment>
</Header>

View File

@ -13,31 +13,28 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import love, { MeetingMinutes } from '@hcengineering/love' import { MeetingMinutes, Room } from '@hcengineering/love'
import { ChannelEmbeddedContent } from '@hcengineering/chunter-resources' import { ChannelEmbeddedContent } from '@hcengineering/chunter-resources'
import { ActivityMessage } from '@hcengineering/activity' import { WidgetState } from '@hcengineering/workbench-resources'
import { updateTabData, WidgetState } from '@hcengineering/workbench-resources' import MeetingWidgetHeader from './MeetingWidgetHeader.svelte'
export let widgetState: WidgetState export let widgetState: WidgetState
export let meetingMinutes: MeetingMinutes export let meetingMinutes: MeetingMinutes
export let room: Room
export let height: string export let height: string
export let width: string export let width: string
function replyToThread (message: ActivityMessage): void {
updateTabData(love.ids.MeetingWidget, 'transcription', { thread: message._id })
}
function closeThread (): void {
updateTabData(love.ids.MeetingWidget, 'transcription', { thread: undefined })
}
</script> </script>
<ChannelEmbeddedContent <ChannelEmbeddedContent
{width} {width}
{height} {height}
readonly
object={meetingMinutes} object={meetingMinutes}
threadId={widgetState.tabs.find((tab) => tab.id === 'transcription')?.data?.thread} threadId={undefined}
collection="transcription" collection="transcription"
on:channel={closeThread}
onReply={replyToThread}
on:close on:close
/> >
<svelte:fragment slot="header">
<MeetingWidgetHeader doc={meetingMinutes} {room} on:close />
</svelte:fragment>
</ChannelEmbeddedContent>

View File

@ -13,13 +13,16 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Room } from '@hcengineering/love' import { MeetingMinutes, Room } from '@hcengineering/love'
import VideoPopup from '../VideoPopup.svelte' import VideoPopup from '../VideoPopup.svelte'
import MeetingWidgetHeader from './MeetingWidgetHeader.svelte'
export let room: Room export let room: Room
export let doc: MeetingMinutes | undefined = undefined
</script> </script>
<MeetingWidgetHeader {doc} {room} on:close />
<div class="root"> <div class="root">
<VideoPopup room={room._id} isDock canUnpin={false} /> <VideoPopup room={room._id} isDock canUnpin={false} />
</div> </div>

View File

@ -12,6 +12,12 @@ import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import LoveWidget from './components/LoveWidget.svelte' import LoveWidget from './components/LoveWidget.svelte'
import MeetingWidget from './components/widget/MeetingWidget.svelte' import MeetingWidget from './components/widget/MeetingWidget.svelte'
import MeetingMinutesPresenter from './components/MeetingMinutesPresenter.svelte' import MeetingMinutesPresenter from './components/MeetingMinutesPresenter.svelte'
import MeetingMinutesSection from './components/MeetingMinutesSection.svelte'
import EditMeetingMinutes from './components/EditMeetingMinutes.svelte'
import EditRoom from './components/EditRoom.svelte'
import FloorAttributePresenter from './components/FloorAttributePresenter.svelte'
import FloorView from './components/FloorView.svelte'
import MeetingMinutesTable from './components/MeetingMinutesTable.svelte'
import { import {
copyGuestLink, copyGuestLink,
@ -20,7 +26,8 @@ import {
startTranscription, startTranscription,
stopTranscription, stopTranscription,
toggleMic, toggleMic,
toggleVideo toggleVideo,
getMeetingMinutesTitle
} from './utils' } from './utils'
export { setCustomCreateScreenTracks } from './utils' export { setCustomCreateScreenTracks } from './utils'
@ -36,7 +43,13 @@ export default async (): Promise<Resources> => ({
EditMeetingData, EditMeetingData,
LoveWidget, LoveWidget,
MeetingWidget, MeetingWidget,
MeetingMinutesPresenter MeetingMinutesPresenter,
MeetingMinutesSection,
EditMeetingMinutes,
EditRoom,
FloorAttributePresenter,
FloorView,
MeetingMinutesTable
}, },
function: { function: {
CreateMeeting: createMeeting, CreateMeeting: createMeeting,
@ -50,7 +63,8 @@ export default async (): Promise<Resources> => ({
}, },
CanCopyGuestLink: () => { CanCopyGuestLink: () => {
return hasAccountRole(getCurrentAccount(), AccountRole.User) return hasAccountRole(getCurrentAccount(), AccountRole.User)
} },
MeetingMinutesTitleProvider: getMeetingMinutesTitle
}, },
actionImpl: { actionImpl: {
ToggleMic: toggleMic, ToggleMic: toggleMic,

View File

@ -24,7 +24,13 @@ export default mergeIds(loveId, love, {
ControlExt: '' as AnyComponent, ControlExt: '' as AnyComponent,
MeetingData: '' as AnyComponent, MeetingData: '' as AnyComponent,
EditMeetingData: '' as AnyComponent, EditMeetingData: '' as AnyComponent,
MeetingMinutesPresenter: '' as AnyComponent MeetingMinutesPresenter: '' as AnyComponent,
MeetingMinutesSection: '' as AnyComponent,
EditMeetingMinutes: '' as AnyComponent,
EditRoom: '' as AnyComponent,
FloorAttributePresenter: '' as AnyComponent,
MeetingMinutesTable: '' as AnyComponent,
FloorView: '' as AnyComponent
}, },
function: { function: {
CreateMeeting: '' as Resource<DocCreateFunction>, CreateMeeting: '' as Resource<DocCreateFunction>,

View File

@ -8,7 +8,8 @@ import {
type JoinRequest, type JoinRequest,
type Office, type Office,
type ParticipantInfo, type ParticipantInfo,
type Room type Room,
type MeetingMinutes
} from '@hcengineering/love' } from '@hcengineering/love'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { derived, get, writable } from 'svelte/store' import { derived, get, writable } from 'svelte/store'
@ -59,6 +60,9 @@ export const activeInvites = derived(invites, (val) => {
export const myPreferences = writable<DevicesPreference | undefined>() export const myPreferences = writable<DevicesPreference | undefined>()
export let $myPreferences: DevicesPreference | undefined export let $myPreferences: DevicesPreference | undefined
export const meetingMinutesStore = writable<MeetingMinutes | undefined>(undefined)
export const selectedRoomPlace = writable<{ _id: Ref<Room>, x: number, y: number } | undefined>(undefined)
function filterParticipantInfo (value: ParticipantInfo[]): ParticipantInfo[] { function filterParticipantInfo (value: ParticipantInfo[]): ParticipantInfo[] {
const map = new Map<string, ParticipantInfo>() const map = new Map<string, ParticipantInfo>()
const aiPersonId = get(personIdByAccountId).get(aiBot.account.AIBot as Ref<PersonAccount>) const aiPersonId = get(personIdByAccountId).get(aiBot.account.AIBot as Ref<PersonAccount>)

View File

@ -5,8 +5,10 @@ import core, {
AccountRole, AccountRole,
concatLink, concatLink,
type Data, type Data,
generateId,
getCurrentAccount, getCurrentAccount,
type IdMap, type IdMap,
makeCollaborativeDoc,
type Ref, type Ref,
type Space, type Space,
type TxOperations type TxOperations
@ -27,7 +29,8 @@ import {
RoomAccess, RoomAccess,
RoomType, RoomType,
TranscriptionStatus, TranscriptionStatus,
type RoomMetadata type RoomMetadata,
type MeetingMinutes
} from '@hcengineering/love' } from '@hcengineering/love'
import { getEmbeddedLabel, getMetadata, getResource, type IntlString } from '@hcengineering/platform' import { getEmbeddedLabel, getMetadata, getResource, type IntlString } from '@hcengineering/platform'
import presentation, { import presentation, {
@ -36,7 +39,7 @@ import presentation, {
type DocCreatePhase, type DocCreatePhase,
getClient getClient
} from '@hcengineering/presentation' } from '@hcengineering/presentation'
import { type DropdownTextItem, getCurrentLocation, type Location, navigate, showPopup } from '@hcengineering/ui' import { type DropdownTextItem, getCurrentLocation, navigate, showPopup } from '@hcengineering/ui'
import { isKrispNoiseFilterSupported, KrispNoiseFilter } from '@livekit/krisp-noise-filter' import { isKrispNoiseFilterSupported, KrispNoiseFilter } from '@livekit/krisp-noise-filter'
import { BackgroundBlur, type BackgroundOptions, type ProcessorWrapper } from '@livekit/track-processors' import { BackgroundBlur, type BackgroundOptions, type ProcessorWrapper } from '@livekit/track-processors'
import { import {
@ -57,13 +60,20 @@ import {
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
import aiBot from '@hcengineering/ai-bot' import aiBot from '@hcengineering/ai-bot'
import { connectMeeting, disconnectMeeting } from '@hcengineering/ai-bot-resources' import { connectMeeting, disconnectMeeting } from '@hcengineering/ai-bot-resources'
import { openWidget, sidebarStore, updateWidgetState } from '@hcengineering/workbench-resources' import {
openWidget,
sidebarStore,
updateWidgetState,
currentWorkspaceStore,
openWidgetTab
} from '@hcengineering/workbench-resources'
import { type Widget, type WidgetTab } from '@hcengineering/workbench' import { type Widget, type WidgetTab } from '@hcengineering/workbench'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import chunter from '@hcengineering/chunter'
import { sendMessage } from './broadcast' import { sendMessage } from './broadcast'
import love from './plugin' import love from './plugin'
import { $myPreferences, currentRoom } from './stores' import { $myPreferences, meetingMinutesStore, currentRoom } from './stores'
import RoomSettingsPopup from './components/RoomSettingsPopup.svelte' import RoomSettingsPopup from './components/RoomSettingsPopup.svelte'
export const selectedCamId = 'selectedDevice_cam' export const selectedCamId = 'selectedDevice_cam'
@ -92,7 +102,9 @@ export async function getToken (
function getTokenRoomName (roomName: string, roomId: Ref<Room>): string { function getTokenRoomName (roomName: string, roomId: Ref<Room>): string {
const loc = getCurrentLocation() const loc = getCurrentLocation()
return `${loc.path[1]}_${roomName}_${roomId}` const currentWorkspace = get(currentWorkspaceStore)
return `${currentWorkspace?.workspaceId ?? loc.path[1]}_${roomName}_${roomId}`
} }
export const lk: LKRoom = new LKRoom({ export const lk: LKRoom = new LKRoom({
@ -432,6 +444,7 @@ export async function disconnect (): Promise<void> {
isMicEnabled.set(false) isMicEnabled.set(false)
isCameraEnabled.set(false) isCameraEnabled.set(false)
isSharingEnabled.set(false) isSharingEnabled.set(false)
meetingMinutesStore.set(undefined)
sendMessage({ type: 'mic', value: false }) sendMessage({ type: 'mic', value: false })
sendMessage({ type: 'cam', value: false }) sendMessage({ type: 'cam', value: false })
sendMessage({ type: 'share', value: false }) sendMessage({ type: 'share', value: false })
@ -580,6 +593,46 @@ async function connectLK (currentPerson: Person, room: Room): Promise<void> {
]) ])
} }
async function createMeetingMinutes (room: Room): Promise<void> {
const client = getClient()
const sid = await lk.getSid()
if (sid !== undefined) {
const doc = await client.findOne(love.class.MeetingMinutes, { sid })
if (doc === undefined) {
const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19)
const _id = generateId<MeetingMinutes>()
const newDoc: MeetingMinutes = {
_id,
_class: love.class.MeetingMinutes,
sid,
attachedTo: room._id,
attachedToClass: room._class,
collection: 'meetings',
space: core.space.Workspace,
title: room.name + ' ' + dateStr,
description: makeCollaborativeDoc(_id, 'description'),
modifiedBy: getCurrentAccount()._id,
modifiedOn: Date.now()
}
await client.addCollection(
love.class.MeetingMinutes,
core.space.Workspace,
room._id,
room._class,
'meetings',
{ sid, title: newDoc.title, description: newDoc.description },
_id
)
meetingMinutesStore.set(newDoc)
} else {
meetingMinutesStore.set(doc)
}
}
}
export async function connectRoom ( export async function connectRoom (
x: number, x: number,
y: number, y: number,
@ -590,6 +643,7 @@ export async function connectRoom (
await disconnect() await disconnect()
await moveToRoom(x, y, currentInfo, currentPerson, room, getMetadata(presentation.metadata.SessionId) ?? null) await moveToRoom(x, y, currentInfo, currentPerson, room, getMetadata(presentation.metadata.SessionId) ?? null)
await connectLK(currentPerson, room) await connectLK(currentPerson, room)
await createMeetingMinutes(room)
} }
export const joinRequest: Ref<JoinRequest> | undefined = undefined export const joinRequest: Ref<JoinRequest> | undefined = undefined
@ -910,13 +964,13 @@ export function isTranscriptionAllowed (): boolean {
return url !== '' return url !== ''
} }
export function createMeetingWidget (widget: Widget, room: Ref<Room>, loc: Location, video: boolean): void { export function createMeetingWidget (widget: Widget, room: Ref<Room>, video: boolean): void {
const tabs: WidgetTab[] = [ const tabs: WidgetTab[] = [
...(video ...(video
? [ ? [
{ {
id: 'video', id: 'video',
name: 'Video', label: love.string.Video,
icon: love.icon.Cam, icon: love.icon.Cam,
readonly: true readonly: true
} }
@ -924,13 +978,13 @@ export function createMeetingWidget (widget: Widget, room: Ref<Room>, loc: Locat
: []), : []),
{ {
id: 'chat', id: 'chat',
name: 'Chat', label: chunter.string.Chat,
icon: view.icon.Bubble, icon: view.icon.Bubble,
readonly: true readonly: true
}, },
{ {
id: 'transcription', id: 'transcription',
name: 'Transcription', label: love.string.Transcription,
icon: view.icon.Feather, icon: view.icon.Feather,
readonly: true readonly: true
} }
@ -940,12 +994,12 @@ export function createMeetingWidget (widget: Widget, room: Ref<Room>, loc: Locat
{ {
room room
}, },
{ active: loc.path[2] !== loveId, openedByUser: false }, { active: true, openedByUser: false },
tabs tabs
) )
} }
export function createMeetingVideoWidgetTab (widget: Widget, loc: Location): void { export function createMeetingVideoWidgetTab (widget: Widget): void {
const state = get(sidebarStore) const state = get(sidebarStore)
const { widgetsState } = state const { widgetsState } = state
const widgetState = widgetsState.get(widget._id) const widgetState = widgetsState.get(widget._id)
@ -954,12 +1008,23 @@ export function createMeetingVideoWidgetTab (widget: Widget, loc: Location): voi
const tab: WidgetTab = { const tab: WidgetTab = {
id: 'video', id: 'video',
name: 'Video', label: love.string.Video,
icon: love.icon.Cam, icon: love.icon.Cam,
readonly: true readonly: true
} }
updateWidgetState(widget._id, { updateWidgetState(widget._id, {
tabs: [tab, ...widgetState.tabs], tabs: [tab, ...widgetState.tabs],
tab: state.widget === widget._id && loc.path[2] === loveId ? widgetState.tab : 'video' tab: 'video'
}) })
openWidgetTab(love.ids.MeetingWidget, 'video')
}
export async function getMeetingMinutesTitle (
client: TxOperations,
ref: Ref<MeetingMinutes>,
doc?: MeetingMinutes
): Promise<string> {
const meeting = doc ?? (await client.findOne(love.class.MeetingMinutes, { _id: ref }))
return meeting?.title ?? ''
} }

View File

@ -1,12 +1,12 @@
import { Event } from '@hcengineering/calendar' import { Event } from '@hcengineering/calendar'
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import { Class, Doc, Mixin, Ref } from '@hcengineering/core' import { AttachedDoc, Class, CollaborativeDoc, Doc, Mixin, Ref } from '@hcengineering/core'
import { Drive } from '@hcengineering/drive' import { Drive } from '@hcengineering/drive'
import { NotificationType } from '@hcengineering/notification' import { NotificationType } from '@hcengineering/notification'
import { Asset, IntlString, Metadata, Plugin, plugin } from '@hcengineering/platform' import { Asset, IntlString, Metadata, Plugin, plugin } from '@hcengineering/platform'
import { Preference } from '@hcengineering/preference' import { Preference } from '@hcengineering/preference'
import { AnyComponent } from '@hcengineering/ui/src/types' import { AnyComponent } from '@hcengineering/ui/src/types'
import { Action } from '@hcengineering/view' import { Action, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import { Widget } from '@hcengineering/workbench' import { Widget } from '@hcengineering/workbench'
export const loveId = 'love' as Plugin export const loveId = 'love' as Plugin
@ -102,6 +102,9 @@ export interface Room extends Doc {
y: number y: number
language: RoomLanguage language: RoomLanguage
startWithTranscription: boolean startWithTranscription: boolean
description: CollaborativeDoc
attachments?: number
meetings?: number
} }
export interface Office extends Room { export interface Office extends Room {
@ -155,12 +158,13 @@ export interface DevicesPreference extends Preference {
camEnabled: boolean camEnabled: boolean
} }
export interface MeetingMinutes extends Doc { export interface MeetingMinutes extends AttachedDoc {
sid: string sid: string
title: string title: string
room: Ref<Room>
transcription?: number transcription?: number
messages?: number messages?: number
description: CollaborativeDoc
attachments?: number
} }
export * from './utils' export * from './utils'
@ -200,7 +204,11 @@ const love = plugin(loveId, {
Meeting: '' as IntlString, Meeting: '' as IntlString,
Transcription: '' as IntlString, Transcription: '' as IntlString,
StartWithTranscription: '' as IntlString, StartWithTranscription: '' as IntlString,
MeetingMinutes: '' as IntlString MeetingMinutes: '' as IntlString,
StartMeeting: '' as IntlString,
Video: '' as IntlString,
NoMeetingMinutes: '' as IntlString,
JoinMeeting: '' as IntlString
}, },
ids: { ids: {
MainFloor: '' as Ref<Floor>, MainFloor: '' as Ref<Floor>,
@ -243,6 +251,13 @@ const love = plugin(loveId, {
}, },
component: { component: {
SelectScreenSourcePopup: '' as AnyComponent SelectScreenSourcePopup: '' as AnyComponent
},
viewlet: {
TableMeetingMinutes: '' as Ref<Viewlet>,
MeetingMinutesDescriptor: '' as Ref<ViewletDescriptor>,
FloorDescriptor: '' as Ref<ViewletDescriptor>,
Floor: '' as Ref<Viewlet>,
FloorMeetingMinutes: '' as Ref<Viewlet>
} }
}) })

View File

@ -1,5 +1,5 @@
import { Employee, Person } from '@hcengineering/contact' import { Employee, Person } from '@hcengineering/contact'
import { Data, Ref } from '@hcengineering/core' import { Data, generateId, makeCollaborativeDoc, Ref } from '@hcengineering/core'
import love, { Office, Room, ParticipantInfo, RoomAccess, RoomType, GRID_WIDTH } from '.' import love, { Office, Room, ParticipantInfo, RoomAccess, RoomType, GRID_WIDTH } from '.'
@ -15,11 +15,13 @@ export function isOffice (room: Data<Room>): room is Office {
return (room as Office).person !== undefined return (room as Office).person !== undefined
} }
export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Office>[] { export function createDefaultRooms (employees: Ref<Employee>[]): (Data<Room | Office> & { _id: Ref<Room> })[] {
const res: Data<Room | Office>[] = [] const res: (Data<Room | Office> & { _id: Ref<Room> })[] = []
// create 12 offices // create 12 offices
for (let index = 0; index < 12; index++) { for (let index = 0; index < 12; index++) {
const office: Data<Office> = { const _id = generateId<Office>()
const office: Data<Office> & { _id: Ref<Office> } = {
_id,
name: '', name: '',
type: RoomType.Audio, type: RoomType.Audio,
access: RoomAccess.Knock, access: RoomAccess.Knock,
@ -30,11 +32,15 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
y: index - (index % 2), y: index - (index % 2),
person: employees[index] ?? null, person: employees[index] ?? null,
language: 'en', language: 'en',
startWithTranscription: false startWithTranscription: false,
description: makeCollaborativeDoc(_id, 'description')
} }
res.push(office) res.push(office)
} }
const allHands = generateId<Room>()
res.push({ res.push({
_id: allHands,
name: 'All hands', name: 'All hands',
type: RoomType.Video, type: RoomType.Video,
access: RoomAccess.Open, access: RoomAccess.Open,
@ -44,9 +50,13 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
x: 6, x: 6,
y: 0, y: 0,
language: 'en', language: 'en',
startWithTranscription: true startWithTranscription: true,
description: makeCollaborativeDoc(allHands, 'description')
}) })
const meetingRoom1 = generateId<Room>()
res.push({ res.push({
_id: meetingRoom1,
name: 'Meeting Room 1', name: 'Meeting Room 1',
type: RoomType.Video, type: RoomType.Video,
access: RoomAccess.Open, access: RoomAccess.Open,
@ -56,9 +66,12 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
x: 6, x: 6,
y: 4, y: 4,
language: 'en', language: 'en',
startWithTranscription: true startWithTranscription: true,
description: makeCollaborativeDoc(meetingRoom1, 'description')
}) })
const meetingRoom2 = generateId<Room>()
res.push({ res.push({
_id: meetingRoom2,
name: 'Meeting Room 2', name: 'Meeting Room 2',
type: RoomType.Video, type: RoomType.Video,
access: RoomAccess.Open, access: RoomAccess.Open,
@ -68,9 +81,12 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
x: 11, x: 11,
y: 4, y: 4,
language: 'en', language: 'en',
startWithTranscription: true startWithTranscription: true,
description: makeCollaborativeDoc(meetingRoom2, 'description')
}) })
const voiceRoom1 = generateId<Room>()
res.push({ res.push({
_id: voiceRoom1,
name: 'Voice Room 1', name: 'Voice Room 1',
type: RoomType.Audio, type: RoomType.Audio,
access: RoomAccess.Open, access: RoomAccess.Open,
@ -80,9 +96,12 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
x: 6, x: 6,
y: 8, y: 8,
language: 'en', language: 'en',
startWithTranscription: true startWithTranscription: false,
description: makeCollaborativeDoc(voiceRoom1, 'description')
}) })
const voiceRoom2 = generateId<Room>()
res.push({ res.push({
_id: voiceRoom2,
name: 'Voice Room 2', name: 'Voice Room 2',
type: RoomType.Audio, type: RoomType.Audio,
access: RoomAccess.Open, access: RoomAccess.Open,
@ -92,7 +111,8 @@ export function createDefaultRooms (employees: Ref<Employee>[]): Data<Room | Off
x: 11, x: 11,
y: 8, y: 8,
language: 'en', language: 'en',
startWithTranscription: true startWithTranscription: false,
description: makeCollaborativeDoc(voiceRoom2, 'description')
}) })
return res return res
} }

View File

@ -226,32 +226,31 @@ export async function unarchiveContextNotifications (doc?: DocNotifyContext): Pr
} }
} }
enum OpWithMe { export async function subscribeDoc (
Add = 'add',
Remove = 'remove'
}
async function updateMeInCollaborators (
client: TxOperations, client: TxOperations,
docClass: Ref<Class<Doc>>, docClass: Ref<Class<Doc>>,
docId: Ref<Doc>, docId: Ref<Doc>,
op: OpWithMe op: 'add' | 'remove',
doc?: Doc
): Promise<void> { ): Promise<void> {
const me = getCurrentAccount()._id const me = getCurrentAccount()._id
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
const target = await client.findOne(docClass, { _id: docId })
if (target !== undefined) { if (hierarchy.classHierarchyMixin(docClass, notification.mixin.ClassCollaborators) === undefined) return
const target = doc ?? (await client.findOne(docClass, { _id: docId }))
if (target === undefined) return
if (hierarchy.hasMixin(target, notification.mixin.Collaborators)) { if (hierarchy.hasMixin(target, notification.mixin.Collaborators)) {
const collab = hierarchy.as(target, notification.mixin.Collaborators) const collab = hierarchy.as(target, notification.mixin.Collaborators)
let collabUpdate: DocumentUpdate<Collaborators> | undefined let collabUpdate: DocumentUpdate<Collaborators> | undefined
if (collab.collaborators.includes(me) && op === OpWithMe.Remove) { if (collab.collaborators.includes(me) && op === 'remove') {
collabUpdate = { collabUpdate = {
$pull: { $pull: {
collaborators: me collaborators: me
} }
} }
} else if (!collab.collaborators.includes(me) && op === OpWithMe.Add) { } else if (!collab.collaborators.includes(me) && op === 'add') {
collabUpdate = { collabUpdate = {
$push: { $push: {
collaborators: me collaborators: me
@ -260,15 +259,12 @@ async function updateMeInCollaborators (
} }
if (collabUpdate !== undefined) { if (collabUpdate !== undefined) {
await client.updateMixin( await client.updateMixin(collab._id, collab._class, collab.space, notification.mixin.Collaborators, collabUpdate)
collab._id,
collab._class,
collab.space,
notification.mixin.Collaborators,
collabUpdate
)
}
} }
} else if (op === 'add') {
await client.createMixin(docId, docClass, target.space, notification.mixin.Collaborators, {
collaborators: [me]
})
} }
} }
@ -277,7 +273,7 @@ async function updateMeInCollaborators (
*/ */
export async function unsubscribe (context: DocNotifyContext): Promise<void> { export async function unsubscribe (context: DocNotifyContext): Promise<void> {
const client = getClient() const client = getClient()
await updateMeInCollaborators(client, context.objectClass, context.objectId, OpWithMe.Remove) await subscribeDoc(client, context.objectClass, context.objectId, 'remove')
} }
/** /**
@ -285,7 +281,7 @@ export async function unsubscribe (context: DocNotifyContext): Promise<void> {
*/ */
export async function subscribe (docClass: Ref<Class<Doc>>, docId: Ref<Doc>): Promise<void> { export async function subscribe (docClass: Ref<Class<Doc>>, docId: Ref<Doc>): Promise<void> {
const client = getClient() const client = getClient()
await updateMeInCollaborators(client, docClass, docId, OpWithMe.Add) await subscribeDoc(client, docClass, docId, 'add')
} }
export async function pinDocNotifyContext (object: DocNotifyContext): Promise<void> { export async function pinDocNotifyContext (object: DocNotifyContext): Promise<void> {

View File

@ -39,6 +39,7 @@
export let _class: Ref<Class<Doc>> export let _class: Ref<Class<Doc>>
export let embedded: boolean = false export let embedded: boolean = false
export let readonly: boolean = false export let readonly: boolean = false
export let selectedAside: boolean | undefined = undefined
let realObjectClass: Ref<Class<Doc>> = _class let realObjectClass: Ref<Class<Doc>> = _class
let lastId: Ref<Doc> | undefined let lastId: Ref<Doc> | undefined
@ -225,6 +226,7 @@
allowClose={!embedded} allowClose={!embedded}
isAside={true} isAside={true}
{embedded} {embedded}
{selectedAside}
bind:content bind:content
bind:panelWidth bind:panelWidth
bind:innerWidth bind:innerWidth

View File

@ -43,7 +43,7 @@
<ModernTab <ModernTab
label={tab.name} label={tab.name}
labelIntl={widget.label} labelIntl={tab.label ?? widget.label}
highlighted={selected} highlighted={selected}
orientation="vertical" orientation="vertical"
kind={tab.isPinned ? 'secondary' : 'primary'} kind={tab.isPinned ? 'secondary' : 'primary'}

View File

@ -26,7 +26,7 @@ import ServerManager from './components/ServerManager.svelte'
import WorkbenchTabs from './components/WorkbenchTabs.svelte' import WorkbenchTabs from './components/WorkbenchTabs.svelte'
import { isAdminUser } from '@hcengineering/presentation' import { isAdminUser } from '@hcengineering/presentation'
import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench' import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench'
import { closeWidgetTab, createWidgetTab, getSidebarObject } from './sidebar' import { closeWidget, closeWidgetTab, createWidgetTab, getSidebarObject } from './sidebar'
async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> { async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> {
return spaces.find((sp) => sp.archived) !== undefined return spaces.find((sp) => sp.archived) !== undefined
@ -59,6 +59,7 @@ export default async (): Promise<Resources> => ({
CanCloseTab: canCloseTab, CanCloseTab: canCloseTab,
CreateWidgetTab: createWidgetTab, CreateWidgetTab: createWidgetTab,
CloseWidgetTab: closeWidgetTab, CloseWidgetTab: closeWidgetTab,
CloseWidget: closeWidget,
GetSidebarObject: getSidebarObject GetSidebarObject: getSidebarObject
}, },
actionImpl: { actionImpl: {

View File

@ -18,7 +18,7 @@ import { get, writable } from 'svelte/store'
import { getCurrentLocation, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { getCurrentLocation, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import { workspaceStore } from './utils' import { locationWorkspaceStore } from './utils'
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
export enum SidebarVariant { export enum SidebarVariant {
@ -50,14 +50,14 @@ export const defaultSidebarState: SidebarState = {
export const sidebarStore = writable<SidebarState>(defaultSidebarState) export const sidebarStore = writable<SidebarState>(defaultSidebarState)
workspaceStore.subscribe((workspace) => { locationWorkspaceStore.subscribe((workspace) => {
sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? '')) sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? ''))
}) })
sidebarStore.subscribe(setSidebarStateToLocalStorage) sidebarStore.subscribe(setSidebarStateToLocalStorage)
export function syncSidebarState (): void { export function syncSidebarState (): void {
const workspace = get(workspaceStore) const workspace = get(locationWorkspaceStore)
sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? '')) sidebarStore.set(getSidebarStateFromLocalStorage(workspace ?? ''))
} }
function getSideBarLocalStorageKey (workspace: string): string | undefined { function getSideBarLocalStorageKey (workspace: string): string | undefined {
@ -89,7 +89,7 @@ function getSidebarStateFromLocalStorage (workspace: string): SidebarState {
} }
function setSidebarStateToLocalStorage (state: SidebarState): void { function setSidebarStateToLocalStorage (state: SidebarState): void {
const workspace = get(workspaceStore) const workspace = get(locationWorkspaceStore)
if (workspace == null || workspace === '') return if (workspace == null || workspace === '') return
const sidebarStateLocalStorageKey = getSideBarLocalStorageKey(workspace) const sidebarStateLocalStorageKey = getSideBarLocalStorageKey(workspace)
@ -214,6 +214,8 @@ export function openWidgetTab (widget: Ref<Widget>, tab: string): void {
Analytics.handleEvent(WorkbenchEvents.SidebarOpenWidget, { widget, tab: newTab?.name }) Analytics.handleEvent(WorkbenchEvents.SidebarOpenWidget, { widget, tab: newTab?.name })
sidebarStore.set({ sidebarStore.set({
...state, ...state,
widget,
variant: SidebarVariant.EXPANDED,
widgetsState widgetsState
}) })
} }

View File

@ -157,7 +157,13 @@ export async function showApplication (app: Application): Promise<void> {
} }
export const workspacesStore = writable<Workspace[]>([]) export const workspacesStore = writable<Workspace[]>([])
export const workspaceStore = derived(location, (loc: Location) => loc.path[1]) export const locationWorkspaceStore = derived(location, (loc: Location) => loc.path[1])
export const currentWorkspaceStore = derived(
[workspacesStore, locationWorkspaceStore],
([$workspaces, $locationWorkspace]) => {
return $workspaces.find((it) => it.workspace === $locationWorkspace)
}
)
/** /**
* @public * @public

View File

@ -40,7 +40,7 @@ import { type Asset, type IntlString, getMetadata, getResource, translate } from
import { parseLinkId } from '@hcengineering/view-resources' import { parseLinkId } from '@hcengineering/view-resources'
import notification, { notificationId } from '@hcengineering/notification' import notification, { notificationId } from '@hcengineering/notification'
import { workspaceStore } from './utils' import { locationWorkspaceStore } from './utils'
import workbench from './plugin' import workbench from './plugin'
export const tabIdStore = writable<Ref<WorkbenchTab> | undefined>() export const tabIdStore = writable<Ref<WorkbenchTab> | undefined>()
@ -56,7 +56,7 @@ tabIdStore.subscribe((value) => {
prevTabId = value prevTabId = value
}) })
workspaceStore.subscribe((workspace) => { locationWorkspaceStore.subscribe((workspace) => {
tabIdStore.set(getTabFromLocalStorage(workspace ?? '')) tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
}) })
@ -64,7 +64,7 @@ tabIdStore.subscribe(saveTabToLocalStorage)
const syncTabLoc = reduceCalls(async (): Promise<void> => { const syncTabLoc = reduceCalls(async (): Promise<void> => {
const loc = getCurrentLocation() const loc = getCurrentLocation()
const workspace = get(workspaceStore) const workspace = get(locationWorkspaceStore)
if (workspace == null || workspace === '') return if (workspace == null || workspace === '') return
const tab = get(currentTabStore) const tab = get(currentTabStore)
if (tab == null) return if (tab == null) return
@ -132,7 +132,7 @@ locationStore.subscribe((l: Location) => {
}) })
export function syncWorkbenchTab (): void { export function syncWorkbenchTab (): void {
const workspace = get(workspaceStore) const workspace = get(locationWorkspaceStore)
tabIdStore.set(getTabFromLocalStorage(workspace ?? '')) tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
} }
@ -153,7 +153,7 @@ function getTabFromLocalStorage (workspace: string): Ref<WorkbenchTab> | undefin
} }
function saveTabToLocalStorage (_id: Ref<WorkbenchTab> | undefined): void { function saveTabToLocalStorage (_id: Ref<WorkbenchTab> | undefined): void {
const workspace = get(workspaceStore) const workspace = get(locationWorkspaceStore)
if (workspace == null || workspace === '') return if (workspace == null || workspace === '') return
const localStorageKey = getTabIdLocalStorageKey(workspace) const localStorageKey = getTabIdLocalStorageKey(workspace)

View File

@ -89,6 +89,7 @@ export interface WidgetPreference extends Preference {
export interface WidgetTab { export interface WidgetTab {
id: string id: string
name?: string name?: string
label?: IntlString
icon?: Asset | AnySvelteComponent icon?: Asset | AnySvelteComponent
iconComponent?: AnyComponent iconComponent?: AnyComponent
iconProps?: Record<string, any> iconProps?: Record<string, any>
@ -280,6 +281,7 @@ export default plugin(workbenchId, {
function: { function: {
CreateWidgetTab: '' as Resource<(widget: Widget, tab: WidgetTab, newTab: boolean) => Promise<void>>, CreateWidgetTab: '' as Resource<(widget: Widget, tab: WidgetTab, newTab: boolean) => Promise<void>>,
CloseWidgetTab: '' as Resource<(widget: Widget, tab: string) => Promise<void>>, CloseWidgetTab: '' as Resource<(widget: Widget, tab: string) => Promise<void>>,
CloseWidget: '' as Resource<(widget: Ref<Widget>) => Promise<void>>,
GetSidebarObject: '' as Resource<() => Partial<Pick<Doc, '_id' | '_class'>>> GetSidebarObject: '' as Resource<() => Partial<Pick<Doc, '_id' | '_class'>>>
}, },
actionImpl: { actionImpl: {

View File

@ -62,6 +62,7 @@ export async function createAccountRequest (workspace: WorkspaceId, ctx: Measure
} }
try { try {
ctx.info('Requesting AI account creation', { url, workspace })
await fetch(concatLink(url, '/connect'), { await fetch(concatLink(url, '/connect'), {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@ -37,6 +37,7 @@
"prettier-plugin-svelte": "^3.2.2" "prettier-plugin-svelte": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"@hcengineering/view": "^0.6.13",
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",
"@hcengineering/notification": "^0.6.23", "@hcengineering/notification": "^0.6.23",

View File

@ -23,11 +23,14 @@ import core, {
TxMixin, TxMixin,
TxProcessor, TxProcessor,
TxUpdateDoc, TxUpdateDoc,
UserStatus UserStatus,
Doc,
concatLink
} from '@hcengineering/core' } from '@hcengineering/core'
import love, { import love, {
Invite, Invite,
JoinRequest, JoinRequest,
MeetingMinutes,
ParticipantInfo, ParticipantInfo,
RequestStatus, RequestStatus,
RoomAccess, RoomAccess,
@ -35,14 +38,15 @@ import love, {
loveId loveId
} from '@hcengineering/love' } from '@hcengineering/love'
import notification from '@hcengineering/notification' import notification from '@hcengineering/notification'
import { translate } from '@hcengineering/platform' import { getMetadata, translate } from '@hcengineering/platform'
import { TriggerControl } from '@hcengineering/server-core' import serverCore, { TriggerControl } from '@hcengineering/server-core'
import { import {
createPushNotification, createPushNotification,
getNotificationProviderControl, getNotificationProviderControl,
isAllowed isAllowed
} from '@hcengineering/server-notification-resources' } from '@hcengineering/server-notification-resources'
import { workbenchId } from '@hcengineering/workbench' import { workbenchId } from '@hcengineering/workbench'
import view from '@hcengineering/view'
export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise<Tx[]> { export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const result: Tx[] = [] const result: Tx[] = []
@ -370,8 +374,31 @@ export async function OnInvite (txes: Tx[], control: TriggerControl): Promise<Tx
return [] return []
} }
export async function meetingMinutesHTMLPresenter (doc: Doc, control: TriggerControl): Promise<string> {
const meetingMinutes = doc as MeetingMinutes
const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
const panelProps = [view.component.EditDoc, meetingMinutes._id, meetingMinutes._class]
const fragment = encodeURIComponent(panelProps.join('|'))
const path = `${workbenchId}/${control.workspace.workspaceUrl}/${loveId}#${fragment}`
const link = concatLink(front, path)
return `<a href="${link}">${meetingMinutes.title}</a>`
}
/**
* @public
*/
export async function meetingMinutesTextPresenter (doc: Doc): Promise<string> {
const meetingMinutes = doc as MeetingMinutes
return meetingMinutes.title
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({ export default async () => ({
function: {
MeetingMinutesHTMLPresenter: meetingMinutesHTMLPresenter,
MeetingMinutesTextPresenter: meetingMinutesTextPresenter
},
trigger: { trigger: {
OnEmployee, OnEmployee,
OnUserStatus, OnUserStatus,

View File

@ -38,7 +38,9 @@
"prettier-plugin-svelte": "^3.2.2" "prettier-plugin-svelte": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"@hcengineering/server-core": "^0.6.1" "@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-notification": "^0.6.1"
} }
} }

View File

@ -1,6 +1,7 @@
import type { Plugin, Resource } from '@hcengineering/platform' import type { Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform'
import { TriggerFunc } from '@hcengineering/server-core' import { TriggerFunc } from '@hcengineering/server-core'
import { Presenter } from '@hcengineering/server-notification'
/** /**
* @public * @public
@ -11,6 +12,10 @@ export const serverLoveId = 'server-love' as Plugin
* @public * @public
*/ */
export default plugin(serverLoveId, { export default plugin(serverLoveId, {
function: {
MeetingMinutesHTMLPresenter: '' as Resource<Presenter>,
MeetingMinutesTextPresenter: '' as Resource<Presenter>
},
trigger: { trigger: {
OnEmployee: '' as Resource<TriggerFunc>, OnEmployee: '' as Resource<TriggerFunc>,
OnUserStatus: '' as Resource<TriggerFunc>, OnUserStatus: '' as Resource<TriggerFunc>,

View File

@ -290,7 +290,10 @@ export class AIControl {
if (workspace === null) return if (workspace === null) return
const wsClient = await this.getWorkspaceClient(workspace) const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return if (wsClient === undefined) {
this.ctx.error('Workspace not found', { workspace })
return
}
return await wsClient.getLoveIdentity() return await wsClient.getLoveIdentity()
} }

View File

@ -27,6 +27,7 @@ import {
aiBotAccountEmail aiBotAccountEmail
} from '@hcengineering/ai-bot' } from '@hcengineering/ai-bot'
import { extractToken } from '@hcengineering/server-client' import { extractToken } from '@hcengineering/server-client'
import { MeasureContext } from '@hcengineering/core'
import { ApiError } from './error' import { ApiError } from './error'
import { AIControl } from '../controller' import { AIControl } from '../controller'
@ -54,7 +55,7 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
void handleRequest(fn, req, res, next) void handleRequest(fn, req, res, next)
} }
export function createServer (controller: AIControl): Express { export function createServer (controller: AIControl, ctx: MeasureContext): Express {
const app = express() const app = express()
app.use(cors()) app.use(cors())
app.use(express.json()) app.use(express.json())
@ -78,6 +79,7 @@ export function createServer (controller: AIControl): Express {
app.post( app.post(
'/connect', '/connect',
wrapRequest(async (_, res, token) => { wrapRequest(async (_, res, token) => {
ctx.info('Request to connect to workspace', { workspace: token.workspace.name })
await controller.connect(token.workspace.name) await controller.connect(token.workspace.name)
res.status(200) res.status(200)

View File

@ -51,7 +51,7 @@ export const start = async (): Promise<void> => {
} }
const aiControl = new AIControl(storage, ctx) const aiControl = new AIControl(storage, ctx)
const app = createServer(aiControl) const app = createServer(aiControl, ctx)
const server = listen(app, config.Port) const server = listen(app, config.Port)
const onClose = (): void => { const onClose = (): void => {

View File

@ -64,6 +64,7 @@ export async function tryAssignToWorkspace (
const info = await tryGetWorkspaceInfo(workspace, ctx) const info = await tryGetWorkspaceInfo(workspace, ctx)
if (info === undefined) { if (info === undefined) {
ctx.error('Workspace not found', { workspace })
return false return false
} }

View File

@ -9,8 +9,7 @@ import core, {
TxCreateDoc, TxCreateDoc,
TxUpdateDoc, TxUpdateDoc,
MeasureContext, MeasureContext,
Markup, Markup
generateId
} from '@hcengineering/core' } from '@hcengineering/core'
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import love, { import love, {
@ -22,27 +21,11 @@ import love, {
TranscriptionStatus TranscriptionStatus
} from '@hcengineering/love' } from '@hcengineering/love'
import { ConnectMeetingRequest } from '@hcengineering/ai-bot' import { ConnectMeetingRequest } from '@hcengineering/ai-bot'
import chunter, { ChatMessage } from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text' import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text'
import config from '../config' import config from '../config'
class Transcriptions {
private readonly transcriptionByPerson = new Map<Ref<Person>, { _id: Ref<ChatMessage>, text: string }>()
get (person: Ref<Person>): { _id: Ref<ChatMessage>, text: string } | undefined {
return this.transcriptionByPerson.get(person)
}
set (person: Ref<Person>, value: { _id: Ref<ChatMessage>, text: string }): void {
this.transcriptionByPerson.set(person, value)
}
delete (person: Ref<Person>): void {
this.transcriptionByPerson.delete(person)
}
}
export class LoveController { export class LoveController {
private readonly roomSidById = new Map<Ref<Room>, string>() private readonly roomSidById = new Map<Ref<Room>, string>()
private readonly connectedRooms = new Set<Ref<Room>>() private readonly connectedRooms = new Set<Ref<Room>>()
@ -50,7 +33,6 @@ export class LoveController {
private participantsInfo: ParticipantInfo[] = [] private participantsInfo: ParticipantInfo[] = []
private rooms: Room[] = [] private rooms: Room[] = []
private readonly meetingMinutes: MeetingMinutes[] = [] private readonly meetingMinutes: MeetingMinutes[] = []
private readonly activeTranscriptions = new Map<Ref<Room>, Transcriptions>()
constructor ( constructor (
private readonly workspace: string, private readonly workspace: string,
@ -142,6 +124,7 @@ export class LoveController {
const room = await this.getRoom(request.roomId) const room = await this.getRoom(request.roomId)
if (room === undefined) { if (room === undefined) {
this.ctx.error('Room not found', request)
this.roomSidById.delete(request.roomId) this.roomSidById.delete(request.roomId)
this.connectedRooms.delete(request.roomId) this.connectedRooms.delete(request.roomId)
return return
@ -166,8 +149,6 @@ export class LoveController {
async disconnect (roomId: Ref<Room>): Promise<void> { async disconnect (roomId: Ref<Room>): Promise<void> {
this.ctx.info('Disconnecting', { roomId }) this.ctx.info('Disconnecting', { roomId })
this.activeTranscriptions.delete(roomId)
const participant = await this.getRoomParticipant(roomId, this.currentPerson._id) const participant = await this.getRoomParticipant(roomId, this.currentPerson._id)
if (participant !== undefined) { if (participant !== undefined) {
await this.client.remove(participant) await this.client.remove(participant)
@ -191,21 +172,19 @@ export class LoveController {
return return
} }
const doc = await this.getMeetingMinutes(roomId, this.roomSidById.get(roomId) ?? '') const sid = this.roomSidById.get(roomId)
const personAccount = this.client.getModel().getAccountByPersonId(participant.person)[0]
if (doc === undefined) return
const transcriptions = this.activeTranscriptions.get(roomId) ?? new Transcriptions() if (sid === undefined) {
const activeTranscription = transcriptions.get(participant.person) return
if (activeTranscription === undefined) {
const _id = generateId<ChatMessage>()
if (!final) {
transcriptions.set(participant.person, { _id, text })
this.activeTranscriptions.set(roomId, transcriptions)
} }
await this.client.addCollection( const personAccount = this.client.getModel().getAccountByPersonId(participant.person)[0]
const doc = await this.getMeetingMinutes(room, sid)
if (doc === undefined) return
const op = this.client.apply(undefined, undefined, true)
await op.addCollection(
chunter.class.ChatMessage, chunter.class.ChatMessage,
core.space.Workspace, core.space.Workspace,
doc._id, doc._id,
@ -214,21 +193,11 @@ export class LoveController {
{ {
message: this.transcriptToMarkup(text) message: this.transcriptToMarkup(text)
}, },
_id, undefined,
undefined, undefined,
personAccount._id personAccount._id
) )
} else { await op.commit()
const mergedText = activeTranscription.text + ' ' + text
if (!final) {
transcriptions.set(participant.person, { _id: activeTranscription._id, text: mergedText })
} else {
transcriptions.delete(participant.person)
}
await this.client.updateDoc(chunter.class.ChatMessage, core.space.Workspace, activeTranscription._id, {
message: this.transcriptToMarkup(mergedText)
})
}
} }
hasActiveConnections (): boolean { hasActiveConnections (): boolean {
@ -246,18 +215,15 @@ export class LoveController {
) )
} }
async getMeetingMinutes (room: Ref<Room>, sid: string): Promise<MeetingMinutes | undefined> { async getMeetingMinutes (room: Room, sid: string): Promise<MeetingMinutes | undefined> {
if (sid === '') return undefined if (sid === '') return undefined
const existing = this.meetingMinutes.find((m) => m.room === room && m.sid === sid) const doc =
if (existing !== undefined) return existing this.meetingMinutes.find((m) => m.sid === sid) ?? (await this.client.findOne(love.class.MeetingMinutes, { sid }))
const doc = await this.client.findOne(love.class.MeetingMinutes, { if (doc === undefined) {
room, return undefined
sid }
})
if (doc === undefined) return
this.meetingMinutes.push(doc) this.meetingMinutes.push(doc)
return doc return doc

View File

@ -727,7 +727,10 @@ export class WorkspaceClient {
// Just wait initialization // Just wait initialization
await this.opClient await this.opClient
if (this.love === undefined) return if (this.love === undefined) {
this.ctx.error('Love is not initialized')
return
}
return this.love.getIdentity() return this.love.getIdentity()
} }

View File

@ -180,7 +180,7 @@ export const main = async (): Promise<void> => {
const metadata = language != null ? { transcription, language } : { transcription } const metadata = language != null ? { transcription, language } : { transcription }
try { try {
await updateMetadata(roomClient, roomName, metadata) await updateMetadata(roomClient, roomName, metadata)
res.send() res.status(200).send()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
res.status(500).send() res.status(500).send()