diff --git a/lib/Service/Proposal/ProposalService.php b/lib/Service/Proposal/ProposalService.php index 2a913bcd4e..b30ed47ca7 100644 --- a/lib/Service/Proposal/ProposalService.php +++ b/lib/Service/Proposal/ProposalService.php @@ -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; @@ -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; } } @@ -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; @@ -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()); @@ -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; } diff --git a/src/services/autocompleteService.ts b/src/services/autocompleteService.ts new file mode 100644 index 0000000000..df82f133cb --- /dev/null +++ b/src/services/autocompleteService.ts @@ -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 + +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 { + // 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[] +} diff --git a/src/services/talkService.ts b/src/services/talkService.ts new file mode 100644 index 0000000000..ac55714170 --- /dev/null +++ b/src/services/talkService.ts @@ -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 + +/** + * 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 { + // 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('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(path: string, data: TRequest): Promise { + 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 +} + +export default { + createTalkRoomFromProposal, +} diff --git a/src/store/proposalStore.ts b/src/store/proposalStore.ts index 6c2c0be4c3..17ae83d13a 100644 --- a/src/store/proposalStore.ts +++ b/src/store/proposalStore.ts @@ -4,8 +4,10 @@ */ 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' @@ -13,6 +15,7 @@ export default defineStore('proposal', () => { const modalVisible = ref(false) const modalMode = ref<'view' | 'create' | 'modify'>('view') const modalProposal = ref(null) + /** * Show the proposal modal dialog. * @@ -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 { + async function convertProposal(proposal: ProposalInterface, date: ProposalDateInterface, timezone: string|null = null): Promise { + 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) } diff --git a/src/types/autocomplete.ts b/src/types/autocomplete.ts new file mode 100644 index 0000000000..138141da86 --- /dev/null +++ b/src/types/autocomplete.ts @@ -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 +} diff --git a/src/types/ocs.ts b/src/types/ocs.ts new file mode 100644 index 0000000000..d5d0660dba --- /dev/null +++ b/src/types/ocs.ts @@ -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 = { + ocs: { + meta: OcsMeta + data: T | OcsErrorData + } +} diff --git a/src/types/talk.ts b/src/types/talk.ts new file mode 100644 index 0000000000..c5f27034bf --- /dev/null +++ b/src/types/talk.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export type TalkRoom = { + actorId: string + invitedActorId: string + actorType: string + attendeeId: number + attendeePermissions: number + attendeePin: string + avatarVersion: string + breakoutRoomMode: number + breakoutRoomStatus: number + callFlag: number + callPermissions: number + callRecording: number + callStartTime: number + canDeleteConversation: boolean + canEnableSIP: boolean + canLeaveConversation: boolean + canStartCall: boolean + defaultPermissions: number + description: string + displayName: string + hasCall: boolean + hasPassword: boolean + id: number + isCustomAvatar: boolean + isFavorite: boolean + lastActivity: number + lastCommonReadMessage: number + lastMessage: string + lastPing: number + lastReadMessage: number + listable: number + liveTranscriptionLanguageId: string + lobbyState: number + lobbyTimer: number + mentionPermissions: number + messageExpiration: number + name: string + notificationCalls: number + notificationLevel: number + objectId: string + objectType: string + participantFlags: number + participantType: number + permissions: number + readOnly: number + recordingConsent: number + remoteServer: string + remoteToken: string + sessionId: string + sipEnabled: number + status: string + statusClearAt: number + statusIcon: string + statusMessage: string + token: string + type: number + unreadMention: boolean + unreadMentionDirect: boolean + unreadMessages: number + isArchived: boolean + isImportant: boolean + isSensitive: boolean + invalidParticipants?: { + users?: string[] + federated_users?: string[] + groups?: string[] + emails?: string[] + phones?: string[] + teams?: string[] + } +} diff --git a/src/views/Proposal/ProposalEditor.vue b/src/views/Proposal/ProposalEditor.vue index bbb52b94bd..a609f63553 100644 --- a/src/views/Proposal/ProposalEditor.vue +++ b/src/views/Proposal/ProposalEditor.vue @@ -72,12 +72,12 @@ class="proposal-editor__proposal-description" :label="t('calendar', 'Description')" />
- - + @@ -600,21 +600,6 @@ export default { this.changeDuration(duration) }, - onProposalLocationInput(event: Event) { - const value = (event.target as HTMLInputElement).value - if (!this.selectedProposal) { - return console.error('No proposal selected for this operation') - } - if (this.selectedProposal.location !== 'Talk conversation') { - this.selectedProposal.location = value - } else { - // Prevent the input change when location is 'Talk conversation' - event.preventDefault(); - (event.target as HTMLInputElement).value = 'Talk conversation' - return false - } - }, - onProposalLocationTypeToggle(): void { if (!this.selectedProposal) { return