feat: calendar integration (#608)

* feat: calendar integration

* feat: add ICS calendar API endpoints for room configuration and sync

* feat: add Celery background tasks for ICS sync

* feat: implement Phase 2 - Multiple active meetings per room with grace period

This commit adds support for multiple concurrent meetings per room, implementing
grace period logic and improved meeting lifecycle management for calendar integration.

## Database Changes
- Remove unique constraint preventing multiple active meetings per room
- Add last_participant_left_at field to track when meeting becomes empty
- Add grace_period_minutes field (default: 15) for configurable grace period

## Meeting Controller Enhancements
- Add get_all_active_for_room() to retrieve all active meetings for a room
- Add get_active_by_calendar_event() to find meetings by calendar event ID
- Maintain backward compatibility with existing get_active() method

## New API Endpoints
- GET /rooms/{room_name}/meetings/active - List all active meetings
- POST /rooms/{room_name}/meetings/{meeting_id}/join - Join specific meeting

## Meeting Lifecycle Improvements
- 15-minute grace period after last participant leaves
- Automatic reactivation when participant rejoins during grace period
- Force close calendar meetings 30 minutes after scheduled end time
- Update process_meetings task to handle multiple active meetings

## Whereby Integration
- Clear grace period when participants join via webhook events
- Track participant count for grace period management

## Testing
- Add comprehensive tests for multiple active meetings
- Test grace period behavior and participant rejoin scenarios
- Test calendar meeting force closure logic
- All 5 new tests passing

This enables proper calendar integration with overlapping meetings while
preventing accidental meeting closures through the grace period mechanism.

* feat: implement frontend for calendar integration (Phase 3 & 4)

- Created MeetingSelection component for choosing between multiple active meetings
- Shows both active meetings and upcoming calendar events (30 min ahead)
- Displays meeting metadata with privacy controls (owner-only details)
- Supports creation of unscheduled meetings alongside calendar meetings

- Added waiting page for users joining before scheduled start time
- Shows countdown timer until meeting begins
- Auto-transitions to meeting when calendar event becomes active
- Handles early joining with proper routing

- Created collapsible info panel showing meeting details
- Displays calendar metadata (title, description, attendees)
- Shows participant count and duration
- Privacy-aware: sensitive info only visible to room owners

- Integrated ICS settings into room configuration dialog
- Test connection functionality with immediate feedback
- Manual sync trigger with detailed results
- Shows last sync time and ETag for monitoring
- Configurable sync intervals (1 min to 1 hour)

- New /room/{roomName} route for meeting selection
- Waiting room at /room/{roomName}/wait?eventId={id}
- Classic room page at /{roomName} with meeting info
- Uses sessionStorage to pass selected meeting between pages

- Added new endpoints for active/upcoming meetings
- Regenerated TypeScript client with latest OpenAPI spec
- Proper error handling and loading states
- Auto-refresh every 30 seconds for live updates

- Color-coded badges for meeting status
- Attendee status indicators (accepted/declined/tentative)
- Responsive design with Chakra UI components
- Clear visual hierarchy between active and upcoming meetings
- Smart truncation for long attendee lists

This completes the frontend implementation for calendar integration,
enabling users to seamlessly join scheduled meetings from their
calendar applications.

* WIP: Migrate calendar integration frontend to React Query

- Migrate all calendar components from useApi to React Query hooks
- Fix Chakra UI v3 compatibility issues (Card, Progress, spacing props, leftIcon)
- Update backend Meeting model to include calendar fields
- Replace imperative API calls with declarative React Query patterns
- Remove old OpenAPI generated files that conflict with new structure

* fix: alembic migrations

* feat: add calendar migration

* feat: update ics, first version working

* feat: implement tabbed interface for room edit dialog

- Add General, Calendar, and Share tabs to organize room settings
- Move ICS settings to dedicated Calendar tab
- Move Zulip configuration to Share tab
- Keep basic room settings and webhooks in General tab
- Remove redundant migration file
- Fix Chakra UI v3 compatibility issues in calendar components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: infinite loop

* feat: improve ICS calendar sync UX and fix room URL matching

- Replace "Test Connection" button with "Force Sync" button (Edit Room only)
- Show detailed sync results: total events downloaded vs room matches
- Remove emoticons and auto-hide timeout for cleaner UX
- Fix room URL matching to use UI_BASE_URL instead of BASE_URL
- Replace FaSync icon with LuRefreshCw for consistency
- Clear sync results when dialog closes or Force Sync pressed
- Update tests to reflect UI_BASE_URL change and exact URL matching

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: reorganize room edit dialog and fix Force Sync button

- Move WebHook configuration from General to dedicated WebHook tab
- Add WebHook tab after Share tab in room edit dialog
- Fix Force Sync button not appearing by adding missing isEditing prop
- Fix indentation issues in MeetingSelection component

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: complete calendar integration with UI improvements and code cleanup

Calendar Integration Tasks:
- Update upcoming meetings window from 30 to 120 minutes
- Include currently happening events in upcoming meetings API
- Create shared time utility functions (formatDateTime, formatCountdown, formatStartedAgo)
- Improve ongoing meetings UI logic with proper time detection
- Fix backend code organization and remove excessive documentation

UI/UX Improvements:
- Restructure room page layout using MinimalHeader pattern
- Remove borders from header and footer elements
- Change button text from "Leave Meeting" to "Leave Room"
- Remove "Back to Reflector" footer for cleaner design
- Extract WaitPageClient component for better separation

Backend Changes:
- calendar_events.py: Fix import organization and extend timing window
- rooms.py: Update API default from 30 to 120 minutes
- Enhanced test coverage for ongoing meeting scenarios

Frontend Changes:
- MinimalHeader: Add onLeave prop for custom navigation
- MeetingSelection: Complete layout restructure with shared utilities
- timeUtils: New shared utility file for consistent time formatting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: remove wait page and simplify Join button with 5-minute disable logic

- Remove entire wait page directory and associated files
- Update handleJoinUpcoming to create unscheduled meeting directly
- Simplify Join button to single state:
  - Always shows "Join" text
  - Blue when meeting can be joined (ongoing or within 5 minutes)
  - Gray/disabled when more than 5 minutes away
- Remove confusing "Join Now", "Join Early" text variations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: improve calendar integration and meeting UI

- Refactor ICS sync tasks to use @asynctask decorator for cleaner async handling
- Extract meeting creation logic into reusable function
- Improve meeting selection UI with distinct current/upcoming sections
- Add early join functionality for upcoming meetings within 5-minute window
- Simplify non-ICS room workflow with direct Whereby embed
- Fix import paths and component organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: restore original recording consent functionality

- Remove custom ConsentDialogButton and WherebyEmbed components
- Merge RoomClient logic back into main room page
- Restore original consent UI: blue button with toast modal
- Maintain calendar integration features for ICS-enabled rooms
- Add consent-handler.md documentation of original functionality
- Preserve focus management and accessibility features

* fix: redirect Join Now button to local meeting page

- Change handleJoinDirect to use onMeetingSelect instead of opening external URL
- Join Now button now navigates to /{roomName}/{meetingId} instead of whereby.com
- Maintains proper routing within the application

* feat: remove restrictive message for non-owners in private rooms

- Remove confusing message about room owner permissions
- Cleaner UI for all users regardless of ownership status
- Users will only see available meetings and join options

* feat: improve meeting selection UI for better readability

- Limit page content to max 800px width for better 4K display readability
- Remove LIVE tag badge for cleaner interface
- Remove shadow from main live meeting box
- Remove blue border and hover effects for minimal design
- Change background to neutral gray for less visual noise

* feat: add room by name endpoint for non-authenticated access

- Add GET /rooms/name/{room_name} backend endpoint
- Endpoint supports non-authenticated access for public rooms
- Returns RoomDetails with webhook fields hidden for non-owners
- Update useRoomGetByName hook to use new direct endpoint
- Remove authentication requirement from frontend hook
- Regenerate API client types

Fixes: Non-authenticated users can now access room lobbies

* feat: add friendly message when no meetings are ongoing

- Show centered message with calendar icon when no meetings are active
- Message text: 'No meetings right now' with helpful description
- Contextual text for owners/shared rooms mentioning quick meeting option
- Consistent gray styling matching the rest of the interface
- Only displays when both currentMeetings and upcomingMeetings are empty

* style: center no meetings message and remove background

- Change from Box to Flex with flex=1 for vertical centering
- Remove gray background, border radius, and padding
- Message now appears cleanly centered in available space
- Maintains horizontal and vertical centering

* feat: move Create Meeting button to header

- Remove 'Start a Quick Meeting' box from main content area
- Add showCreateButton and onCreateMeeting props to MinimalHeader
- Create Meeting button now appears in header left of Leave Room
- Only shows for room owners or shared room users
- Update no meetings message to remove reference to quick meeting below
- Cleaner, more accessible UI with actions in the header

* style: change room title and no meetings text to pure black

- Update room title in MinimalHeader from gray.700 to black
- Update 'No meetings right now' text from gray.700 to black
- Improves visual hierarchy and readability
- Consistent with other pages' styling

* style: linting

* fix: remove plan files

* fix: alembic migration with named foreign keys

* feat: add SyncStatus enum and refactor ICS sync to use rooms controller

- Add SyncStatus enum to replace string literals in ICS sync status
- Replace direct SQL queries in worker with rooms_controller.get_ics_enabled()
- Improve type safety and maintainability of ICS sync code
- Enum values: SUCCESS, UNCHANGED, ERROR, SKIPPED maintain backward compatibility

* refactor: remove unnecessary docstring from get_ics_enabled method

The function name is self-explanatory

* fix: import top level

* feat: use Literal type for ICSStatus.status field

- Changed ICSStatus.status from str to Literal['enabled', 'disabled']
- Improves type safety and API documentation

* feat: update TypeScript definitions for ICSStatus Literal type

- OpenAPI generation now properly reflects Literal['enabled', 'disabled'] type
- Improves type safety for frontend consumers of the API
- Applied automatic formatting via pre-commit hooks

* refactor: replace loguru with structlog in ics_sync service

- Replace loguru import with structlog in services/ics_sync.py
- Update logging calls to use structlog's structured format with keyword args
- Maintains consistency with other services using structlog
- Changes: logger.info(f'...') -> logger.info('...', key=value) format

* chore: remove loguru dependency and improve type annotations

- Remove loguru from dependencies in pyproject.toml (replaced with structlog)
- Update meeting controller methods to properly return Optional types
- Update dependency lock file after loguru removal

* fix: resolve pyflakes warnings in ics_sync and meetings modules

Remove unused imports and variables to clean up code quality

* Remove grace period logic and improve meeting deactivation

- Removed grace_period_minutes and last_participant_left_at fields
- Simplified deactivation logic based on actual usage patterns:
  * Active sessions: Keep meeting active regardless of scheduled time
  * Calendar meetings: Wait until scheduled end if unused, deactivate immediately once used and empty
  * On-the-fly meetings: Deactivate immediately when empty
- Created migration to drop unused database columns
- Updated tests to remove grace period test cases

* Update test to match new deactivation logic for calendar meetings

* fix: remove unwanted file

* fix: incompleted changes from EVENT_WINDOW*

* fix: update room ICS API tests to include required webhook fields and correct URL

- Add webhook_url and webhook_secret fields to room creation tests
- Fix room URL matching in ICS sync test to use UI_BASE_URL instead of BASE_URL
- Aligns test with actual API requirements and ICS sync service implementation

* fix: add Redis distributed locking to prevent race conditions in process_meetings

- Implement per-meeting locks using Redis to prevent concurrent processing
- Add lock extension after slow API calls (Whereby) to handle long-running operations
- Use redis-py's built-in lock.extend() with replace_ttl=True for simple TTL refresh
- Track and log skipped meetings when locked by other workers
- Document SSRF analysis showing it's low-risk due to async worker isolation

This prevents multiple workers from processing the same meeting simultaneously,
which could cause state corruption or duplicate deactivations.

* refactor: rename MinimalHeader to MeetingMinimalHeader for clarity

* fix: minor code quality improvements - add emoji constants, fix type safety, cleanup comments

* fix: database migration

* self-pr review

* self-pr review

* self-pr review treeshake

* fix: local fixes

* fix: creation of meeting

* fix: meeting selection create button

* compile fix

* fix: meeting selection responsive

* fix: rework process logic for meeting

* fix: meeting useEffect frontend-only dedupe (#647)

* meeting useEffect frontend-only dedupe

* format

* also get room by name backend fix

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>

* invalidate meeting list on new meeting

* test fix

* room url copy button for ics

* calendar refresh quick action icon

* remove work.md

* meeting page frontend fixes

* hide number of meeting participants

* Revert "hide number of meeting participants"

This reverts commit 38906c5d1a.

* ui bits

* ui bits

* remove log

* room name typing stricten

* feat: protect atomic operation involving external service with redlock

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Igor Monadical <igor@monadical.com>
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
2025-09-17 16:43:20 -06:00
committed by GitHub
parent ab859d65a6
commit 6f680b5795
53 changed files with 6876 additions and 960 deletions

View File

@@ -0,0 +1,343 @@
import {
VStack,
HStack,
Field,
Input,
Select,
Checkbox,
Button,
Text,
Badge,
createListCollection,
Spinner,
Box,
IconButton,
} from "@chakra-ui/react";
import { useState, useEffect, useRef } from "react";
import { LuRefreshCw, LuCopy, LuCheck } from "react-icons/lu";
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks";
import { toaster } from "../../../components/ui/toaster";
import { roomAbsoluteUrl } from "../../../lib/routesClient";
import {
assertExists,
assertExistsAndNonEmptyString,
NonEmptyString,
parseNonEmptyString,
} from "../../../lib/utils";
interface ICSSettingsProps {
roomName: NonEmptyString;
icsUrl?: string;
icsEnabled?: boolean;
icsFetchInterval?: number;
icsLastSync?: string;
icsLastEtag?: string;
onChange: (settings: Partial<ICSSettingsData>) => void;
isOwner?: boolean;
isEditing?: boolean;
}
export interface ICSSettingsData {
ics_url: string;
ics_enabled: boolean;
ics_fetch_interval: number;
}
const fetchIntervalOptions = [
{ label: "1 minute", value: "1" },
{ label: "5 minutes", value: "5" },
{ label: "10 minutes", value: "10" },
{ label: "30 minutes", value: "30" },
{ label: "1 hour", value: "60" },
];
export default function ICSSettings({
roomName,
icsUrl = "",
icsEnabled = false,
icsFetchInterval = 5,
icsLastSync,
icsLastEtag,
onChange,
isOwner = true,
isEditing = false,
}: ICSSettingsProps) {
const [syncStatus, setSyncStatus] = useState<
"idle" | "syncing" | "success" | "error"
>("idle");
const [syncMessage, setSyncMessage] = useState<string>("");
const [syncResult, setSyncResult] = useState<{
eventsFound: number;
totalEvents: number;
eventsCreated: number;
eventsUpdated: number;
} | null>(null);
const [justCopied, setJustCopied] = useState(false);
const roomUrlInputRef = useRef<HTMLInputElement>(null);
const syncMutation = useRoomIcsSync();
const fetchIntervalCollection = createListCollection({
items: fetchIntervalOptions,
});
const handleCopyRoomUrl = async () => {
try {
await navigator.clipboard.writeText(
roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)),
);
setJustCopied(true);
toaster
.create({
placement: "top",
duration: 3000,
render: ({ dismiss }) => (
<Box
bg="green.500"
color="white"
px={4}
py={3}
borderRadius="md"
display="flex"
alignItems="center"
gap={2}
boxShadow="lg"
>
<LuCheck />
<Text>Room URL copied to clipboard!</Text>
</Box>
),
})
.then(() => {});
setTimeout(() => {
setJustCopied(false);
}, 2000);
} catch (err) {
console.error("Failed to copy room url:", err);
}
};
const handleRoomUrlClick = () => {
if (roomUrlInputRef.current) {
roomUrlInputRef.current.select();
handleCopyRoomUrl();
}
};
// Clear sync results when dialog closes
useEffect(() => {
if (!isEditing) {
setSyncStatus("idle");
setSyncResult(null);
setSyncMessage("");
}
}, [isEditing]);
const handleForceSync = async () => {
if (!roomName || !isEditing) return;
// Clear previous results
setSyncStatus("syncing");
setSyncResult(null);
setSyncMessage("");
try {
const result = await syncMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
if (result.status === "success" || result.status === "unchanged") {
setSyncStatus("success");
setSyncResult({
eventsFound: result.events_found || 0,
totalEvents: result.total_events || 0,
eventsCreated: result.events_created || 0,
eventsUpdated: result.events_updated || 0,
});
} else {
setSyncStatus("error");
setSyncMessage(result.error || "Sync failed");
}
} catch (err: any) {
setSyncStatus("error");
setSyncMessage(err.body?.detail || "Failed to force sync calendar");
}
};
if (!isOwner) {
return null; // ICS settings only visible to room owner
}
return (
<VStack gap={4} align="stretch">
<Field.Root>
<Checkbox.Root
checked={icsEnabled}
onCheckedChange={(e) => onChange({ ics_enabled: !!e.checked })}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{icsEnabled && (
<>
<Field.Root>
<Field.Label>Room URL</Field.Label>
<Field.HelperText>
To enable Reflector to recognize your calendar events as meetings,
add this URL as the location in your calendar events
</Field.HelperText>
<HStack gap={0} position="relative" width="100%">
<Input
ref={roomUrlInputRef}
value={roomAbsoluteUrl(parseNonEmptyString(roomName))}
readOnly
onClick={handleRoomUrlClick}
cursor="pointer"
bg="gray.100"
_hover={{ bg: "gray.200" }}
_focus={{ bg: "gray.200" }}
pr="90px"
width="100%"
/>
<HStack position="absolute" right="4px" gap={1} zIndex={1}>
<IconButton
aria-label="Copy room URL"
onClick={handleCopyRoomUrl}
variant="ghost"
size="sm"
>
{justCopied ? <LuCheck /> : <LuCopy />}
</IconButton>
</HStack>
</HStack>
</Field.Root>
<Field.Root>
<Field.Label>ICS Calendar URL</Field.Label>
<Input
placeholder="https://calendar.google.com/calendar/ical/..."
value={icsUrl}
onChange={(e) => onChange({ ics_url: e.target.value })}
/>
<Field.HelperText>
Enter the ICS URL from Google Calendar, Outlook, or other calendar
services
</Field.HelperText>
</Field.Root>
<Field.Root>
<Field.Label>Sync Interval</Field.Label>
<Select.Root
collection={fetchIntervalCollection}
value={[icsFetchInterval.toString()]}
onValueChange={(details) => {
const value = parseInt(details.value[0]);
onChange({ ics_fetch_interval: value });
}}
>
<Select.Trigger>
<Select.ValueText />
</Select.Trigger>
<Select.Content>
{fetchIntervalOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Field.HelperText>
How often to check for calendar updates
</Field.HelperText>
</Field.Root>
{icsUrl && isEditing && roomName && (
<HStack gap={3}>
<Button
size="sm"
variant="outline"
onClick={handleForceSync}
disabled={syncStatus === "syncing"}
>
{syncStatus === "syncing" ? (
<Spinner size="sm" />
) : (
<LuRefreshCw />
)}
Force Sync
</Button>
</HStack>
)}
{syncResult && syncStatus === "success" && (
<Box
p={3}
borderRadius="md"
bg="green.50"
borderLeft="4px solid"
borderColor="green.400"
>
<VStack gap={1} align="stretch">
<Text fontSize="sm" color="green.800" fontWeight="medium">
Sync completed
</Text>
<Text fontSize="sm" color="green.700">
{syncResult.totalEvents} events downloaded,{" "}
{syncResult.eventsFound} match this room
</Text>
{(syncResult.eventsCreated > 0 ||
syncResult.eventsUpdated > 0) && (
<Text fontSize="sm" color="green.700">
{syncResult.eventsCreated} created,{" "}
{syncResult.eventsUpdated} updated
</Text>
)}
</VStack>
</Box>
)}
{syncMessage && (
<Box
p={3}
borderRadius="md"
bg={syncStatus === "success" ? "green.50" : "red.50"}
borderLeft="4px solid"
borderColor={syncStatus === "success" ? "green.400" : "red.400"}
>
<Text
fontSize="sm"
color={syncStatus === "success" ? "green.800" : "red.800"}
>
{syncMessage}
</Text>
</Box>
)}
{icsLastSync && (
<HStack gap={4} fontSize="sm" color="gray.600">
<HStack>
<FaCheckCircle color="green" />
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
</HStack>
{icsLastEtag && (
<Badge colorScheme="blue" fontSize="xs">
ETag: {icsLastEtag.slice(0, 8)}...
</Badge>
)}
</HStack>
)}
</>
)}
</VStack>
);
}

View File

@@ -4,12 +4,13 @@ import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
import { NonEmptyString } from "../../../lib/utils";
interface RoomListProps {
title: string;
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onCopyUrl: (roomName: NonEmptyString) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
emptyMessage?: string;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import {
Box,
Table,
@@ -7,17 +7,58 @@ import {
IconButton,
Text,
Spinner,
Badge,
VStack,
Icon,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
import { LuLink, LuRefreshCw } from "react-icons/lu";
import { FaCalendarAlt } from "react-icons/fa";
import type { components } from "../../../reflector-api";
import {
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomIcsSync,
} from "../../../lib/apiHooks";
type Room = components["schemas"]["Room"];
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
import { RoomActionsMenu } from "./RoomActionsMenu";
import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants";
import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils";
// Custom icon component that combines calendar and refresh icons
const CalendarSyncIcon = () => (
<Box position="relative" display="inline-block" w="20px" h="20px">
<Icon
as={FaCalendarAlt}
position="absolute"
top={0}
left={0}
boxSize="20px"
/>
<Box
position="absolute"
bottom="-2px"
right="-2px"
bg="white"
borderRadius="full"
p="1px"
display="flex"
alignItems="center"
justifyContent="center"
w="12px"
h="12px"
>
<Icon as={LuRefreshCw} boxSize="10px" color="gray.700" />
</Box>
</Box>
);
interface RoomTableProps {
rooms: Room[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onCopyUrl: (roomName: NonEmptyString) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
loading?: boolean;
@@ -63,6 +104,71 @@ const getZulipDisplay = (
return "Enabled";
};
function MeetingStatus({ roomName }: { roomName: string }) {
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
const activeMeetings = activeMeetingsQuery.data || [];
const upcomingMeetings = upcomingMeetingsQuery.data || [];
if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
return <Spinner size="sm" />;
}
if (activeMeetings.length > 0) {
const meeting = activeMeetings[0];
const title = String(
meeting.calendar_metadata?.["title"] || "Active Meeting",
);
return (
<VStack gap={1} alignItems="start">
<Text fontSize="xs" color="gray.600" lineHeight={1}>
{title}
</Text>
<Text fontSize="xs" color="gray.500" lineHeight={1}>
{meeting.num_clients} participants
</Text>
</VStack>
);
}
if (upcomingMeetings.length > 0) {
const event = upcomingMeetings[0];
const startTime = new Date(event.start_time);
const now = new Date();
const diffMinutes = Math.floor(
(startTime.getTime() - now.getTime()) / 60000,
);
return (
<VStack gap={1} alignItems="start">
<Badge colorScheme="orange" size="sm">
{diffMinutes < MEETING_DEFAULT_TIME_MINUTES
? `In ${diffMinutes}m`
: "Upcoming"}
</Badge>
<Text fontSize="xs" color="gray.600" lineHeight={1}>
{event.title || "Scheduled Meeting"}
</Text>
<Text fontSize="xs" color="gray.500" lineHeight={1}>
{startTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
month: "short",
day: "numeric",
})}
</Text>
</VStack>
);
}
return (
<Text fontSize="xs" color="gray.500">
No meetings
</Text>
);
}
export function RoomTable({
rooms,
linkCopied,
@@ -71,6 +177,30 @@ export function RoomTable({
onDelete,
loading,
}: RoomTableProps) {
const [syncingRooms, setSyncingRooms] = useState<Set<NonEmptyString>>(
new Set(),
);
const syncMutation = useRoomIcsSync();
const handleForceSync = async (roomName: NonEmptyString) => {
setSyncingRooms((prev) => new Set(prev).add(roomName));
try {
await syncMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
});
} catch (err) {
console.error("Failed to sync calendar:", err);
} finally {
setSyncingRooms((prev) => {
const next = new Set(prev);
next.delete(roomName);
return next;
});
}
};
return (
<Box display={{ base: "none", lg: "block" }} position="relative">
{loading && (
@@ -97,13 +227,16 @@ export function RoomTable({
<Table.ColumnHeader width="250px" fontWeight="600">
Room Name
</Table.ColumnHeader>
<Table.ColumnHeader width="250px" fontWeight="600">
Zulip
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Room Size
<Table.ColumnHeader width="200px" fontWeight="600">
Current Meeting
</Table.ColumnHeader>
<Table.ColumnHeader width="200px" fontWeight="600">
Zulip
</Table.ColumnHeader>
<Table.ColumnHeader width="120px" fontWeight="600">
Room Size
</Table.ColumnHeader>
<Table.ColumnHeader width="150px" fontWeight="600">
Recording
</Table.ColumnHeader>
<Table.ColumnHeader
@@ -118,6 +251,9 @@ export function RoomTable({
<Table.Cell>
<Link href={`/${room.name}`}>{room.name}</Link>
</Table.Cell>
<Table.Cell>
<MeetingStatus roomName={room.name} />
</Table.Cell>
<Table.Cell>
{getZulipDisplay(
room.zulip_auto_post,
@@ -133,7 +269,26 @@ export function RoomTable({
)}
</Table.Cell>
<Table.Cell>
<Flex alignItems="center" gap={2}>
<Flex alignItems="center" gap={2} justifyContent="flex-end">
{room.ics_enabled && (
<IconButton
aria-label="Force sync calendar"
onClick={() =>
handleForceSync(parseNonEmptyString(room.name))
}
size="sm"
variant="ghost"
disabled={syncingRooms.has(
parseNonEmptyString(room.name),
)}
>
{syncingRooms.has(parseNonEmptyString(room.name)) ? (
<Spinner size="sm" />
) : (
<CalendarSyncIcon />
)}
</IconButton>
)}
{linkCopied === room.name ? (
<Text color="green.500" fontSize="sm">
Copied!
@@ -141,7 +296,9 @@ export function RoomTable({
) : (
<IconButton
aria-label="Copy URL"
onClick={() => onCopyUrl(room.name)}
onClick={() =>
onCopyUrl(parseNonEmptyString(room.name))
}
size="sm"
variant="ghost"
>

View File

@@ -14,6 +14,7 @@ import {
IconButton,
createListCollection,
useDisclosure,
Tabs,
} from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -30,7 +31,13 @@ import {
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
import { assertExists } from "../../lib/utils";
import {
assertExists,
NonEmptyString,
parseNonEmptyString,
} from "../../lib/utils";
import ICSSettings from "./_components/ICSSettings";
import { roomAbsoluteUrl } from "../../lib/routesClient";
type Room = components["schemas"]["Room"];
@@ -40,6 +47,8 @@ interface SelectOption {
}
const RESERVED_PATHS = ["browse", "rooms", "transcripts"];
const SUCCESS_EMOJI = "✅";
const ERROR_EMOJI = "❌";
const roomModeOptions: SelectOption[] = [
{ label: "2-4 people", value: "normal" },
@@ -70,6 +79,9 @@ const roomInitialState = {
isShared: false,
webhookUrl: "",
webhookSecret: "",
icsUrl: "",
icsEnabled: false,
icsFetchInterval: 5,
};
export default function RoomsList() {
@@ -137,6 +149,9 @@ export default function RoomsList() {
isShared: detailedEditedRoom.is_shared,
webhookUrl: detailedEditedRoom.webhook_url || "",
webhookSecret: detailedEditedRoom.webhook_secret || "",
icsUrl: detailedEditedRoom.ics_url || "",
icsEnabled: detailedEditedRoom.ics_enabled || false,
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
}
: null,
[detailedEditedRoom],
@@ -176,14 +191,13 @@ export default function RoomsList() {
items: topicOptions,
});
const handleCopyUrl = (roomName: string) => {
const roomUrl = `${window.location.origin}/${roomName}`;
navigator.clipboard.writeText(roomUrl);
setLinkCopied(roomName);
setTimeout(() => {
setLinkCopied("");
}, 2000);
const handleCopyUrl = (roomName: NonEmptyString) => {
navigator.clipboard.writeText(roomAbsoluteUrl(roomName)).then(() => {
setLinkCopied(roomName);
setTimeout(() => {
setLinkCopied("");
}, 2000);
});
};
const handleCloseDialog = () => {
@@ -217,10 +231,10 @@ export default function RoomsList() {
if (response.success) {
setWebhookTestResult(
` Webhook test successful! Status: ${response.status_code}`,
`${SUCCESS_EMOJI} Webhook test successful! Status: ${response.status_code}`,
);
} else {
let errorMsg = ` Webhook test failed`;
let errorMsg = `${ERROR_EMOJI} Webhook test failed`;
errorMsg += ` (Status: ${response.status_code})`;
if (response.error) {
errorMsg += `: ${response.error}`;
@@ -275,6 +289,9 @@ export default function RoomsList() {
is_shared: room.isShared,
webhook_url: room.webhookUrl,
webhook_secret: room.webhookSecret,
ics_url: room.icsUrl,
ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval,
};
if (isEditing) {
@@ -316,6 +333,22 @@ export default function RoomsList() {
setShowWebhookSecret(false);
setWebhookTestResult(null);
setRoomInput({
name: roomData.name,
zulipAutoPost: roomData.zulip_auto_post,
zulipStream: roomData.zulip_stream,
zulipTopic: roomData.zulip_topic,
isLocked: roomData.is_locked,
roomMode: roomData.room_mode,
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
webhookUrl: roomData.webhook_url || "",
webhookSecret: roomData.webhook_secret || "",
icsUrl: roomData.ics_url || "",
icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5,
});
setEditRoomId(roomId);
setIsEditing(true);
setNameError("");
@@ -416,353 +449,407 @@ export default function RoomsList() {
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && <Field.ErrorText>{nameError}</Field.ErrorText>}
</Field.Root>
<Tabs.Root defaultValue="general">
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger>
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud" ? "none" : room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={8}>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
{/* Webhook Configuration Section */}
<Field.Root mt={8}>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
type="url"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts are
ready
</Field.HelperText>
</Field.Root>
{room.webhookUrl && (
<>
<Field.Root mt={4}>
<Field.Label>Webhook Secret</Field.Label>
<Flex gap={2}>
<Input
name="webhookSecret"
type={showWebhookSecret ? "text" : "password"}
value={room.webhookSecret}
onChange={handleRoomChange}
placeholder={
isEditing && room.webhookSecret
? "••••••••"
: "Leave empty to auto-generate"
}
flex="1"
/>
{isEditing && room.webhookSecret && (
<IconButton
size="sm"
variant="ghost"
aria-label={
showWebhookSecret ? "Hide secret" : "Show secret"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)}
</Flex>
<Tabs.Content value="general" pt={6}>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<Field.HelperText>
Used for HMAC signature verification (auto-generated if
left empty)
No spaces or special characters allowed
</Field.HelperText>
{nameError && (
<Field.ErrorText>{nameError}</Field.ErrorText>
)}
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud"
? "none"
: room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
</Tabs.Content>
<Tabs.Content value="calendar" pt={6}>
<ICSSettings
roomName={parseNonEmptyString(room.name)}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoomInput({
...room,
icsUrl:
settings.ics_url !== undefined
? settings.ics_url
: room.icsUrl,
icsEnabled:
settings.ics_enabled !== undefined
? settings.ics_enabled
: room.icsEnabled,
icsFetchInterval:
settings.ics_fetch_interval !== undefined
? settings.ics_fetch_interval
: room.icsFetchInterval,
});
}}
isOwner={true}
isEditing={isEditing}
/>
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
<Tabs.Content value="webhook" pt={6}>
<Field.Root>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
type="url"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts
are ready
</Field.HelperText>
</Field.Root>
{isEditing && (
{room.webhookUrl && (
<>
<Flex
mt={2}
gap={2}
alignItems="flex-start"
direction="column"
>
<Button
size="sm"
variant="outline"
onClick={handleTestWebhook}
disabled={testingWebhook || !room.webhookUrl}
>
{testingWebhook ? (
<>
<Spinner size="xs" mr={2} />
Testing...
</>
) : (
"Test Webhook"
<Field.Root mt={4}>
<Field.Label>Webhook Secret</Field.Label>
<Flex gap={2}>
<Input
name="webhookSecret"
type={showWebhookSecret ? "text" : "password"}
value={room.webhookSecret}
onChange={handleRoomChange}
placeholder={
isEditing && room.webhookSecret
? "••••••••"
: "Leave empty to auto-generate"
}
flex="1"
/>
{isEditing && room.webhookSecret && (
<IconButton
size="sm"
variant="ghost"
aria-label={
showWebhookSecret
? "Hide secret"
: "Show secret"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)}
</Button>
{webhookTestResult && (
<div
style={{
fontSize: "14px",
wordBreak: "break-word",
maxWidth: "100%",
padding: "8px",
borderRadius: "4px",
backgroundColor: webhookTestResult.startsWith(
"✅",
)
? "#f0fdf4"
: "#fef2f2",
border: `1px solid ${webhookTestResult.startsWith("✅") ? "#86efac" : "#fca5a5"}`,
}}
</Flex>
<Field.HelperText>
Used for HMAC signature verification (auto-generated
if left empty)
</Field.HelperText>
</Field.Root>
{isEditing && (
<>
<Flex
mt={2}
gap={2}
alignItems="flex-start"
direction="column"
>
{webhookTestResult}
</div>
)}
</Flex>
<Button
size="sm"
variant="outline"
onClick={handleTestWebhook}
disabled={testingWebhook || !room.webhookUrl}
>
{testingWebhook ? (
<>
<Spinner size="xs" mr={2} />
Testing...
</>
) : (
"Test Webhook"
)}
</Button>
{webhookTestResult && (
<div
style={{
fontSize: "14px",
wordBreak: "break-word",
maxWidth: "100%",
padding: "8px",
borderRadius: "4px",
backgroundColor: webhookTestResult.startsWith(
SUCCESS_EMOJI,
)
? "#f0fdf4"
: "#fef2f2",
border: `1px solid ${webhookTestResult.startsWith(SUCCESS_EMOJI) ? "#86efac" : "#fca5a5"}`,
}}
>
{webhookTestResult}
</div>
)}
</Flex>
</>
)}
</>
)}
</>
)}
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
</Tabs.Content>
</Tabs.Root>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={handleCloseDialog}>

View File

@@ -0,0 +1,569 @@
"use client";
import { partition } from "remeda";
import {
Box,
VStack,
HStack,
Text,
Button,
Spinner,
Badge,
Icon,
Flex,
} from "@chakra-ui/react";
import React from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { LuX } from "react-icons/lu";
import type { components } from "../reflector-api";
import {
useRoomActiveMeetings,
useRoomJoinMeeting,
useMeetingDeactivate,
useRoomGetByName,
} from "../lib/apiHooks";
import { useRouter } from "next/navigation";
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
import { NonEmptyString } from "../lib/utils";
type Meeting = components["schemas"]["Meeting"];
interface MeetingSelectionProps {
roomName: NonEmptyString;
isOwner: boolean;
isSharedRoom: boolean;
authLoading: boolean;
onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void;
isCreatingMeeting?: boolean;
}
export default function MeetingSelection({
roomName,
isOwner,
isSharedRoom,
onMeetingSelect,
onCreateUnscheduled,
isCreatingMeeting = false,
}: MeetingSelectionProps) {
const router = useRouter();
const roomQuery = useRoomGetByName(roomName);
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
const joinMeetingMutation = useRoomJoinMeeting();
const deactivateMeetingMutation = useMeetingDeactivate();
const room = roomQuery.data;
const allMeetings = activeMeetingsQuery.data || [];
const now = new Date();
const [currentMeetings, nonCurrentMeetings] = partition(
allMeetings,
(meeting) => {
const startTime = new Date(meeting.start_date);
const endTime = new Date(meeting.end_date);
// Meeting is ongoing if current time is between start and end
return now >= startTime && now <= endTime;
},
);
const upcomingMeetings = nonCurrentMeetings.filter((meeting) => {
const startTime = new Date(meeting.start_date);
// Meeting is upcoming if it hasn't started yet
return now < startTime;
});
const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading;
const error = roomQuery.error || activeMeetingsQuery.error;
const handleJoinUpcoming = async (meeting: Meeting) => {
// Join the upcoming meeting and navigate to local meeting page
try {
const joinedMeeting = await joinMeetingMutation.mutateAsync({
params: {
path: {
room_name: roomName,
meeting_id: meeting.id,
},
},
});
onMeetingSelect(joinedMeeting);
} catch (err) {
console.error("Failed to join upcoming meeting:", err);
}
};
const handleJoinDirect = (meeting: Meeting) => {
// Navigate to local meeting page instead of external URL
onMeetingSelect(meeting);
};
const handleEndMeeting = async (meetingId: string) => {
try {
await deactivateMeetingMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
});
} catch (err) {
console.error("Failed to end meeting:", err);
}
};
if (loading) {
return (
<Box p={8} textAlign="center">
<Spinner size="lg" color="blue.500" />
<Text mt={4}>Loading meetings...</Text>
</Box>
);
}
if (error) {
return (
<Box
p={4}
borderRadius="md"
bg="red.50"
borderLeft="4px solid"
borderColor="red.400"
>
<Text fontWeight="semibold" color="red.800">
Error
</Text>
<Text color="red.700">{"Failed to load meetings"}</Text>
</Box>
);
}
const handleLeaveMeeting = () => {
router.push("/");
};
return (
<Flex flexDir="column" minH="100vh" position="relative">
{/* Loading overlay */}
{isCreatingMeeting && (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
zIndex={9999}
display="flex"
alignItems="center"
justifyContent="center"
>
<VStack gap={4} p={8} bg="white" borderRadius="lg" boxShadow="xl">
<Spinner size="lg" color="blue.500" />
<Text fontSize="lg" fontWeight="medium">
Creating meeting...
</Text>
</VStack>
</Box>
)}
<MeetingMinimalHeader
roomName={roomName}
displayName={room?.name}
showLeaveButton={true}
onLeave={handleLeaveMeeting}
showCreateButton={isOwner || isSharedRoom}
onCreateMeeting={onCreateUnscheduled}
isCreatingMeeting={isCreatingMeeting}
/>
<Flex
flexDir="column"
w="full"
maxW="800px"
mx="auto"
px={{ base: 4, md: 6 }}
py={{ base: 4, md: 8 }}
flex="1"
gap={{ base: 4, md: 6 }}
>
{/* Current Ongoing Meetings - BIG DISPLAY */}
{currentMeetings.length > 0 ? (
<VStack align="stretch" gap={6} mb={8}>
{currentMeetings.map((meeting) => (
<Box
key={meeting.id}
width="100%"
bg="gray.50"
borderRadius="xl"
p={{ base: 4, md: 8 }}
>
<Flex
direction={{ base: "column", md: "row" }}
justify="space-between"
align={{ base: "stretch", md: "start" }}
gap={{ base: 4, md: 6 }}
>
<VStack align="start" gap={{ base: 3, md: 4 }} flex={1}>
<HStack>
<Icon
as={FaCalendarAlt}
color="blue.600"
boxSize="24px"
/>
<Text
fontSize={{ base: "xl", md: "2xl" }}
fontWeight="bold"
color="blue.800"
>
{(meeting.calendar_metadata as any)?.title ||
"Live Meeting"}
</Text>
</HStack>
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text
fontSize={{ base: "md", md: "lg" }}
color="gray.700"
>
{(meeting.calendar_metadata as any).description}
</Text>
)}
<Flex
gap={{ base: 4, md: 8 }}
fontSize={{ base: "sm", md: "md" }}
color="gray.600"
flexWrap="wrap"
>
<HStack>
<Icon
as={FaUsers}
boxSize={{ base: "16px", md: "20px" }}
/>
<Text fontWeight="medium">
{meeting.num_clients || 0} participant
{meeting.num_clients !== 1 ? "s" : ""}
</Text>
</HStack>
<HStack>
<Icon
as={FaClock}
boxSize={{ base: "16px", md: "20px" }}
/>
<Text>
{formatStartedAgo(new Date(meeting.start_date))}
</Text>
</HStack>
</Flex>
{isOwner &&
(meeting.calendar_metadata as any)?.attendees && (
<HStack gap={3} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 4)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="blue"
fontSize="sm"
px={3}
py={1}
>
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees.length >
4 && (
<Badge
colorScheme="gray"
fontSize="sm"
px={3}
py={1}
>
+
{(meeting.calendar_metadata as any).attendees
.length - 4}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<VStack gap={3} width={{ base: "full", md: "auto" }}>
<Button
colorScheme="blue"
size={{ base: "lg", md: "xl" }}
fontSize={{ base: "md", md: "lg" }}
px={{ base: 6, md: 8 }}
py={{ base: 4, md: 6 }}
width={{ base: "full", md: "auto" }}
onClick={() => handleJoinDirect(meeting)}
>
<Icon
as={FaUsers}
boxSize={{ base: "18px", md: "20px" }}
me={2}
/>
Join Now
</Button>
{isOwner && (
<Button
variant="outline"
colorScheme="red"
size="md"
onClick={() => handleEndMeeting(meeting.id)}
loading={deactivateMeetingMutation.isPending}
>
<Icon as={LuX} me={2} />
End Meeting
</Button>
)}
</VStack>
</Flex>
</Box>
))}
</VStack>
) : upcomingMeetings.length > 0 ? (
/* Upcoming Meetings - BIG DISPLAY when no ongoing meetings */
<VStack align="stretch" gap={6} mb={8}>
<Text fontSize="xl" fontWeight="bold" color="gray.800">
Upcoming Meeting{upcomingMeetings.length > 1 ? "s" : ""}
</Text>
{upcomingMeetings.map((meeting) => {
const now = new Date();
const startTime = new Date(meeting.start_date);
const minutesUntilStart = Math.floor(
(startTime.getTime() - now.getTime()) / (1000 * 60),
);
return (
<Box
key={meeting.id}
width="100%"
bg="orange.50"
borderRadius="xl"
p={{ base: 4, md: 8 }}
border="2px solid"
borderColor="orange.200"
>
<Flex
direction={{ base: "column", md: "row" }}
justify="space-between"
align={{ base: "stretch", md: "start" }}
gap={{ base: 4, md: 6 }}
>
<VStack align="start" gap={{ base: 3, md: 4 }} flex={1}>
<HStack>
<Icon
as={FaCalendarAlt}
color="orange.600"
boxSize="24px"
/>
<Text
fontSize={{ base: "xl", md: "2xl" }}
fontWeight="bold"
color="orange.800"
>
{(meeting.calendar_metadata as any)?.title ||
"Upcoming Meeting"}
</Text>
</HStack>
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text
fontSize={{ base: "md", md: "lg" }}
color="gray.700"
>
{(meeting.calendar_metadata as any).description}
</Text>
)}
<Flex
gap={{ base: 2, md: 8 }}
fontSize={{ base: "sm", md: "md" }}
color="gray.600"
flexWrap="wrap"
align="center"
>
<Badge
colorScheme="orange"
fontSize={{ base: "sm", md: "md" }}
px={3}
py={1}
>
Starts in {minutesUntilStart} minute
{minutesUntilStart !== 1 ? "s" : ""}
</Badge>
<Text>
{formatDateTime(new Date(meeting.start_date))}
</Text>
</Flex>
{isOwner &&
(meeting.calendar_metadata as any)?.attendees && (
<HStack gap={3} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 4)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="orange"
fontSize="sm"
px={3}
py={1}
>
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees
.length > 4 && (
<Badge
colorScheme="gray"
fontSize="sm"
px={3}
py={1}
>
+
{(meeting.calendar_metadata as any).attendees
.length - 4}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<VStack gap={3} width={{ base: "full", md: "auto" }}>
<Button
colorScheme="orange"
size={{ base: "lg", md: "xl" }}
fontSize={{ base: "md", md: "lg" }}
px={{ base: 6, md: 8 }}
py={{ base: 4, md: 6 }}
width={{ base: "full", md: "auto" }}
onClick={() => handleJoinUpcoming(meeting)}
>
<Icon
as={FaClock}
boxSize={{ base: "18px", md: "20px" }}
me={2}
/>
Join Early
</Button>
{isOwner && (
<Button
variant="outline"
colorScheme="red"
size="md"
onClick={() => handleEndMeeting(meeting.id)}
loading={deactivateMeetingMutation.isPending}
>
<Icon as={LuX} me={2} />
Cancel Meeting
</Button>
)}
</VStack>
</Flex>
</Box>
);
})}
</VStack>
) : null}
{/* Upcoming Meetings - SMALLER ASIDE DISPLAY when there are ongoing meetings */}
{currentMeetings.length > 0 && upcomingMeetings.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="lg" fontWeight="semibold" color="gray.700">
Starting Soon
</Text>
<Flex
gap={4}
flexWrap="wrap"
direction={{ base: "column", sm: "row" }}
>
{upcomingMeetings.map((meeting) => {
const now = new Date();
const startTime = new Date(meeting.start_date);
const minutesUntilStart = Math.floor(
(startTime.getTime() - now.getTime()) / (1000 * 60),
);
return (
<Box
key={meeting.id}
bg="white"
border="2px solid"
borderColor="orange.200"
borderRadius="lg"
p={4}
minW={{ base: "100%", sm: "300px" }}
maxW={{ base: "100%", sm: "400px" }}
_hover={{ borderColor: "orange.300", bg: "orange.50" }}
transition="all 0.2s"
>
<VStack align="start" gap={3}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold" fontSize="md">
{(meeting.calendar_metadata as any)?.title ||
"Upcoming Meeting"}
</Text>
</HStack>
<Badge colorScheme="orange" fontSize="sm" px={2} py={1}>
in {minutesUntilStart} minute
{minutesUntilStart !== 1 ? "s" : ""}
</Badge>
<Text fontSize="sm" color="gray.600">
Starts: {formatDateTime(new Date(meeting.start_date))}
</Text>
<Button
colorScheme="orange"
size="sm"
width="full"
onClick={() => handleJoinUpcoming(meeting)}
>
Join Early
</Button>
</VStack>
</Box>
);
})}
</Flex>
</VStack>
)}
{/* No meetings message - show when no ongoing or upcoming meetings */}
{currentMeetings.length === 0 && upcomingMeetings.length === 0 && (
<Flex
width="100%"
flex="1"
justify="center"
align="center"
textAlign="center"
mb={6}
>
<VStack gap={4}>
<Icon as={FaCalendarAlt} boxSize="48px" color="gray.400" />
<VStack gap={2}>
<Text fontSize="xl" fontWeight="semibold" color="black">
No meetings right now
</Text>
<Text fontSize="md" color="gray.600" maxW="400px">
There are no ongoing or upcoming meetings in this room at the
moment.
</Text>
</VStack>
</VStack>
</Flex>
)}
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1 @@
export const MEETING_DEFAULT_TIME_MINUTES = 60;

View File

@@ -0,0 +1,3 @@
import Room from "../room";
export default Room;

View File

@@ -1,336 +1,3 @@
"use client";
import Room from "./room";
import {
useCallback,
useEffect,
useRef,
useState,
useContext,
RefObject,
use,
} from "react";
import {
Box,
Button,
Text,
VStack,
HStack,
Spinner,
Icon,
} from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
import useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation";
import { notFound } from "next/navigation";
import { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent } from "../lib/apiHooks";
import type { components } from "../reflector-api";
type Meeting = components["schemas"]["Meeting"];
import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider";
export type RoomDetails = {
params: Promise<{
roomName: string;
}>;
};
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
const useConsentWherebyFocusManagement = (
acceptButtonRef: RefObject<HTMLButtonElement>,
wherebyRef: RefObject<HTMLElement>,
) => {
const currentFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
} else {
console.error(
"accept button ref not available yet for focus management - seems to be illegal state",
);
}
const handleWherebyReady = () => {
console.log("whereby ready - refocusing consent button");
currentFocusRef.current = document.activeElement as HTMLElement;
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
}
};
if (wherebyRef.current) {
wherebyRef.current.addEventListener("ready", handleWherebyReady);
} else {
console.warn(
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
);
}
return () => {
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
currentFocusRef.current?.focus();
};
}, []);
};
const useConsentDialog = (
meetingId: string,
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
) => {
const { state: consentState, touch, hasConsent } = useRecordingConsent();
// toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
body: {
consent_given: given,
},
});
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
}
},
[audioConsentMutation, touch],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => {
const AcceptButton = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
return (
<Button
ref={buttonRef}
colorPalette="primary"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
Yes, store the audio
</Button>
);
};
return (
<Box
p={6}
bg="rgba(255, 255, 255, 0.7)"
borderRadius="lg"
boxShadow="lg"
maxW="md"
mx="auto"
>
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack gap={4} justifyContent="center">
<Button
variant="ghost"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
No, delete after transcription
</Button>
<AcceptButton />
</HStack>
</VStack>
</Box>
);
},
});
// Set modal state when toast is dismissed
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, 100);
});
// Handle escape key to close the toast
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
return {
showConsentModal,
consentState,
hasConsent,
consentLoading: audioConsentMutation.isPending,
};
};
function ConsentDialogButton({
meetingId,
wherebyRef,
}: {
meetingId: string;
wherebyRef: React.RefObject<HTMLElement>;
}) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null;
}
return (
<Button
position="absolute"
top="56px"
left="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
Meeting is being recorded
<Icon as={FaBars} ml={2} />
</Button>
);
}
const recordingTypeRequiresConsent = (
recordingType: NonNullable<Meeting["recording_type"]>,
) => {
return recordingType === "cloud";
};
// next throws even with "use client"
const useWhereby = () => {
const [wherebyLoaded, setWherebyLoaded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true);
})
.catch(console.error.bind(console));
}
}, []);
return wherebyLoaded;
};
export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const roomName = params.roomName;
const meeting = useRoomMeeting(roomName);
const router = useRouter();
const status = useAuth().status;
const isAuthenticated = status === "authenticated";
const isLoading = status === "loading" || meeting.loading;
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
: meeting?.response?.room_url;
const meetingId = meeting?.response?.id;
const recordingType = meeting?.response?.recording_type;
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (
!isLoading &&
meeting?.error &&
"status" in meeting.error &&
meeting.error.status === 404
) {
notFound();
}
}, [isLoading, meeting?.error]);
useEffect(() => {
if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
if (isLoading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}
return (
<>
{roomUrl && meetingId && wherebyLoaded && (
<>
<whereby-embed
ref={wherebyRef}
room={roomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
{recordingType && recordingTypeRequiresConsent(recordingType) && (
<ConsentDialogButton
meetingId={meetingId}
wherebyRef={wherebyRef}
/>
)}
</>
)}
</>
);
}
export default Room;

437
www/app/[roomName]/room.tsx Normal file
View File

@@ -0,0 +1,437 @@
"use client";
import { roomMeetingUrl, roomUrl as getRoomUrl } from "../lib/routes";
import {
useCallback,
useEffect,
useRef,
useState,
useContext,
RefObject,
use,
} from "react";
import {
Box,
Button,
Text,
VStack,
HStack,
Spinner,
Icon,
} from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
import { useRouter } from "next/navigation";
import { useRecordingConsent } from "../recordingConsentContext";
import {
useMeetingAudioConsent,
useRoomGetByName,
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomsCreateMeeting,
useRoomGetMeeting,
} from "../lib/apiHooks";
import type { components } from "../reflector-api";
import MeetingSelection from "./MeetingSelection";
import useRoomDefaultMeeting from "./useRoomDefaultMeeting";
type Meeting = components["schemas"]["Meeting"];
import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
import { useError } from "../(errors)/errorContext";
import {
assertExistsAndNonEmptyString,
NonEmptyString,
parseNonEmptyString,
} from "../lib/utils";
import { printApiError } from "../api/_error";
export type RoomDetails = {
params: Promise<{
roomName: string;
meetingId?: string;
}>;
};
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
const useConsentWherebyFocusManagement = (
acceptButtonRef: RefObject<HTMLButtonElement>,
wherebyRef: RefObject<HTMLElement>,
) => {
const currentFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
} else {
console.error(
"accept button ref not available yet for focus management - seems to be illegal state",
);
}
const handleWherebyReady = () => {
console.log("whereby ready - refocusing consent button");
currentFocusRef.current = document.activeElement as HTMLElement;
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
}
};
if (wherebyRef.current) {
wherebyRef.current.addEventListener("ready", handleWherebyReady);
} else {
console.warn(
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
);
}
return () => {
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
currentFocusRef.current?.focus();
};
}, []);
};
const useConsentDialog = (
meetingId: string,
wherebyRef: RefObject<HTMLElement> /*accessibility*/,
) => {
const { state: consentState, touch, hasConsent } = useRecordingConsent();
// toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
body: {
consent_given: given,
},
});
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
}
},
[audioConsentMutation, touch],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => {
const AcceptButton = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
return (
<Button
ref={buttonRef}
colorPalette="primary"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
Yes, store the audio
</Button>
);
};
return (
<Box
p={6}
bg="rgba(255, 255, 255, 0.7)"
borderRadius="lg"
boxShadow="lg"
maxW="md"
mx="auto"
>
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack gap={4} justifyContent="center">
<Button
variant="ghost"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
No, delete after transcription
</Button>
<AcceptButton />
</HStack>
</VStack>
</Box>
);
},
});
// Set modal state when toast is dismissed
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, 100);
});
// Handle escape key to close the toast
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
return {
showConsentModal,
consentState,
hasConsent,
consentLoading: audioConsentMutation.isPending,
};
};
function ConsentDialogButton({
meetingId,
wherebyRef,
}: {
meetingId: NonEmptyString;
wherebyRef: React.RefObject<HTMLElement>;
}) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null;
}
return (
<Button
position="absolute"
top="56px"
left="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
Meeting is being recorded
<Icon as={FaBars} ml={2} />
</Button>
);
}
const recordingTypeRequiresConsent = (
recordingType: NonNullable<Meeting["recording_type"]>,
) => {
return recordingType === "cloud";
};
export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const roomName = parseNonEmptyString(params.roomName);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const { setError } = useError();
const roomQuery = useRoomGetByName(roomName);
const createMeetingMutation = useRoomsCreateMeeting();
const room = roomQuery.data;
const pageMeetingId = params.meetingId;
// this one is called on room page
const defaultMeeting = useRoomDefaultMeeting(
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
);
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
const wherebyRoomUrl = explicitMeeting.data
? getWherebyUrl(explicitMeeting.data)
: defaultMeeting.response
? getWherebyUrl(defaultMeeting.response)
: null;
const recordingType = (explicitMeeting.data || defaultMeeting.response)
?.recording_type;
const meetingId = (explicitMeeting.data || defaultMeeting.response)?.id;
const isLoading =
status === "loading" ||
roomQuery.isLoading ||
defaultMeeting?.loading ||
explicitMeeting.isLoading;
const errors = [
explicitMeeting.error,
defaultMeeting.error,
roomQuery.error,
createMeetingMutation.error,
].filter(Boolean);
const isOwner =
isAuthenticated && room ? auth.user?.id === room.user_id : false;
const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push(
roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)),
);
};
const handleCreateUnscheduled = async () => {
try {
// Create a new unscheduled meeting
const newMeeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
body: {
allow_duplicated: room ? room.ics_enabled : false,
},
});
handleMeetingSelect(newMeeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
return;
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
useEffect(() => {
if (!isLoading && !wherebyRoomUrl) {
setError(new Error("Whereby room URL not found"));
}
}, [isLoading, wherebyRoomUrl]);
if (isLoading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}
if (!room) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
if (room.ics_enabled && !params.meetingId) {
return (
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
isSharedRoom={room?.is_shared || false}
authLoading={["loading", "refreshing"].includes(auth.status)}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
isCreatingMeeting={createMeetingMutation.isPending}
/>
);
}
if (errors.length > 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
{errors.map((error, i) => (
<Text key={i} fontSize="lg">
{printApiError(error)}
</Text>
))}
</Box>
);
}
return (
<>
{wherebyRoomUrl && wherebyLoaded && (
<>
<whereby-embed
ref={wherebyRef}
room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
{recordingType &&
recordingTypeRequiresConsent(recordingType) &&
meetingId && (
<ConsentDialogButton
meetingId={assertExistsAndNonEmptyString(meetingId)}
wherebyRef={wherebyRef}
/>
)}
</>
)}
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useError } from "../(errors)/errorContext";
import type { components } from "../reflector-api";
import { shouldShowError } from "../lib/errorUtils";
@@ -6,30 +6,31 @@ import { shouldShowError } from "../lib/errorUtils";
type Meeting = components["schemas"]["Meeting"];
import { useRoomsCreateMeeting } from "../lib/apiHooks";
import { notFound } from "next/navigation";
import { ApiError } from "../api/_error";
type ErrorMeeting = {
error: Error;
error: ApiError;
loading: false;
response: null;
reload: () => void;
};
type LoadingMeeting = {
error: null;
response: null;
loading: true;
error: false;
reload: () => void;
};
type SuccessMeeting = {
error: null;
response: Meeting;
loading: false;
error: null;
reload: () => void;
};
const useRoomMeeting = (
roomName: string | null | undefined,
const useRoomDefaultMeeting = (
roomName: string | null,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState<Meeting | null>(null);
const [reload, setReload] = useState(0);
@@ -37,10 +38,15 @@ const useRoomMeeting = (
const createMeetingMutation = useRoomsCreateMeeting();
const reloadHandler = () => setReload((prev) => prev + 1);
// this is to undupe dev mode room creation
const creatingRef = useRef(false);
useEffect(() => {
if (!roomName) return;
if (creatingRef.current) return;
const createMeeting = async () => {
creatingRef.current = true;
try {
const result = await createMeetingMutation.mutateAsync({
params: {
@@ -48,6 +54,9 @@ const useRoomMeeting = (
room_name: roomName,
},
},
body: {
allow_duplicated: false,
},
});
setResponse(result);
} catch (error: any) {
@@ -60,14 +69,16 @@ const useRoomMeeting = (
} else {
setError(error);
}
} finally {
creatingRef.current = false;
}
};
createMeeting();
createMeeting().then(() => {});
}, [roomName, reload]);
const loading = createMeetingMutation.isPending && !response;
const error = createMeetingMutation.error as Error | null;
const error = createMeetingMutation.error;
return { response, loading, error, reload: reloadHandler } as
| ErrorMeeting
@@ -75,4 +86,4 @@ const useRoomMeeting = (
| SuccessMeeting;
};
export default useRoomMeeting;
export default useRoomDefaultMeeting;

26
www/app/api/_error.ts Normal file
View File

@@ -0,0 +1,26 @@
import { components } from "../reflector-api";
import { isArray } from "remeda";
export type ApiError = {
detail?: components["schemas"]["ValidationError"][];
} | null;
// errors as declared on api types is not != as they in reality e.g. detail may be a string
export const printApiError = (error: ApiError) => {
if (!error || !error.detail) {
return null;
}
const detail = error.detail as unknown;
if (isArray(error.detail)) {
return error.detail.map((e) => e.msg).join(", ");
}
if (typeof detail === "string") {
if (detail.length > 0) {
return detail;
}
console.error("Error detail is empty");
return null;
}
console.error("Error detail is not a string or array");
return null;
};

View File

@@ -0,0 +1,101 @@
"use client";
import { Flex, Link, Button, Text, HStack } from "@chakra-ui/react";
import NextLink from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { roomUrl } from "../lib/routes";
import { NonEmptyString } from "../lib/utils";
interface MeetingMinimalHeaderProps {
roomName: NonEmptyString;
displayName?: string;
showLeaveButton?: boolean;
onLeave?: () => void;
showCreateButton?: boolean;
onCreateMeeting?: () => void;
isCreatingMeeting?: boolean;
}
export default function MeetingMinimalHeader({
roomName,
displayName,
showLeaveButton = true,
onLeave,
showCreateButton = false,
onCreateMeeting,
isCreatingMeeting = false,
}: MeetingMinimalHeaderProps) {
const router = useRouter();
const handleLeaveMeeting = () => {
if (onLeave) {
onLeave();
} else {
router.push(roomUrl(roomName));
}
};
const roomTitle = displayName
? displayName.endsWith("'s") || displayName.endsWith("s")
? `${displayName} Room`
: `${displayName}'s Room`
: `${roomName} Room`;
return (
<Flex
as="header"
justify="space-between"
alignItems="center"
w="100%"
py="2"
px="4"
bg="white"
position="sticky"
top="0"
zIndex="10"
>
{/* Logo and Room Context */}
<Flex alignItems="center" gap={3}>
<Link as={NextLink} href="/" className="flex items-center">
<Image
src="/reach.svg"
width={24}
height={30}
className="h-8 w-auto"
alt="Reflector"
/>
</Link>
<Text fontSize="lg" fontWeight="semibold" color="black">
{roomTitle}
</Text>
</Flex>
{/* Action Buttons */}
<HStack gap={2}>
{showCreateButton && onCreateMeeting && (
<Button
colorScheme="green"
size="sm"
onClick={onCreateMeeting}
loading={isCreatingMeeting}
disabled={isCreatingMeeting}
>
Create Meeting
</Button>
)}
{showLeaveButton && (
<Button
variant="outline"
colorScheme="gray"
size="sm"
onClick={handleLeaveMeeting}
disabled={isCreatingMeeting}
>
Leave Room
</Button>
)}
</HStack>
</Flex>
);
}

View File

@@ -4,16 +4,16 @@ import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
interface WherebyEmbedProps {
interface WherebyWebinarEmbedProps {
roomUrl: string;
onLeave?: () => void;
}
// currently used for webinars only
// used for webinars only
export default function WherebyWebinarEmbed({
roomUrl,
onLeave,
}: WherebyEmbedProps) {
}: WherebyWebinarEmbedProps) {
const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms

View File

@@ -12,7 +12,7 @@ import { useAuth } from "./AuthProvider";
* or, limitation or incorrect usage of .d type generator from json schema
* */
const useAuthReady = () => {
export const useAuthReady = () => {
const auth = useAuth();
return {
@@ -75,7 +75,7 @@ export function useTranscriptDelete() {
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
@@ -102,7 +102,7 @@ export function useTranscriptGet(transcriptId: string | null) {
{
params: {
path: {
transcript_id: transcriptId || "",
transcript_id: transcriptId!,
},
},
},
@@ -120,7 +120,7 @@ export function useRoomGet(roomId: string | null) {
"/v1/rooms/{room_id}",
{
params: {
path: { room_id: roomId || "" },
path: { room_id: roomId! },
},
},
{
@@ -145,7 +145,7 @@ export function useRoomCreate() {
return $api.useMutation("post", "/v1/rooms", {
onSuccess: () => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
@@ -188,7 +188,7 @@ export function useRoomDelete() {
return $api.useMutation("delete", "/v1/rooms/{room_id}", {
onSuccess: () => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
@@ -236,7 +236,7 @@ export function useTranscriptUpdate() {
return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
params: {
path: { transcript_id: variables.params.path.transcript_id },
@@ -270,7 +270,7 @@ export function useTranscriptUploadAudio() {
"/v1/transcripts/{transcript_id}/record/upload",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
@@ -327,7 +327,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/topics",
{
params: {
path: { transcript_id: transcriptId || "" },
path: { transcript_id: transcriptId! },
},
},
{
@@ -344,7 +344,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/topics/with-words",
{
params: {
path: { transcript_id: transcriptId || "" },
path: { transcript_id: transcriptId! },
},
},
{
@@ -365,8 +365,8 @@ export function useTranscriptTopicsWithWordsPerSpeaker(
{
params: {
path: {
transcript_id: transcriptId || "",
topic_id: topicId || "",
transcript_id: transcriptId!,
topic_id: topicId!,
},
},
},
@@ -384,7 +384,7 @@ export function useTranscriptParticipants(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: transcriptId || "" },
path: { transcript_id: transcriptId! },
},
},
{
@@ -402,7 +402,7 @@ export function useTranscriptParticipantUpdate() {
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -430,7 +430,7 @@ export function useTranscriptParticipantCreate() {
"/v1/transcripts/{transcript_id}/participants",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -458,7 +458,7 @@ export function useTranscriptParticipantDelete() {
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -486,28 +486,30 @@ export function useTranscriptSpeakerAssign() {
"/v1/transcripts/{transcript_id}/speaker/assign",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
return Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
},
).queryKey,
});
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
).queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
},
).queryKey,
});
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error assigning the speaker");
@@ -525,28 +527,30 @@ export function useTranscriptSpeakerMerge() {
"/v1/transcripts/{transcript_id}/speaker/merge",
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
return Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
},
).queryKey,
});
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
).queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
{
params: {
path: { transcript_id: variables.params.path.transcript_id },
},
},
},
).queryKey,
});
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error merging speakers");
@@ -565,6 +569,29 @@ export function useMeetingAudioConsent() {
});
}
export function useMeetingDeactivate() {
const { setError } = useError();
const queryClient = useQueryClient();
return $api.useMutation("patch", `/v1/meetings/{meeting_id}/deactivate`, {
onError: (error) => {
setError(error as Error, "Failed to end meeting");
},
onSuccess: () => {
return queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key.some(
(k) =>
typeof k === "string" &&
!!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)),
);
},
});
},
});
}
export function useTranscriptWebRTC() {
const { setError } = useError();
@@ -585,7 +612,7 @@ export function useTranscriptCreate() {
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
@@ -600,13 +627,164 @@ export function useRoomsCreateMeeting() {
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
onSuccess: async (data, variables) => {
const roomName = variables.params.path.room_name;
await Promise.all([
queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
}),
queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName },
},
},
).queryKey,
}),
]);
},
onError: (error) => {
setError(error as Error, "There was an error creating the meeting");
},
});
}
// Calendar integration hooks
export function useRoomGetByName(roomName: string | null) {
return $api.useQuery(
"get",
"/v1/rooms/name/{room_name}",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName,
},
);
}
export function useRoomUpcomingMeetings(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
const MEETINGS_PATH_PARTIAL = "meetings" as const;
const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const;
const MEETINGS_UPCOMING_PATH_PARTIAL =
`${MEETINGS_PATH_PARTIAL}/upcoming` as const;
const MEETING_LIST_PATH_PARTIALS = [
MEETINGS_ACTIVE_PATH_PARTIAL,
MEETINGS_UPCOMING_PATH_PARTIAL,
];
export function useRoomActiveMeetings(roomName: string | null) {
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName,
},
);
}
export function useRoomGetMeeting(
roomName: string | null,
meetingId: string | null,
) {
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings/{meeting_id}",
{
params: {
path: {
room_name: roomName!,
meeting_id: meetingId!,
},
},
},
{
enabled: !!roomName && !!meetingId,
},
);
}
export function useRoomJoinMeeting() {
const { setError } = useError();
return $api.useMutation(
"post",
"/v1/rooms/{room_name}/meetings/{meeting_id}/join",
{
onError: (error) => {
setError(error as Error, "There was an error joining the meeting");
},
},
);
}
export function useRoomIcsSync() {
const { setError } = useError();
return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", {
onError: (error) => {
setError(error as Error, "There was an error syncing the calendar");
},
});
}
export function useRoomIcsStatus(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/ics/status",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
export function useRoomCalendarEvents(roomName: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/rooms/{room_name}/meetings",
{
params: {
path: { room_name: roomName! },
},
},
{
enabled: !!roomName && isAuthenticated,
},
);
}
// End of Calendar integration hooks

