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
This commit is contained in:
@@ -7,4 +7,5 @@ var SSO_CODE_CHALLENGE_METHOD = "S256";
|
|||||||
var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1";
|
var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1";
|
||||||
var CALENDAR_BASE_URL = "https://calendar.example.com";
|
var CALENDAR_BASE_URL = "https://calendar.example.com";
|
||||||
var MAIL_SPA_URL = "https://mail.example.com";
|
var MAIL_SPA_URL = "https://mail.example.com";
|
||||||
|
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com"
|
||||||
var DEBUG = false;
|
var DEBUG = false;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
@@ -18,6 +19,9 @@ import {
|
|||||||
Description as DescriptionIcon,
|
Description as DescriptionIcon,
|
||||||
Public as PublicIcon,
|
Public as PublicIcon,
|
||||||
Lock as LockIcon,
|
Lock as LockIcon,
|
||||||
|
CameraAlt as VideocamIcon,
|
||||||
|
ContentCopy as CopyIcon,
|
||||||
|
Close as DeleteIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
@@ -30,6 +34,7 @@ import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
|||||||
import { createSelector } from "@reduxjs/toolkit";
|
import { createSelector } from "@reduxjs/toolkit";
|
||||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||||
import { TIMEZONES } from "../../utils/timezone-data";
|
import { TIMEZONES } from "../../utils/timezone-data";
|
||||||
|
import { generateMeetingLink, addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||||
|
|
||||||
// Helper component for field with label
|
// Helper component for field with label
|
||||||
const FieldWithLabel = React.memo(
|
const FieldWithLabel = React.memo(
|
||||||
@@ -194,6 +199,12 @@ function EventPopover({
|
|||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz
|
event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz
|
||||||
);
|
);
|
||||||
|
const [hasVideoConference, setHasVideoConference] = useState(
|
||||||
|
event?.x_openpass_videoconference ? true : false
|
||||||
|
);
|
||||||
|
const [meetingLink, setMeetingLink] = useState<string | null>(
|
||||||
|
event?.x_openpass_videoconference || null
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRange) {
|
if (selectedRange) {
|
||||||
@@ -209,8 +220,50 @@ function EventPopover({
|
|||||||
? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
|
? 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]);
|
}, [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 = () => {
|
const handleClose = () => {
|
||||||
onClose({}, "backdropClick");
|
onClose({}, "backdropClick");
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -224,6 +277,8 @@ function EventPopover({
|
|||||||
setCalendarid(0);
|
setCalendarid(0);
|
||||||
setImportant(false);
|
setImportant(false);
|
||||||
setTimezone(timezoneList.browserTz);
|
setTimezone(timezoneList.browserTz);
|
||||||
|
setHasVideoConference(false);
|
||||||
|
setMeetingLink(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -255,6 +310,7 @@ function EventPopover({
|
|||||||
transp: busy,
|
transp: busy,
|
||||||
color: userPersonnalCalendars[calendarid]?.color,
|
color: userPersonnalCalendars[calendarid]?.color,
|
||||||
alarm: { trigger: alarm, action: "EMAIL" },
|
alarm: { trigger: alarm, action: "EMAIL" },
|
||||||
|
x_openpass_videoconference: meetingLink || undefined,
|
||||||
};
|
};
|
||||||
if (end) {
|
if (end) {
|
||||||
newEvent.end = new Date(end).toISOString();
|
newEvent.end = new Date(end).toISOString();
|
||||||
@@ -278,6 +334,8 @@ function EventPopover({
|
|||||||
setCalendarid(0);
|
setCalendarid(0);
|
||||||
setImportant(false);
|
setImportant(false);
|
||||||
setTimezone(timezoneList.browserTz);
|
setTimezone(timezoneList.browserTz);
|
||||||
|
setHasVideoConference(false);
|
||||||
|
setMeetingLink(null);
|
||||||
|
|
||||||
// Save to API in background
|
// Save to API in background
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -512,6 +570,49 @@ function EventPopover({
|
|||||||
<FieldWithLabel label="Participants" isExpanded={showMore}>
|
<FieldWithLabel label="Participants" isExpanded={showMore}>
|
||||||
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
|
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
|
<FieldWithLabel label="Video meeting" isExpanded={showMore}>
|
||||||
|
<Box display="flex" gap={1} mb={1} alignItems="center">
|
||||||
|
<Button
|
||||||
|
startIcon={<VideocamIcon />}
|
||||||
|
onClick={handleAddVideoConference}
|
||||||
|
size="medium"
|
||||||
|
sx={{
|
||||||
|
textTransform: "none",
|
||||||
|
color: "text.secondary",
|
||||||
|
display: hasVideoConference ? "none" : "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Visio conference
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasVideoConference && meetingLink && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mr: 1 }}>
|
||||||
|
Meeting link generated
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleCopyMeetingLink}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "primary.main" }}
|
||||||
|
aria-label="Copy meeting link"
|
||||||
|
title="Copy meeting link"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleDeleteVideoConference}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "error.main" }}
|
||||||
|
aria-label="Remove video conference"
|
||||||
|
title="Remove video conference"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</FieldWithLabel>
|
||||||
<FieldWithLabel label="Location" isExpanded={showMore}>
|
<FieldWithLabel label="Location" isExpanded={showMore}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { generateMeetingId, generateMeetingLink, addVideoConferenceToDescription, extractVideoConferenceFromDescription } from '../videoConferenceUtils';
|
||||||
|
|
||||||
|
describe('videoConferenceUtils', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user