Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions lib/Service/Proposal/ProposalService.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use OCA\Calendar\Objects\Proposal\ProposalResponseObject;
use OCA\Calendar\Objects\Proposal\ProposalVoteCollection;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarIsWritable;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager;
use OCP\IAppConfig;
Expand Down Expand Up @@ -320,9 +321,9 @@ public function convertProposal(IUser $user, int $proposalId, int $dateId, array
// if no primary calendar is set, use the first useable calendar
if ($userCalendar === null) {
$userCalendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
foreach ($userCalendars as $userCalendar) {
if (!$userCalendar instanceof ICreateFromString || !$userCalendar->isDeleted()) {
$userCalendar = $userCalendar;
foreach ($userCalendars as $calendar) {
if ($calendar instanceof ICreateFromString && $calendar instanceof ICalendarIsWritable && $calendar->isWritable() && !$calendar->isDeleted()) {
$userCalendar = $calendar;
break;
}
}
Expand All @@ -335,7 +336,9 @@ public function convertProposal(IUser $user, int $proposalId, int $dateId, array
// timezone option
$eventTimezone = null;
if (isset($options['timezone']) && is_string($options['timezone']) && in_array($options['timezone'], DateTimeZone::listIdentifiers(), true)) {
$eventTimezone = new DateTimeZone($options['timezone']);
if (!empty($options['timezone'])) {
$eventTimezone = new DateTimeZone($options['timezone']);
}
}
// participant attendance option
$eventAttendancePreset = false;
Expand All @@ -354,8 +357,7 @@ public function convertProposal(IUser $user, int $proposalId, int $dateId, array
$vEvent = $vObject->add('VEVENT', []);
$vEvent->UID->setValue($proposal->getUuid() ?? Uuid::v4()->toRfc4122());
$vEvent->add('DTSTART', $eventTimezone ? $selectedDate->getDate()->setTimezone($eventTimezone) : $selectedDate->getDate());
$vEvent->add('DURATION', "PT{$proposal->getDuration()}M");
$vEvent->add('STATUS', 'CONFIRMED');
$vEvent->add('DTEND', (clone $vEvent->DTSTART->getDateTime())->add(new \DateInterval("PT{$proposal->getDuration()}M")));
$vEvent->add('SEQUENCE', 1);
$vEvent->add('SUMMARY', $proposal->getTitle());
$vEvent->add('DESCRIPTION', $proposal->getDescription());
Expand Down Expand Up @@ -634,11 +636,16 @@ private function generateIMip(IUser $user, ProposalObject $proposal, string $rea
}

foreach ($proposal->getParticipants()->filterByRealm(ProposalParticipantRealm::Internal) as $participant) {
$participantAddress = $participant->getAddress();
if ($participantAddress === null) {
continue;
}

// TODO: this is stupid, we send the internal users email address from the UI then convert it back to a user name
// should probably be sent from the UI as a user name, or send and store both the user name and email address
// maybe send the address as a special schema "local:{user name}/{email address}", this would allow us to later extend this to federated users
// with a different special schema like "federated:{user name}@{server}/{email address}"
$participantUsers = $this->userManager->getByEmail($participant->getAddress());
$participantUsers = $this->userManager->getByEmail($participantAddress);
if ($participantUsers === []) {
continue;
}
Expand Down
71 changes: 71 additions & 0 deletions src/services/autocompleteService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { generateOcsUrl } from '@nextcloud/router'
import type { AutocompleteEntry } from '@/types/autocomplete'
import type { OcsEnvelope } from '@/types/ocs'

export type AutocompleteResponse = OcsEnvelope<AutocompleteEntry[] | AutocompleteEntry>

export type AutocompleteOptions = {
search: string
itemType?: string
itemId?: string
sorter?: string
shareTypes?: number[]
limit?: number
}

/**
* search for users, groups, contacts, ...
*
* @param {AutocompleteOptions} search search options
*/
export async function autocomplete(search: AutocompleteOptions): Promise<AutocompleteEntry[]> {
// construct query parameters
const params = new URLSearchParams()
params.set('search', search.search)
if (search.itemType) {
params.set('itemType', search.itemType)
}
if (search.itemId) {
params.set('itemId', search.itemId)
}
if (search.sorter) {
params.set('sorter', search.sorter)
}
for (const st of search.shareTypes ?? [0]) {
params.append('shareTypes[]', String(st))
}
if (search.limit) {
params.set('limit', String(search.limit))
}
// transceive
const url = `${generateOcsUrl('/core/autocomplete/get')}?${params.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
'OCS-APIREQUEST': 'true',
Accept: 'application/json',
},
credentials: 'same-origin',
})
// handle protocol errors
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Autocomplete error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`)
}
// parse response
const data = await response.json() as AutocompleteResponse
// response sanity checks
if (!data.ocs || !data.ocs.meta || !data.ocs.data || !data.ocs.meta.status) {
throw new Error('Autocomplete error: malformed response')
}
if (data.ocs.meta.status !== 'ok') {
throw new Error(`Autocomplete error: ${data.ocs.meta.message || 'unknown error'}`)
}

return Array.isArray(data.ocs.data) ? data.ocs.data as AutocompleteEntry[] : [data.ocs.data] as AutocompleteEntry[]
}
162 changes: 162 additions & 0 deletions src/services/talkService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl, generateUrl, getBaseUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import md5 from 'md5'

import type { ProposalInterface, ProposalParticipantInterface } from '@/types/proposals/proposalInterfaces'
import { ProposalParticipantRealm } from '@/types/proposals/proposalEnums'
import { autocomplete } from './autocompleteService'
import type { AutocompleteEntry } from '@/types/autocomplete'
import type { TalkRoom } from '@/types/talk'
import type { OcsEnvelope } from '@/types/ocs'

type TalkRoomCreateRequest = {
roomType: number
roomName: string
objectType: string
objectId: string
password?: string
readOnly?: number
listable?: number
messageExpiration?: number
lobbyState?: number
lobbyTimer?: number | null
sipEnabled?: number
permissions?: number
recordingConsent?: number
mentionPermissions?: number
description?: string
emoji?: string | null
avatarColor?: string | null
participants?: {
users?: string[]
federated_users?: string[]
groups?: string[]
emails?: string[]
phones?: string[]
teams?: string[]
}
}

type TalkRoomCreateResponse = OcsEnvelope<TalkRoom>

/**
* Generates an absolute URL to the talk room based on the token
*
* @param token The Talk conversation token to build the URL for
*/
export function generateURLForToken(token: string): string {
return generateUrl('/call/' + token, {}, { baseURL: getBaseUrl() })
}

/**
* Create a Talk room from a proposal and return the created room details
*
* @param proposal The source proposal to derive room name, description, and participants from
*/
export async function createTalkRoomFromProposal(proposal: ProposalInterface): Promise<TalkRoom> {
// resolve participants to user IDs or emails
const participantPromises = (proposal.participants || [])
.filter((participant): participant is ProposalParticipantInterface => !!participant?.address)
.map((participant) => {
return new Promise<{ userId?: string; email?: string }>((resolve) => {
(async () => {
// internal users are resolved to user IDs if possible
// otherwise fallback to email
// external users are added by email
if (participant.realm === ProposalParticipantRealm.Internal) {
try {
const matches: AutocompleteEntry[] = await autocomplete({ search: participant.address, limit: 1, itemType: 'users' })
if (matches[0] !== undefined && matches[0].shareWithDisplayNameUnique === participant.address) {
resolve({ userId: matches[0].id })
return
}
resolve({ email: participant.address })
} catch {
resolve({ email: participant.address })
}
} else {
resolve({ email: participant.address })
}
})()
})
})
// construct internal and external lists participants from resolved participants
const users: string[] = []
const emails: string[] = []
const participantResults = await Promise.all(participantPromises)
for (const result of participantResults) {
if (result.userId) {
users.push(result.userId)
} else if (result.email) {
emails.push(result.email)
}
}

const payload: TalkRoomCreateRequest = {
roomType: 3,
roomName: proposal.title || t('calendar', 'Talk conversation for proposal'),
objectType: 'event',
objectId: proposal.uuid || md5(String(Date.now())),
description: proposal.description ?? '',
}

payload.participants = {}
if (users.length) payload.participants.users = users
if (emails.length) payload.participants.emails = emails

try {
const response = await transceivePost<TalkRoomCreateRequest, TalkRoomCreateResponse>('room', payload)
// response sanity checks
if (!response.ocs || !response.ocs.meta || !response.ocs.data || !response.ocs.meta.status) {
throw new Error('Talk create error: malformed response')
}
if (response.ocs.meta.status !== 'ok') {
throw new Error(`Talk create error: ${response.ocs.meta.message || 'unknown error'}`)
}

return Array.isArray(response.ocs.data) ? response.ocs.data[0] as TalkRoom : response.ocs.data as TalkRoom
} catch (error) {
console.debug(error)
throw error
}
}

/**
* Generic function to POST data to the Talk OCS API and return the typed response
*
* @param path API path segment to append after the version (for example: "room")
* @param data The request payload body to send as JSON
*/
async function transceivePost<TRequest extends object, TResponse>(path: string, data: TRequest): Promise<TResponse> {
const apiVersion = loadState('calendar', 'talk_api_version')
const response = await fetch(
generateOcsUrl('/apps/spreed/api/{apiVersion}/{path}', { apiVersion, path }),
{
method: 'POST',
headers: {
'OCS-APIREQUEST': 'true',
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(data),
},
)

if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`)
}

return response.json() as Promise<TResponse>
}

export default {
createTalkRoomFromProposal,
}
13 changes: 12 additions & 1 deletion src/store/proposalStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
*/

import { proposalService } from '@/services/proposalService'
import { createTalkRoomFromProposal, generateURLForToken } from '@/services/talkService'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import useSettingsStore from '@/store/settings'
import { Proposal } from '@/models/proposals/proposals'
import type { ProposalInterface, ProposalDateInterface, ProposalResponseInterface } from '@/types/proposals/proposalInterfaces'

export default defineStore('proposal', () => {
const modalVisible = ref(false)
const modalMode = ref<'view' | 'create' | 'modify'>('view')
const modalProposal = ref<ProposalInterface | null>(null)

/**
* Show the proposal modal dialog.
*
Expand Down Expand Up @@ -100,11 +103,19 @@ export default defineStore('proposal', () => {
* @param date - The proposed date to convert to a meeting.
* @param timezone - The timezone to use for the meeting.
*/
async function convertProposal(proposal: ProposalInterface, date: ProposalDateInterface, timezone: string): Promise<void> {
async function convertProposal(proposal: ProposalInterface, date: ProposalDateInterface, timezone: string|null = null): Promise<void> {
const settingsStore = useSettingsStore()
const options = {
timezone,
attendancePreset: true,
talkRoomUri: null as string|null,
}

if (settingsStore.talkEnabled && proposal.location === 'Talk conversation') {
const talkRoom = await createTalkRoomFromProposal(proposal)
options.talkRoomUri = generateURLForToken(talkRoom.token)
}

await proposalService.convertProposal(proposal, date, options)
}

Expand Down
19 changes: 19 additions & 0 deletions src/types/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

export type AutocompleteEntryStatus = {
status: string
message: string
icon: string
clearAt: number
}

export type AutocompleteEntry = {
id: string
label: string
icon: string
source: string
status?: AutocompleteEntryStatus
subline?: string
shareWithDisplayNameUnique?: string
}
22 changes: 22 additions & 0 deletions src/types/ocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

export type OcsMeta = {
status: string
statuscode: number
message: string
totalitems: string
itemsperpage: string
}

export type OcsErrorData = {
error: string
message?: string
}

export type OcsEnvelope<T> = {
ocs: {
meta: OcsMeta
data: T | OcsErrorData
}
}
Loading
Loading