7
www/app/lib/routes.ts Normal file
View File

@@ -0,0 +1,7 @@
import { NonEmptyString } from "./utils";
export const roomUrl = (roomName: NonEmptyString) => `/${roomName}`;
export const roomMeetingUrl = (
roomName: NonEmptyString,
meetingId: NonEmptyString,
) => `${roomUrl(roomName)}/${meetingId}`;

View File

@@ -0,0 +1,5 @@
import { roomUrl } from "./routes";
import { NonEmptyString } from "./utils";
export const roomAbsoluteUrl = (roomName: NonEmptyString) =>
`${window.location.origin}${roomUrl(roomName)}`;

25
www/app/lib/timeUtils.ts Normal file
View File

@@ -0,0 +1,25 @@
export const formatDateTime = (d: Date): string => {
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
export const formatStartedAgo = (
startTime: Date,
now: Date = new Date(),
): string => {
const diff = now.getTime() - startTime.getTime();
if (diff <= 0) return "Starting now";
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`;
if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`;
return `Started ${minutes} minutes ago`;
};

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import { components } from "../reflector-api";
export const useWhereby = () => {
const [wherebyLoaded, setWherebyLoaded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
import("@whereby.com/browser-sdk/embed")
.then(() => {
setWherebyLoaded(true);
})
.catch(console.error.bind(console));
}
}, []);
return wherebyLoaded;
};
export const getWherebyUrl = (
meeting: Pick<components["schemas"]["Meeting"], "room_url" | "host_room_url">,
) =>
// host_room_url possible '' atm
meeting.host_room_url || meeting.room_url;

