Scheduled meetings (#6206)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2024-07-31 20:33:57 +05:00 committed by GitHub
parent 31f6fcbcf8
commit 88e2df5885
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 512 additions and 97 deletions

View File

@ -46,6 +46,7 @@
"@hcengineering/love": "^0.6.0", "@hcengineering/love": "^0.6.0",
"@hcengineering/love-resources": "^0.6.0", "@hcengineering/love-resources": "^0.6.0",
"@hcengineering/notification": "^0.6.23", "@hcengineering/notification": "^0.6.23",
"@hcengineering/model-notification": "^0.6.0" "@hcengineering/model-notification": "^0.6.0",
"@hcengineering/model-calendar": "^0.6.0"
} }
} }

View File

@ -15,21 +15,13 @@
import contact, { type Employee, type Person } from '@hcengineering/contact' import contact, { type Employee, type Person } from '@hcengineering/contact'
import { AccountRole, DOMAIN_TRANSIENT, IndexKind, type Domain, type Ref } from '@hcengineering/core' import { AccountRole, DOMAIN_TRANSIENT, IndexKind, type Domain, type Ref } from '@hcengineering/core'
import { Index, Model, Prop, TypeRef, type Builder } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
import presentation from '@hcengineering/model-presentation'
import view, { createAction } from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import { getEmbeddedLabel } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import workbench from '@hcengineering/workbench'
import { import {
loveId, loveId,
type DevicesPreference, type DevicesPreference,
type Floor, type Floor,
type Invite, type Invite,
type JoinRequest, type JoinRequest,
type Meeting,
type Office, type Office,
type ParticipantInfo, type ParticipantInfo,
type RequestStatus, type RequestStatus,
@ -38,6 +30,16 @@ import {
type RoomInfo, type RoomInfo,
type RoomType type RoomType
} from '@hcengineering/love' } from '@hcengineering/love'
import { Index, Mixin, Model, Prop, TypeRef, type Builder } from '@hcengineering/model'
import calendar, { TEvent } from '@hcengineering/model-calendar'
import core, { TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
import presentation from '@hcengineering/model-presentation'
import view, { createAction } from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import { getEmbeddedLabel } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import workbench from '@hcengineering/workbench'
import love from './plugin' import love from './plugin'
export { loveId } from '@hcengineering/love' export { loveId } from '@hcengineering/love'
@ -127,10 +129,25 @@ export class TRoomInfo extends TDoc implements RoomInfo {
isOffice!: boolean isOffice!: boolean
} }
@Mixin(love.mixin.Meeting, calendar.class.Event)
export class TMeeting extends TEvent implements Meeting {
room!: Ref<Room>
}
export default love export default love
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel(TRoom, TFloor, TOffice, TParticipantInfo, TJoinRequest, TDevicesPreference, TRoomInfo, TInvite) builder.createModel(
TRoom,
TFloor,
TOffice,
TParticipantInfo,
TJoinRequest,
TDevicesPreference,
TRoomInfo,
TInvite,
TMeeting
)
builder.createDoc( builder.createDoc(
workbench.class.Application, workbench.class.Application,
@ -151,6 +168,19 @@ export function createModel (builder: Builder): void {
component: love.component.WorkbenchExtension component: love.component.WorkbenchExtension
}) })
builder.createDoc(presentation.class.DocCreateExtension, core.space.Model, {
ofClass: calendar.class.Event,
apply: love.function.CreateMeeting,
components: {
body: love.component.MeetingData
}
})
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: calendar.extensions.EditEventExtensions,
component: love.component.EditMeetingData
})
builder.createDoc( builder.createDoc(
setting.class.SettingsCategory, setting.class.SettingsCategory,
core.space.Model, core.space.Model,

View File

@ -7,7 +7,7 @@
export let manager: DocCreateExtensionManager export let manager: DocCreateExtensionManager
export let kind: CreateExtensionKind export let kind: CreateExtensionKind
export let props: Record<string, any> = {} export let props: Record<string, any> = {}
export let space: Space | undefined export let space: Space | undefined = undefined
$: extensions = manager.extensions $: extensions = manager.extensions

View File

@ -13,10 +13,15 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Calendar, RecurringRule, Visibility, generateEventId } from '@hcengineering/calendar' import { Calendar, Event, ReccuringEvent, RecurringRule, Visibility, generateEventId } from '@hcengineering/calendar'
import { Person, PersonAccount } from '@hcengineering/contact' import { Person, PersonAccount } from '@hcengineering/contact'
import { Class, Doc, Markup, Ref, getCurrentAccount } from '@hcengineering/core' import core, { Class, Doc, Markup, Ref, Space, generateId, getCurrentAccount } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation' import presentation, {
createQuery,
DocCreateExtComponent,
DocCreateExtensionManager,
getClient
} from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text' import { EmptyMarkup } from '@hcengineering/text'
import { StyledTextBox } from '@hcengineering/text-editor-resources' import { StyledTextBox } from '@hcengineering/text-editor-resources'
import { import {
@ -43,16 +48,21 @@
import ReccurancePopup from './ReccurancePopup.svelte' import ReccurancePopup from './ReccurancePopup.svelte'
import VisibilityEditor from './VisibilityEditor.svelte' import VisibilityEditor from './VisibilityEditor.svelte'
const currentUser = getCurrentAccount() as PersonAccount
export let attachedTo: Ref<Doc> = calendar.ids.NoAttached export let attachedTo: Ref<Doc> = calendar.ids.NoAttached
export let attachedToClass: Ref<Class<Doc>> = calendar.class.Event export let attachedToClass: Ref<Class<Doc>> = calendar.class.Event
export let title: string = '' export let title: string = ''
export let date: Date | undefined = undefined export let date: Date | undefined = undefined
export let withTime = false export let withTime = false
export let participants: Ref<Person>[] = [currentUser.person]
const now = new Date() const now = new Date()
const defaultDuration = 60 * 60 * 1000 const defaultDuration = 60 * 60 * 1000
const allDayDuration = 24 * 60 * 60 * 1000 - 1 const allDayDuration = 24 * 60 * 60 * 1000 - 1
const docCreateManager = DocCreateExtensionManager.create(calendar.class.Event)
let startDate = let startDate =
date === undefined ? now.getTime() : withTime ? date.getTime() : date.setHours(now.getHours(), now.getMinutes()) date === undefined ? now.getTime() : withTime ? date.getTime() : date.setHours(now.getHours(), now.getMinutes())
const duration = defaultDuration const duration = defaultDuration
@ -75,10 +85,14 @@
} }
}) })
const spaceQ = createQuery()
let space: Space | undefined = undefined
spaceQ.query(core.class.Space, { _id: calendar.space.Calendar }, (res) => {
space = res[0]
})
let rules: RecurringRule[] = [] let rules: RecurringRule[] = []
const currentUser = getCurrentAccount() as PersonAccount
let participants: Ref<Person>[] = [currentUser.person]
let externalParticipants: string[] = [] let externalParticipants: string[] = []
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -93,6 +107,7 @@
if (startDate != null) date = startDate if (startDate != null) date = startDate
if (date === undefined) return if (date === undefined) return
if (title === '') return if (title === '') return
const _id = generateId<Event>()
if (rules.length > 0) { if (rules.length > 0) {
await client.addCollection( await client.addCollection(
calendar.class.ReccuringEvent, calendar.class.ReccuringEvent,
@ -119,25 +134,37 @@
access: 'owner', access: 'owner',
originalStartTime: allDay ? saveUTC(date) : date, originalStartTime: allDay ? saveUTC(date) : date,
timeZone timeZone
} },
_id as Ref<ReccuringEvent>
) )
} else { } else {
await client.addCollection(calendar.class.Event, calendar.space.Calendar, attachedTo, attachedToClass, 'events', { await client.addCollection(
calendar: _calendar, calendar.class.Event,
eventId: generateEventId(), calendar.space.Calendar,
date: allDay ? saveUTC(date) : date, attachedTo,
dueDate: allDay ? saveUTC(dueDate) : dueDate, attachedToClass,
externalParticipants, 'events',
description, {
visibility, calendar: _calendar,
participants, eventId: generateEventId(),
reminders, date: allDay ? saveUTC(date) : date,
title, dueDate: allDay ? saveUTC(dueDate) : dueDate,
location, externalParticipants,
allDay, description,
timeZone, visibility,
access: 'owner' participants,
}) reminders,
title,
location,
allDay,
timeZone,
access: 'owner'
},
_id
)
}
if (space !== undefined) {
await docCreateManager.commit(client, _id, space, {}, 'post')
} }
dispatch('close') dispatch('close')
} }
@ -204,6 +231,9 @@
<LocationEditor focusIndex={10010} bind:value={location} /> <LocationEditor focusIndex={10010} bind:value={location} />
<EventParticipants focusIndex={10011} bind:participants bind:externalParticipants /> <EventParticipants focusIndex={10011} bind:participants bind:externalParticipants />
</div> </div>
<div class="block">
<DocCreateExtComponent manager={docCreateManager} kind={'body'} />
</div>
<div class="block row gap-1-5"> <div class="block row gap-1-5">
<div class="top-icon"> <div class="top-icon">
<Icon icon={calendar.icon.Description} size={'small'} /> <Icon icon={calendar.icon.Description} size={'small'} />
@ -222,7 +252,7 @@
<CalendarSelector bind:value={_calendar} focusIndex={10101} /> <CalendarSelector bind:value={_calendar} focusIndex={10101} />
<div class="flex-row-center flex-gap-1"> <div class="flex-row-center flex-gap-1">
<Icon icon={calendar.icon.Hidden} size={'small'} /> <Icon icon={calendar.icon.Hidden} size={'small'} />
<VisibilityEditor bind:value={visibility} kind={'tertiary'} focusIndex={10102} withoutIcon /> <VisibilityEditor bind:value={visibility} kind={'tertiary'} size={'small'} focusIndex={10102} withoutIcon />
</div> </div>
<EventReminders bind:reminders focusIndex={10103} /> <EventReminders bind:reminders focusIndex={10103} />
</div> </div>

