From 298b69b3e17bdf345af48b3ae9f0e6b54d705cac Mon Sep 17 00:00:00 2001 From: lenhanphung Date: Thu, 2 Oct 2025 16:16:04 +0700 Subject: [PATCH] feat: add video conference meeting link generation to event modal - Add video conference utility functions for generating random meeting IDs - Implement video meeting button with camera icon in EventModal - Add copy and delete functionality with IconButton components - Support X-OPENPAAS-VIDEOCONFERENCE field in CalendarEvent type - Handle state management for editing events with/without video conference - Add meeting link footer to event description - Include comprehensive test suite for video conference utilities Features: - Generate random meeting links (format: xxx-xxxx-xxx) - Copy meeting link to clipboard - Remove video conference from events - Proper state sync when editing different events - Integration with existing JCal/ICS conversion --- public/.env.example.js | 1 + src/features/Events/EventModal.tsx | 101 ++++++++++++++++++ .../__test__/videoConferenceUtils.test.ts | 65 +++++++++++ src/utils/videoConferenceUtils.ts | 48 +++++++++ 4 files changed, 215 insertions(+) create mode 100644 src/utils/__test__/videoConferenceUtils.test.ts create mode 100644 src/utils/videoConferenceUtils.ts diff --git a/public/.env.example.js b/public/.env.example.js index 4a32816..3fce390 100644 --- a/public/.env.example.js +++ b/public/.env.example.js @@ -7,4 +7,5 @@ var SSO_CODE_CHALLENGE_METHOD = "S256"; var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1"; var CALENDAR_BASE_URL = "https://calendar.example.com"; var MAIL_SPA_URL = "https://mail.example.com"; +var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com" var DEBUG = false; diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 30156fc..8bca859 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -5,6 +5,7 @@ import { Checkbox, FormControl, FormControlLabel, + IconButton, InputLabel, MenuItem, Select, @@ -18,6 +19,9 @@ import { Description as DescriptionIcon, Public as PublicIcon, Lock as LockIcon, + CameraAlt as VideocamIcon, + ContentCopy as CopyIcon, + Close as DeleteIcon, } from "@mui/icons-material"; import React, { useEffect, useState, useMemo } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; @@ -30,6 +34,7 @@ import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { createSelector } from "@reduxjs/toolkit"; import RepeatEvent from "../../components/Event/EventRepeat"; import { TIMEZONES } from "../../utils/timezone-data"; +import { generateMeetingLink, addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; // Helper component for field with label const FieldWithLabel = React.memo( @@ -194,6 +199,12 @@ function EventPopover({ const [timezone, setTimezone] = useState( event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz ); + const [hasVideoConference, setHasVideoConference] = useState( + event?.x_openpass_videoconference ? true : false + ); + const [meetingLink, setMeetingLink] = useState( + event?.x_openpass_videoconference || null + ); useEffect(() => { if (selectedRange) { @@ -209,8 +220,50 @@ function EventPopover({ ? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address) : [] ); + // Update video conference state when editing different events + setHasVideoConference(event?.x_openpass_videoconference ? true : false); + setMeetingLink(event?.x_openpass_videoconference || null); + // Update description to include video conference footer if exists + if (event?.x_openpass_videoconference && event?.description) { + const hasVideoFooter = event.description.includes('Visio:'); + if (!hasVideoFooter) { + setDescription(addVideoConferenceToDescription(event.description, event.x_openpass_videoconference)); + } else { + setDescription(event.description); + } + } else { + setDescription(event?.description ?? ""); + } }, [event, organizer?.cal_address]); + const handleAddVideoConference = () => { + const newMeetingLink = generateMeetingLink(); + const updatedDescription = addVideoConferenceToDescription(description, newMeetingLink); + setDescription(updatedDescription); + setHasVideoConference(true); + setMeetingLink(newMeetingLink); + }; + + const handleCopyMeetingLink = async () => { + if (meetingLink) { + try { + await navigator.clipboard.writeText(meetingLink); + // You could add a toast notification here + console.log('Meeting link copied to clipboard'); + } catch (err) { + console.error('Failed to copy link:', err); + } + } + }; + + const handleDeleteVideoConference = () => { + // Remove video conference footer from description + const updatedDescription = description.replace(/\n\nVisio: https?:\/\/[^\s]+/, ''); + setDescription(updatedDescription); + setHasVideoConference(false); + setMeetingLink(null); + }; + const handleClose = () => { onClose({}, "backdropClick"); // Reset state @@ -224,6 +277,8 @@ function EventPopover({ setCalendarid(0); setImportant(false); setTimezone(timezoneList.browserTz); + setHasVideoConference(false); + setMeetingLink(null); }; const handleSave = async () => { @@ -255,6 +310,7 @@ function EventPopover({ transp: busy, color: userPersonnalCalendars[calendarid]?.color, alarm: { trigger: alarm, action: "EMAIL" }, + x_openpass_videoconference: meetingLink || undefined, }; if (end) { newEvent.end = new Date(end).toISOString(); @@ -278,6 +334,8 @@ function EventPopover({ setCalendarid(0); setImportant(false); setTimezone(timezoneList.browserTz); + setHasVideoConference(false); + setMeetingLink(null); // Save to API in background dispatch( @@ -512,6 +570,49 @@ function EventPopover({ + + + + + + {hasVideoConference && meetingLink && ( + <> + + Meeting link generated + + + + + + + + + )} + + { + describe('generateMeetingId', () => { + it('should generate meeting ID in correct format', () => { + const meetingId = generateMeetingId(); + expect(meetingId).toMatch(/^[a-z]{3}-[a-z]{4}-[a-z]{3}$/); + }); + + it('should generate different IDs each time', () => { + const id1 = generateMeetingId(); + const id2 = generateMeetingId(); + expect(id1).not.toBe(id2); + }); + }); + + describe('generateMeetingLink', () => { + it('should generate link with default base URL', () => { + const link = generateMeetingLink(); + expect(link).toMatch(/^https:\/\/meet\.linagora\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}$/); + }); + + it('should generate link with custom base URL', () => { + const customBase = 'https://custom-meet.example.com'; + const link = generateMeetingLink(customBase); + expect(link).toMatch(/^https:\/\/custom-meet\.example\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}$/); + }); + }); + + describe('addVideoConferenceToDescription', () => { + it('should add video conference footer to empty description', () => { + const description = ''; + const meetingLink = 'https://meet.linagora.com/abc-defg-hij'; + const result = addVideoConferenceToDescription(description, meetingLink); + expect(result).toBe('\n\nVisio: https://meet.linagora.com/abc-defg-hij'); + }); + + it('should add video conference footer to existing description', () => { + const description = 'This is a meeting description.'; + const meetingLink = 'https://meet.linagora.com/abc-defg-hij'; + const result = addVideoConferenceToDescription(description, meetingLink); + expect(result).toBe('This is a meeting description.\n\nVisio: https://meet.linagora.com/abc-defg-hij'); + }); + }); + + describe('extractVideoConferenceFromDescription', () => { + it('should extract video conference link from description', () => { + const description = 'Meeting description.\n\nVisio: https://meet.linagora.com/abc-defg-hij'; + const result = extractVideoConferenceFromDescription(description); + expect(result).toBe('https://meet.linagora.com/abc-defg-hij'); + }); + + it('should return null when no video conference link found', () => { + const description = 'Just a regular meeting description.'; + const result = extractVideoConferenceFromDescription(description); + expect(result).toBeNull(); + }); + + it('should return null for empty description', () => { + const description = ''; + const result = extractVideoConferenceFromDescription(description); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/utils/videoConferenceUtils.ts b/src/utils/videoConferenceUtils.ts new file mode 100644 index 0000000..4bdcf01 --- /dev/null +++ b/src/utils/videoConferenceUtils.ts @@ -0,0 +1,48 @@ +/** + * Utility functions for video conference meeting generation + */ + +/** + * Generate a random meeting ID in format xxx-xxxx-xxx + * @returns {string} Random meeting ID + */ +export function generateMeetingId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz'; + const generateSegment = (length: number): string => { + return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + }; + + return `${generateSegment(3)}-${generateSegment(4)}-${generateSegment(3)}`; +} + +/** + * Generate a complete meeting link + * @param {string} baseUrl - Base URL for video conference (from .env.js) + * @returns {string} Complete meeting link + */ +export function generateMeetingLink(baseUrl?: string): string { + const base = baseUrl || (window as any).VIDEO_CONFERENCE_BASE_URL || 'https://meet.linagora.com'; + const meetingId = generateMeetingId(); + return `${base}/${meetingId}`; +} + +/** + * Add video conference footer to event description + * @param {string} description - Original description + * @param {string} meetingLink - Generated meeting link + * @returns {string} Description with video conference footer + */ +export function addVideoConferenceToDescription(description: string, meetingLink: string): string { + const footer = `\n\nVisio: ${meetingLink}`; + return description + footer; +} + +/** + * Extract video conference link from description + * @param {string} description - Event description + * @returns {string | null} Video conference link if found, null otherwise + */ +export function extractVideoConferenceFromDescription(description: string): string | null { + const match = description.match(/Visio:\s*(https?:\/\/[^\s]+)/); + return match ? match[1] : null; +}