View File

@@ -41,6 +41,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/meetings/{meeting_id}/deactivate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Meeting Deactivate */
patch: operations["v1_meeting_deactivate"];
trace?: never;
};
"/v1/rooms": {
parameters: {
query?: never;
@@ -78,6 +95,23 @@ export interface paths {
patch: operations["v1_rooms_update"];
trace?: never;
};
"/v1/rooms/name/{room_name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms Get By Name */
get: operations["v1_rooms_get_by_name"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meeting": {
parameters: {
query?: never;
@@ -115,6 +149,128 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/ics/sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Rooms Sync Ics */
post: operations["v1_rooms_sync_ics"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/ics/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms Ics Status */
get: operations["v1_rooms_ics_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms List Meetings */
get: operations["v1_rooms_list_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/upcoming": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms List Upcoming Meetings */
get: operations["v1_rooms_list_upcoming_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/active": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Rooms List Active Meetings */
get: operations["v1_rooms_list_active_meetings"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/{meeting_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Rooms Get Meeting
* @description Get a single meeting by ID within a specific room.
*/
get: operations["v1_rooms_get_meeting"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/rooms/{room_name}/meetings/{meeting_id}/join": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Rooms Join Meeting */
post: operations["v1_rooms_join_meeting"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts": {
parameters: {
query?: never;
@@ -505,6 +661,52 @@ export interface components {
*/
chunk: string;
};
/** CalendarEventResponse */
CalendarEventResponse: {
/** Id */
id: string;
/** Room Id */
room_id: string;
/** Ics Uid */
ics_uid: string;
/** Title */
title?: string | null;
/** Description */
description?: string | null;
/**
* Start Time
* Format: date-time
*/
start_time: string;
/**
* End Time
* Format: date-time
*/
end_time: string;
/** Attendees */
attendees?:
| {
[key: string]: unknown;
}[]
| null;
/** Location */
location?: string | null;
/**
* Last Synced
* Format: date-time
*/
last_synced: string;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
};
/** CreateParticipant */
CreateParticipant: {
/** Speaker */
@@ -536,6 +738,26 @@ export interface components {
webhook_url: string;
/** Webhook Secret */
webhook_secret: string;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
/**
* Allow Duplicated
* @default false
*/
allow_duplicated: boolean | null;
};
/** CreateTranscript */
CreateTranscript: {
@@ -748,6 +970,60 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** ICSStatus */
ICSStatus: {
/**
* Status
* @enum {string}
*/
status: "enabled" | "disabled";
/** Last Sync */
last_sync?: string | null;
/** Next Sync */
next_sync?: string | null;
/** Last Etag */
last_etag?: string | null;
/**
* Events Count
* @default 0
*/
events_count: number;
};
/** ICSSyncResult */
ICSSyncResult: {
status: components["schemas"]["SyncStatus"];
/** Hash */
hash?: string | null;
/**
* Events Found
* @default 0
*/
events_found: number;
/**
* Total Events
* @default 0
*/
total_events: number;
/**
* Events Created
* @default 0
*/
events_created: number;
/**
* Events Updated
* @default 0
*/
events_updated: number;
/**
* Events Deleted
* @default 0
*/
events_deleted: number;
/** Error */
error?: string | null;
/** Reason */
reason?: string | null;
};
/** Meeting */
Meeting: {
/** Id */
@@ -768,12 +1044,53 @@ export interface components {
* Format: date-time
*/
end_date: string;
/** User Id */
user_id?: string | null;
/** Room Id */
room_id?: string | null;
/**
* Is Locked
* @default false
*/
is_locked: boolean;
/**
* Room Mode
* @default normal
* @enum {string}
*/
room_mode: "normal" | "group";
/**
* Recording Type
* @default cloud
* @enum {string}
*/
recording_type: "none" | "local" | "cloud";
/**
* Recording Trigger
* @default automatic-2nd-participant
* @enum {string}
*/
recording_trigger:
| "none"
| "prompt"
| "automatic"
| "automatic-2nd-participant";
/**
* Num Clients
* @default 0
*/
num_clients: number;
/**
* Is Active
* @default true
*/
is_active: boolean;
/** Calendar Event Id */
calendar_event_id?: string | null;
/** Calendar Metadata */
calendar_metadata?: {
[key: string]: unknown;
} | null;
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -844,6 +1161,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
/** Ics Last Sync */
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
};
/** RoomDetails */
RoomDetails: {
@@ -874,6 +1207,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
/** Ics Url */
ics_url?: string | null;
/**
* Ics Fetch Interval
* @default 300
*/
ics_fetch_interval: number;
/**
* Ics Enabled
* @default false
*/
ics_enabled: boolean;
/** Ics Last Sync */
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -998,6 +1347,11 @@ export interface components {
/** Name */
name: string;
};
/**
* SyncStatus
* @enum {string}
*/
SyncStatus: "success" | "unchanged" | "error" | "skipped";
/** Topic */
Topic: {
/** Name */
@@ -1022,27 +1376,33 @@ export interface components {
/** UpdateRoom */
UpdateRoom: {
/** Name */
name: string;
name?: string | null;
/** Zulip Auto Post */
zulip_auto_post: boolean;
zulip_auto_post?: boolean | null;
/** Zulip Stream */
zulip_stream: string;
zulip_stream?: string | null;
/** Zulip Topic */
zulip_topic: string;
zulip_topic?: string | null;
/** Is Locked */
is_locked: boolean;
is_locked?: boolean | null;
/** Room Mode */
room_mode: string;
room_mode?: string | null;
/** Recording Type */
recording_type: string;
recording_type?: string | null;
/** Recording Trigger */
recording_trigger: string;
recording_trigger?: string | null;
/** Is Shared */
is_shared: boolean;
is_shared?: boolean | null;
/** Webhook Url */
webhook_url: string;
webhook_url?: string | null;
/** Webhook Secret */
webhook_secret: string;
webhook_secret?: string | null;
/** Ics Url */
ics_url?: string | null;
/** Ics Fetch Interval */
ics_fetch_interval?: number | null;
/** Ics Enabled */
ics_enabled?: boolean | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -1204,6 +1564,37 @@ export interface operations {
};
};
};
v1_meeting_deactivate: {
parameters: {
query?: never;
header?: never;
path: {
meeting_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list: {
parameters: {
query?: {
@@ -1368,7 +1759,7 @@ export interface operations {
};
};
};
v1_rooms_create_meeting: {
v1_rooms_get_by_name: {
parameters: {
query?: never;
header?: never;
@@ -1378,6 +1769,41 @@ export interface operations {
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RoomDetails"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_create_meeting: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateRoomMeeting"];
};
};
responses: {
/** @description Successful Response */
200: {
@@ -1430,6 +1856,227 @@ export interface operations {
};
};
};
v1_rooms_sync_ics: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ICSSyncResult"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_ics_status: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ICSStatus"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_meetings: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarEventResponse"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_upcoming_meetings: {
parameters: {
query?: {
minutes_ahead?: number;
};
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarEventResponse"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_list_active_meetings: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meeting"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_get_meeting: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
meeting_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meeting"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_rooms_join_meeting: {
parameters: {
query?: never;
header?: never;
path: {
room_name: string;
meeting_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Meeting"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcripts_list: {
parameters: {
query?: {

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, use } from "react";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
import useRoomMeeting from "../../[roomName]/useRoomMeeting";
import useRoomDefaultMeeting from "../../[roomName]/useRoomDefaultMeeting";
import dynamic from "next/dynamic";
const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), {
ssr: false,
@@ -72,7 +72,7 @@ export default function WebinarPage(details: WebinarDetails) {
const startDate = new Date(Date.parse(webinar.startsAt));
const endDate = new Date(Date.parse(webinar.endsAt));
const meeting = useRoomMeeting(ROOM_NAME);
const meeting = useRoomDefaultMeeting(ROOM_NAME);
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
: meeting?.response?.room_url;

View File

@@ -45,6 +45,7 @@
"react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7",
"redlock": "5.0.0-beta.2",
"remeda": "^2.31.1",
"sass": "^1.63.6",
"simple-peer": "^9.11.1",
"tailwindcss": "^3.3.2",

13
www/pnpm-lock.yaml generated
View File

@@ -106,6 +106,9 @@ importers:
redlock:
specifier: 5.0.0-beta.2
version: 5.0.0-beta.2
remeda:
specifier: ^2.31.1
version: 2.31.1
sass:
specifier: ^1.63.6
version: 1.90.0
@@ -7645,6 +7648,12 @@ packages:
integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==,
}
remeda@2.31.1:
resolution:
{
integrity: sha512-FRZefcuXbmCoYt8hAITAzW4t8i/RERaGk/+GtRN90eV3NHxsnRKCDIOJVrwrQ6zz77TG/Xyi9mGRfiJWT7DK1g==,
}
require-directory@2.1.1:
resolution:
{
@@ -14510,6 +14519,10 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remeda@2.31.1:
dependencies:
type-fest: 4.41.0
require-directory@2.1.1: {}
require-from-string@2.0.2: {}