View File

@ -16,7 +16,7 @@
import { Event, ReccuringEvent, ReccuringInstance, RecurringRule } from '@hcengineering/calendar' import { Event, ReccuringEvent, ReccuringInstance, RecurringRule } from '@hcengineering/calendar'
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import { DocumentUpdate, Ref } from '@hcengineering/core' import { DocumentUpdate, Ref } from '@hcengineering/core'
import presentation, { getClient } from '@hcengineering/presentation' import presentation, { ComponentExtensions, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor-resources' import { StyledTextBox } from '@hcengineering/text-editor-resources'
import { import {
Button, Button,
@ -199,6 +199,7 @@
<div class="block rightCropPadding"> <div class="block rightCropPadding">
<LocationEditor bind:value={location} focusIndex={10005} /> <LocationEditor bind:value={location} focusIndex={10005} />
<EventParticipants bind:participants bind:externalParticipants disabled={readOnly} focusIndex={10006} /> <EventParticipants bind:participants bind:externalParticipants disabled={readOnly} focusIndex={10006} />
<ComponentExtensions extension={calendar.extensions.EditEventExtensions} props={{ readOnly, value: object }} />
</div> </div>
<div class="block row gap-1-5"> <div class="block row gap-1-5">
<div class="top-icon"> <div class="top-icon">

View File

@ -49,6 +49,8 @@
<Button <Button
label={reminders.length > 0 ? calendar.string.AddReminder : calendar.string.Reminders} label={reminders.length > 0 ? calendar.string.AddReminder : calendar.string.Reminders}
{disabled} {disabled}
padding={'0 .5rem'}
justify={'left'}
kind={'ghost'} kind={'ghost'}
{focusIndex} {focusIndex}
on:click={(e) => { on:click={(e) => {

View File

@ -17,7 +17,7 @@ import { NotificationType } from '@hcengineering/notification'
import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform' import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform'
import type { Handler, IntegrationType } from '@hcengineering/setting' import type { Handler, IntegrationType } from '@hcengineering/setting'
import { AnyComponent } from '@hcengineering/ui' import { AnyComponent, ComponentExtensionId } from '@hcengineering/ui'
/** /**
* @public * @public
@ -212,6 +212,9 @@ const calendarPlugin = plugin(calendarId, {
metadata: { metadata: {
CalendarServiceURL: '' as Metadata<string> CalendarServiceURL: '' as Metadata<string>
}, },
extensions: {
EditEventExtensions: '' as ComponentExtensionId
},
ids: { ids: {
ReminderNotification: '' as Ref<NotificationType>, ReminderNotification: '' as Ref<NotificationType>,
NoAttached: '' as Ref<Event> NoAttached: '' as Ref<Event>

View File

@ -521,7 +521,7 @@ export async function getInviteLinkId (
): Promise<string> { ): Promise<string> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl) const accountsUrl = getMetadata(login.metadata.AccountsUrl)
const exp = expHours * 1000 * 60 * 60 const exp = expHours < 0 ? -1 : expHours * 1000 * 60 * 60
if (accountsUrl === undefined) { if (accountsUrl === undefined) {
throw new Error('accounts url not specified') throw new Error('accounts url not specified')

View File

@ -59,6 +59,7 @@
"FullscreenMode": "Full-screen mode", "FullscreenMode": "Full-screen mode",
"ExitingFullscreenMode": "Exiting fullscreen mode", "ExitingFullscreenMode": "Exiting fullscreen mode",
"Select": "Select", "Select": "Select",
"ChooseShare": "Choose what to share" "ChooseShare": "Choose what to share",
"CreateMeeting": "Create meeting"
} }
} }

View File

@ -59,6 +59,7 @@
"FullscreenMode": "Modo de pantalla completa", "FullscreenMode": "Modo de pantalla completa",
"ExitingFullscreenMode": "Salir del modo de pantalla completa", "ExitingFullscreenMode": "Salir del modo de pantalla completa",
"Select": "Seleccionar", "Select": "Seleccionar",
"ChooseShare": "Elija qué compartir" "ChooseShare": "Elija qué compartir",
"CreateMeeting": "Crear reunión"
} }
} }

View File

@ -59,6 +59,7 @@
"FullscreenMode": "Mode plein écran", "FullscreenMode": "Mode plein écran",
"ExitingFullscreenMode": "Quitter le mode plein écran", "ExitingFullscreenMode": "Quitter le mode plein écran",
"Select": "Sélectionner", "Select": "Sélectionner",
"ChooseShare": "Choisissez ce que vous voulez partager" "ChooseShare": "Choisissez ce que vous voulez partager",
"CreateMeeting": "Créer une réunion"
} }
} }

View File

@ -59,6 +59,7 @@
"FullscreenMode": "Modo de ecrã inteiro", "FullscreenMode": "Modo de ecrã inteiro",
"ExitingFullscreenMode": "Saindo do modo de tela cheia", "ExitingFullscreenMode": "Saindo do modo de tela cheia",
"Select": "Seleccione", "Select": "Seleccione",
"ChooseShare": "Escolha o que partilhar" "ChooseShare": "Escolha o que partilhar",
"CreateMeeting": "Criar reunião"
} }
} }

View File

@ -59,6 +59,7 @@
"FullscreenMode": "Полноэкранный режим", "FullscreenMode": "Полноэкранный режим",
"ExitingFullscreenMode": "Выход из полноэкранного режима", "ExitingFullscreenMode": "Выход из полноэкранного режима",
"Select": "Выбрать", "Select": "Выбрать",
"ChooseShare": "Выберите, чем вы хотите поделиться" "ChooseShare": "Выберите, чем вы хотите поделиться",
"CreateMeeting": "Создать встречу"
} }
} }

View File

@ -59,6 +59,7 @@
"FullscreenMode": "全屏模式", "FullscreenMode": "全屏模式",
"ExitingFullscreenMode": "退出全屏模式", "ExitingFullscreenMode": "退出全屏模式",
"Select": "选择", "Select": "选择",
"ChooseShare": "选择共享内容" "ChooseShare": "选择共享内容",
"CreateMeeting": "创建会议"
} }
} }

View File

@ -40,6 +40,7 @@
"@hcengineering/platform": "^0.6.11", "@hcengineering/platform": "^0.6.11",
"svelte": "^4.2.12", "svelte": "^4.2.12",
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/contact": "^0.6.24", "@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0", "@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/view-resources": "^0.6.0", "@hcengineering/view-resources": "^0.6.0",

View File

@ -122,10 +122,11 @@
if (roomInfo !== undefined) { if (roomInfo !== undefined) {
const navigateUrl = getCurrentLocation() const navigateUrl = getCurrentLocation()
navigateUrl.query = { navigateUrl.query = {
meetId: roomInfo._id sessionId: roomInfo._id
} }
const func = await getResource(login.function.GetInviteLink) const func = await getResource(login.function.GetInviteLink)
return await func(24 * 30, '', -1, AccountRole.Guest, JSON.stringify(navigateUrl)) return await func(24, '', -1, AccountRole.Guest, encodeURIComponent(JSON.stringify(navigateUrl)))
} }
return '' return ''
} }

View File

@ -0,0 +1,37 @@
<!--
// 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 { Event } from '@hcengineering/calendar'
import love from '../plugin'
import RoomSelector from './RoomSelector.svelte'
import { getClient } from '@hcengineering/presentation'
import { Ref } from '@hcengineering/core'
import { Room } from '@hcengineering/love'
export let value: Event
const client = getClient()
$: isMeeting = client.getHierarchy().hasMixin(value, love.mixin.Meeting)
$: meeting = isMeeting ? client.getHierarchy().as(value, love.mixin.Meeting) : null
async function changeRoom (val: Ref<Room>): Promise<void> {
await client.updateMixin(value._id, value._class, value.space, love.mixin.Meeting, { room: val })
}
</script>
{#if isMeeting && meeting}
<RoomSelector value={meeting?.room} on:change={(ev) => changeRoom(ev.detail)} />
{/if}

View File

@ -16,12 +16,12 @@
import { Contact, Person } from '@hcengineering/contact' import { Contact, Person } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources' import { personByIdStore } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core' import { Ref } from '@hcengineering/core'
import love, { Floor as FloorType, Office, Room, RoomInfo, isOffice } from '@hcengineering/love' import love, { Floor as FloorType, Meeting, Office, Room, RoomInfo, isOffice } from '@hcengineering/love'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { deviceOptionsStore as deviceInfo, getCurrentLocation, navigate } from '@hcengineering/ui' import { deviceOptionsStore as deviceInfo, getCurrentLocation, navigate } from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { activeFloor, floors, infos, invites, myInfo, myRequests, rooms } from '../stores' import { activeFloor, floors, infos, invites, myInfo, myRequests, rooms } from '../stores'
import { tryConnect } from '../utils' import { connectToMeeting, tryConnect } from '../utils'
import Floor from './Floor.svelte' import Floor from './Floor.svelte'
import FloorConfigure from './FloorConfigure.svelte' import FloorConfigure from './FloorConfigure.svelte'
import Floors from './Floors.svelte' import Floors from './Floors.svelte'
@ -42,25 +42,31 @@
$: $deviceInfo.replacedPanel = replacedPanel $: $deviceInfo.replacedPanel = replacedPanel
onDestroy(() => ($deviceInfo.replacedPanel = undefined)) onDestroy(() => ($deviceInfo.replacedPanel = undefined))
async function connectToSession (sessionId: string): Promise<void> {
const client = getClient()
const info = await client.findOne(love.class.RoomInfo, { _id: sessionId as Ref<RoomInfo> })
if (info === undefined) return
const room = $rooms.find((p) => p._id === info.room)
if (room === undefined) return
tryConnect(
$personByIdStore,
$myInfo,
room,
$infos.filter((p) => p.room === room._id),
$myRequests,
$invites
)
}
onMount(async () => { onMount(async () => {
const loc = getCurrentLocation() const loc = getCurrentLocation()
const { meetId, ...query } = loc.query ?? {} const { sessionId, meetId, ...query } = loc.query ?? {}
if (meetId != null) { loc.query = Object.keys(query).length === 0 ? undefined : query
loc.query = Object.keys(query).length === 0 ? undefined : query navigate(loc, true)
navigate(loc, true) if (sessionId != null) {
const client = getClient() await connectToSession(sessionId)
const info = await client.findOne(love.class.RoomInfo, { _id: meetId as Ref<RoomInfo> }) } else if (meetId != null) {
if (info === undefined) return await connectToMeeting($personByIdStore, $myInfo, $infos, $myRequests, $invites, $rooms, meetId)
const room = $rooms.find((p) => p._id === info.room)
if (room === undefined) return
tryConnect(
$personByIdStore,
$myInfo,
room,
$infos.filter((p) => p.room === room._id),
$myRequests,
$invites
)
} }
}) })
</script> </script>

View File

@ -0,0 +1,56 @@
<!--
// 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 { Ref } from '@hcengineering/core'
import { Room } from '@hcengineering/love'
import { Button, CheckBox } from '@hcengineering/ui'
import { Writable } from 'svelte/store'
import love from '../plugin'
import RoomSelector from './RoomSelector.svelte'
export let state: Writable<Record<string, any>>
function changeRoom (val: Ref<Room>) {
$state.room = val
}
function changeIsMeeting () {
$state.isMeeting = isMeeting
}
let isMeeting = false
</script>
<div class="flex-row-center gap-1-5 mt-1">
<CheckBox bind:checked={isMeeting} kind={'primary'} on:value={changeIsMeeting} />
<Button
label={love.string.CreateMeeting}
kind={'ghost'}
padding={'0 .5rem'}
justify={'left'}
on:click={() => {
isMeeting = !isMeeting
changeIsMeeting()
}}
/>
</div>
{#if isMeeting}
<RoomSelector
value={$state.room}
on:change={(ev) => {
changeRoom(ev.detail)
}}
/>
{/if}

View File

@ -0,0 +1,64 @@
<!--
// 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 { Ref } from '@hcengineering/core'
import love, { isOffice, Room } from '@hcengineering/love'
import { Dropdown, Icon } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { rooms } from '../stores'
export let value: Ref<Room> | undefined
export let disabled: boolean = false
export let focusIndex = -1
const dispatch = createEventDispatcher()
$: items = $rooms
.filter((p) => !isOffice(p))
.map((p) => {
return {
_id: p._id,
label: p.name
}
})
$: selected = value !== undefined ? items.find((p) => p._id === value) : undefined
function change (id: Ref<Room>) {
if (value !== id) {
dispatch('change', id)
value = id
}
}
</script>
{#if items.length > 0}
<div class="flex-row-center flex-gap-1">
<Icon icon={love.icon.Mic} size={'small'} />
<Dropdown
kind={'ghost'}
size={'medium'}
placeholder={love.string.Room}
{items}
withSearch={false}
{selected}
{disabled}
{focusIndex}
on:selected={(e) => {
change(e.detail._id)
}}
/>
</div>
{/if}

View File

@ -1,10 +1,12 @@
import { type Resources } from '@hcengineering/platform' import { type Resources } from '@hcengineering/platform'
import ControlExt from './components/ControlExt.svelte' import ControlExt from './components/ControlExt.svelte'
import EditMeetingData from './components/EditMeetingData.svelte'
import Main from './components/Main.svelte' import Main from './components/Main.svelte'
import MeetingData from './components/MeetingData.svelte'
import SelectScreenSourcePopup from './components/SelectScreenSourcePopup.svelte'
import Settings from './components/Settings.svelte' import Settings from './components/Settings.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte' import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import SelectScreenSourcePopup from './components/SelectScreenSourcePopup.svelte' import { createMeeting, toggleMic, toggleVideo } from './utils'
import { toggleMic, toggleVideo } from './utils'
export { setCustomCreateScreenTracks } from './utils' export { setCustomCreateScreenTracks } from './utils'
@ -14,7 +16,12 @@ export default async (): Promise<Resources> => ({
ControlExt, ControlExt,
Settings, Settings,
WorkbenchExtension, WorkbenchExtension,
SelectScreenSourcePopup SelectScreenSourcePopup,
MeetingData,
EditMeetingData
},
function: {
CreateMeeting: createMeeting
}, },
actionImpl: { actionImpl: {
ToggleMic: toggleMic, ToggleMic: toggleMic,

View File

@ -13,15 +13,22 @@
// limitations under the License. // limitations under the License.
// //
import { mergeIds, type IntlString } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui'
import love, { loveId } from '@hcengineering/love' import love, { loveId } from '@hcengineering/love'
import { mergeIds, type IntlString, type Resource } from '@hcengineering/platform'
import { type DocCreateFunction } from '@hcengineering/presentation'
import { type AnyComponent } from '@hcengineering/ui'
export default mergeIds(loveId, love, { export default mergeIds(loveId, love, {
component: { component: {
ControlExt: '' as AnyComponent ControlExt: '' as AnyComponent,
MeetingData: '' as AnyComponent,
EditMeetingData: '' as AnyComponent
},
function: {
CreateMeeting: '' as Resource<DocCreateFunction>
}, },
string: { string: {
CreateMeeting: '' as IntlString,
LeaveRoom: '' as IntlString, LeaveRoom: '' as IntlString,
LeaveRoomConfirmation: '' as IntlString, LeaveRoomConfirmation: '' as IntlString,
Mute: '' as IntlString, Mute: '' as IntlString,

View File

@ -1,6 +1,17 @@
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import calendar, { type Event } from '@hcengineering/calendar'
import contact, { getName, type Person, type PersonAccount } from '@hcengineering/contact' import contact, { getName, type Person, type PersonAccount } from '@hcengineering/contact'
import core, { concatLink, getCurrentAccount, type IdMap, type Ref, type Space } from '@hcengineering/core' import core, {
AccountRole,
concatLink,
getCurrentAccount,
type Data,
type IdMap,
type Ref,
type Space,
type TxOperations
} from '@hcengineering/core'
import login from '@hcengineering/login'
import { import {
RequestStatus, RequestStatus,
RoomAccess, RoomAccess,
@ -9,12 +20,13 @@ import {
loveId, loveId,
type Invite, type Invite,
type JoinRequest, type JoinRequest,
type Meeting,
type Office, type Office,
type ParticipantInfo, type ParticipantInfo,
type Room type Room
} from '@hcengineering/love' } from '@hcengineering/love'
import { getEmbeddedLabel, getMetadata, type IntlString } from '@hcengineering/platform' import { getEmbeddedLabel, getMetadata, getResource, type IntlString } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation' import presentation, { createQuery, getClient, type DocCreatePhase } from '@hcengineering/presentation'
import { getCurrentLocation, navigate, type DropdownTextItem } from '@hcengineering/ui' import { getCurrentLocation, navigate, type DropdownTextItem } from '@hcengineering/ui'
import { KrispNoiseFilter, isKrispNoiseFilterSupported } from '@livekit/krisp-noise-filter' import { KrispNoiseFilter, isKrispNoiseFilterSupported } 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'
@ -555,6 +567,33 @@ function checkPlace (room: Room, info: ParticipantInfo[], x: number, y: number):
return !isOffice(room) && info.find((p) => p.x === x && p.y === y) === undefined return !isOffice(room) && info.find((p) => p.x === x && p.y === y) === undefined
} }
export async function connectToMeeting (
personByIdStore: IdMap<Person>,
currentInfo: ParticipantInfo | undefined,
info: ParticipantInfo[],
currentRequests: JoinRequest[],
currentInvites: Invite[],
rooms: Room[],
meetId: string
): Promise<void> {
const client = getClient()
const meeting = await client.findOne(love.mixin.Meeting, { _id: meetId as Ref<Meeting> })
if (meeting === undefined) return
const room = rooms.find((p) => p._id === meeting.room)
if (room === undefined) return
// check time (it should be 10 minutes before the meeting or active in roomInfo)
await tryConnect(
personByIdStore,
currentInfo,
room,
info.filter((p) => p.room === room._id),
currentRequests,
currentInvites
)
}
export async function tryConnect ( export async function tryConnect (
personByIdStore: IdMap<Person>, personByIdStore: IdMap<Person>,
currentInfo: ParticipantInfo | undefined, currentInfo: ParticipantInfo | undefined,
@ -720,3 +759,27 @@ async function checkRecordAvailable (): Promise<void> {
} }
void checkRecordAvailable() void checkRecordAvailable()
export async function createMeeting (
client: TxOperations,
_id: Ref<Event>,
space: Space,
data: Data<Event>,
store: Record<string, any>,
phase: DocCreatePhase
): Promise<void> {
if (phase === 'post' && store.room != null && store.isMeeting === true) {
await client.createMixin<Event, Meeting>(_id, calendar.class.Event, space._id, love.mixin.Meeting, {
room: store.room as Ref<Room>
})
const event = await client.findOne(calendar.class.Event, { _id })
if (event === undefined) return
const navigateUrl = getCurrentLocation()
navigateUrl.query = {
meetId: _id
}
const func = await getResource(login.function.GetInviteLink)
const link = await func(-1, '', -1, AccountRole.Guest, encodeURIComponent(JSON.stringify(navigateUrl)))
await client.update(event, { location: link })
}
}

View File

@ -43,6 +43,7 @@
"@hcengineering/preference": "^0.6.13", "@hcengineering/preference": "^0.6.13",
"@hcengineering/notification": "^0.6.23", "@hcengineering/notification": "^0.6.23",
"@hcengineering/ui": "^0.6.15", "@hcengineering/ui": "^0.6.15",
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/drive": "^0.6.0", "@hcengineering/drive": "^0.6.0",
"@hcengineering/core": "^0.6.32", "@hcengineering/core": "^0.6.32",
"@hcengineering/view": "^0.6.13" "@hcengineering/view": "^0.6.13"

View File

@ -1,5 +1,6 @@
import { Event } from '@hcengineering/calendar'
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import { Class, Doc, Ref } from '@hcengineering/core' import { Class, 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'
@ -58,6 +59,10 @@ export interface RoomInfo extends Doc {
isOffice: boolean isOffice: boolean
} }
export interface Meeting extends Event {
room: Ref<Room>
}
export enum RequestStatus { export enum RequestStatus {
Pending, Pending,
Approved, Approved,
@ -97,6 +102,9 @@ const love = plugin(loveId, {
RoomInfo: '' as Ref<Class<RoomInfo>>, RoomInfo: '' as Ref<Class<RoomInfo>>,
Invite: '' as Ref<Class<Invite>> Invite: '' as Ref<Class<Invite>>
}, },
mixin: {
Meeting: '' as Ref<Mixin<Meeting>>
},
action: { action: {
ToggleMic: '' as Ref<Action>, ToggleMic: '' as Ref<Action>,
ToggleVideo: '' as Ref<Action> ToggleVideo: '' as Ref<Action>

View File

@ -24,7 +24,6 @@ import contact, {
Person, Person,
PersonAccount PersonAccount
} from '@hcengineering/contact' } from '@hcengineering/contact'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import core, { import core, {
AccountRole, AccountRole,
BaseWorkspaceInfo, BaseWorkspaceInfo,
@ -40,30 +39,17 @@ import core, {
Ref, Ref,
roleOrder, roleOrder,
systemAccountEmail, systemAccountEmail,
Timestamp,
Tx, Tx,
TxOperations, TxOperations,
Version, Version,
versionToString, versionToString,
WorkspaceId, WorkspaceId,
Timestamp,
WorkspaceIdWithUrl, WorkspaceIdWithUrl,
type Branding type Branding
} from '@hcengineering/core' } from '@hcengineering/core'
import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model' import { consoleModelLogger, MigrateOperation, ModelLogger } from '@hcengineering/model'
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
import { decodeToken, generateToken } from '@hcengineering/server-token'
import toolPlugin, {
connect,
initializeWorkspace,
initModel,
updateModel,
prepareTools,
upgradeModel
} from '@hcengineering/server-tool'
import { pbkdf2Sync, randomBytes } from 'crypto'
import { Binary, Db, Filter, ObjectId, type MongoClient } from 'mongodb'
import fetch from 'node-fetch'
import otpGenerator from 'otp-generator'
import { import {
DummyFullTextAdapter, DummyFullTextAdapter,
Pipeline, Pipeline,
@ -78,6 +64,20 @@ import {
registerServerPlugins, registerServerPlugins,
registerStringLoaders registerStringLoaders
} from '@hcengineering/server-pipeline' } from '@hcengineering/server-pipeline'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import { decodeToken, generateToken } from '@hcengineering/server-token'
import toolPlugin, {
connect,
initializeWorkspace,
initModel,
prepareTools,
updateModel,
upgradeModel
} from '@hcengineering/server-tool'
import { pbkdf2Sync, randomBytes } from 'crypto'
import { Binary, Db, Filter, ObjectId, type MongoClient } from 'mongodb'
import fetch from 'node-fetch'
import otpGenerator from 'otp-generator'
import { accountPlugin } from './plugin' import { accountPlugin } from './plugin'
@ -692,7 +692,7 @@ export async function checkInvite (ctx: MeasureContext, invite: Invite | null, e
Analytics.handleError(new Error(`no invite or invite limit exceed ${email}`)) Analytics.handleError(new Error(`no invite or invite limit exceed ${email}`))
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
} }
if (invite.exp < Date.now()) { if (invite.exp !== -1 && invite.exp < Date.now()) {
ctx.error('invite', { email, state: 'link expired' }) ctx.error('invite', { email, state: 'link expired' })
Analytics.handleError(new Error(`invite link expired ${invite._id.toString()} ${email}`)) Analytics.handleError(new Error(`invite link expired ${invite._id.toString()} ${email}`))
throw new PlatformError(new Status(Severity.ERROR, platform.status.ExpiredLink, {})) throw new PlatformError(new Status(Severity.ERROR, platform.status.ExpiredLink, {}))
@ -1577,7 +1577,7 @@ export async function getInviteLink (
ctx.info('Getting invite link', { workspace: workspace.name, emailMask, limit }) ctx.info('Getting invite link', { workspace: workspace.name, emailMask, limit })
const data: Omit<Invite, '_id'> = { const data: Omit<Invite, '_id'> = {
workspace, workspace,
exp: Date.now() + exp, exp: exp < 0 ? -1 : Date.now() + exp,
emailMask, emailMask,
limit, limit,
role: role ?? AccountRole.User role: role ?? AccountRole.User

View File

@ -27,7 +27,10 @@ import core, {
AttachedData, AttachedData,
Client, Client,
Data, Data,
Doc,
DocData,
DocumentUpdate, DocumentUpdate,
Mixin,
Ref, Ref,
TxOperations, TxOperations,
TxUpdateDoc, TxUpdateDoc,
@ -42,10 +45,10 @@ import type { Collection, Db } from 'mongodb'
import { encode64 } from './base64' import { encode64 } from './base64'
import { CalendarController } from './calendarController' import { CalendarController } from './calendarController'
import config from './config' import config from './config'
import { RateLimiter } from './rateLimiter'
import type { CalendarHistory, EventHistory, EventWatch, ProjectCredentials, State, Token, User, Watch } from './types' import type { CalendarHistory, EventHistory, EventWatch, ProjectCredentials, State, Token, User, Watch } from './types'
import { encodeReccuring, isToken, parseRecurrenceStrings } from './utils' import { encodeReccuring, isToken, parseRecurrenceStrings } from './utils'
import type { WorkspaceClient } from './workspaceClient' import type { WorkspaceClient } from './workspaceClient'
import { RateLimiter } from './rateLimiter'
const SCOPES = [ const SCOPES = [
'https://www.googleapis.com/auth/calendar.calendars.readonly', 'https://www.googleapis.com/auth/calendar.calendars.readonly',
@ -703,7 +706,7 @@ export class CalendarClient {
} }
const data: Partial<AttachedData<Event>> = await this.parseUpdateData(event) const data: Partial<AttachedData<Event>> = await this.parseUpdateData(event)
if (event.recurringEventId != null) { if (event.recurringEventId != null) {
const diff = this.getEventDiff<ReccuringInstance>( const diff = this.getDiff<ReccuringInstance>(
{ {
...data, ...data,
recurringEventId: event.recurringEventId as Ref<ReccuringEvent>, recurringEventId: event.recurringEventId as Ref<ReccuringEvent>,
@ -719,7 +722,7 @@ export class CalendarClient {
} else { } else {
if (event.recurrence != null) { if (event.recurrence != null) {
const parseRule = parseRecurrenceStrings(event.recurrence) const parseRule = parseRecurrenceStrings(event.recurrence)
const diff = this.getEventDiff<ReccuringEvent>( const diff = this.getDiff<ReccuringEvent>(
{ {
...data, ...data,
rules: parseRule.rules, rules: parseRule.rules,
@ -733,13 +736,70 @@ export class CalendarClient {
await this.client.update(current, diff) await this.client.update(current, diff)
} }
} else { } else {
const diff = this.getEventDiff(data, current) const diff = this.getDiff(data, current)
if (Object.keys(diff).length > 0) { if (Object.keys(diff).length > 0) {
console.log('UPDATE EVENT DIFF', JSON.stringify(diff), JSON.stringify(current)) console.log('UPDATE EVENT DIFF', JSON.stringify(diff), JSON.stringify(current))
await this.client.update(current, diff) await this.client.update(current, diff)
} }
} }
} }
await this.updateMixins(event, current)
}
private async updateMixins (event: calendar_v3.Schema$Event, current: Event): Promise<void> {
const mixins = this.parseMixins(event)
if (mixins !== undefined) {
for (const mixin in mixins) {
const attr = mixins[mixin]
if (typeof attr === 'object' && Object.keys(attr).length > 0) {
if (this.client.getHierarchy().hasMixin(current, mixin as Ref<Mixin<Doc>>)) {
const diff = this.getDiff(attr, this.client.getHierarchy().as(current, mixin as Ref<Mixin<Doc>>))
if (Object.keys(diff).length > 0) {
await this.client.updateMixin(
current._id,
current._class,
calendar.space.Calendar,
mixin as Ref<Mixin<Doc>>,
diff
)
}
} else {
await this.client.createMixin(
current._id,
current._class,
calendar.space.Calendar,
mixin as Ref<Mixin<Doc>>,
attr
)
}
}
}
}
}
private parseMixins (event: calendar_v3.Schema$Event): Record<string, any> | undefined {
if (event.extendedProperties?.shared?.mixins !== undefined) {
const mixins = JSON.parse(event.extendedProperties.shared.mixins)
return mixins
}
}
private async saveMixins (event: calendar_v3.Schema$Event, _id: Ref<Event>): Promise<void> {
const mixins = this.parseMixins(event)
if (mixins !== undefined) {
for (const mixin in mixins) {
const attr = mixins[mixin]
if (typeof attr === 'object' && Object.keys(attr).length > 0) {
await this.client.createMixin(
_id,
calendar.class.Event,
calendar.space.Calendar,
mixin as Ref<Mixin<Doc>>,
attr
)
}
}
}
} }
private async saveExtEvent ( private async saveExtEvent (
@ -767,6 +827,7 @@ export class CalendarClient {
timeZone: event.start?.timeZone ?? event.end?.timeZone ?? 'Etc/GMT' timeZone: event.start?.timeZone ?? event.end?.timeZone ?? 'Etc/GMT'
} }
) )
await this.saveMixins(event, id)
console.log('SAVE INSTANCE', id, JSON.stringify(event)) console.log('SAVE INSTANCE', id, JSON.stringify(event))
} else if (event.status !== 'cancelled') { } else if (event.status !== 'cancelled') {
if (event.recurrence != null) { if (event.recurrence != null) {
@ -786,6 +847,7 @@ export class CalendarClient {
timeZone: event.start?.timeZone ?? event.end?.timeZone ?? 'Etc/GMT' timeZone: event.start?.timeZone ?? event.end?.timeZone ?? 'Etc/GMT'
} }
) )
await this.saveMixins(event, id)
console.log('SAVE REC EVENT', id, JSON.stringify(event)) console.log('SAVE REC EVENT', id, JSON.stringify(event))
} else { } else {
const id = await this.client.addCollection( const id = await this.client.addCollection(
@ -796,12 +858,13 @@ export class CalendarClient {
'events', 'events',
data data
) )
await this.saveMixins(event, id)
console.log('SAVE EVENT', id, JSON.stringify(event)) console.log('SAVE EVENT', id, JSON.stringify(event))
} }
} }
} }
private getEventDiff<T extends Event>(data: Partial<AttachedData<T>>, current: T): Partial<AttachedData<T>> { private getDiff<T extends Doc>(data: Partial<DocData<T>>, current: T): Partial<DocData<T>> {
const res = {} const res = {}
for (const key in data) { for (const key in data) {
if (!deepEqual((data as any)[key], (current as any)[key])) { if (!deepEqual((data as any)[key], (current as any)[key])) {
@ -1120,6 +1183,24 @@ export class CalendarClient {
return false return false
} }
private getMixinFields (event: Event): Record<string, any> {
const res = {}
const h = this.client.getHierarchy()
for (const [k, v] of Object.entries(event)) {
if (typeof v === 'object' && h.isMixin(k as Ref<Mixin<Doc>>)) {
for (const [key, value] of Object.entries(v)) {
if (value !== undefined) {
const obj = (res as any)[k] ?? {}
obj[key] = value
;(res as any)[k] = obj
}
}
}
}
return res
}
private convertBody (event: Event): calendar_v3.Schema$Event { private convertBody (event: Event): calendar_v3.Schema$Event {
const res: calendar_v3.Schema$Event = { const res: calendar_v3.Schema$Event = {
start: convertDate(event.date, event.allDay, getTimezone(event)), start: convertDate(event.date, event.allDay, getTimezone(event)),
@ -1141,6 +1222,16 @@ export class CalendarClient {
} }
} }
} }
const mixin = this.getMixinFields(event)
if (Object.keys(mixin).length > 0) {
res.extendedProperties = {
...res.extendedProperties,
shared: {
...res.extendedProperties?.shared,
mixin: JSON.stringify(mixin)
}
}
}
if (event.reminders !== undefined) { if (event.reminders !== undefined) {
res.reminders = { res.reminders = {
useDefault: false, useDefault: false,