mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
## Frontend Implementation
### Meeting Selection & Management
- 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
### Waiting Room
- 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
### Meeting Info Panel
- 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
### ICS Configuration UI
- 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)
### Routing & Navigation
- 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
### API Integration
- 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
### UI/UX Improvements
- 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.
359 lines
9.5 KiB
TypeScript
359 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
useContext,
|
|
RefObject,
|
|
} 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 useSessionStatus from "../lib/useSessionStatus";
|
|
import { useRecordingConsent } from "../recordingConsentContext";
|
|
import useApi from "../lib/useApi";
|
|
import { Meeting, Room } from "../api";
|
|
import { FaBars, FaInfoCircle } from "react-icons/fa6";
|
|
import MeetingInfo from "./MeetingInfo";
|
|
|
|
export type RoomDetails = {
|
|
params: {
|
|
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();
|
|
const [consentLoading, setConsentLoading] = useState(false);
|
|
// toast would open duplicates, even with using "id=" prop
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const api = useApi();
|
|
|
|
const handleConsent = useCallback(
|
|
async (meetingId: string, given: boolean) => {
|
|
if (!api) return;
|
|
|
|
setConsentLoading(true);
|
|
|
|
try {
|
|
await api.v1MeetingAudioConsent({
|
|
meetingId,
|
|
requestBody: { consent_given: given },
|
|
});
|
|
|
|
touch(meetingId);
|
|
} catch (error) {
|
|
console.error("Error submitting consent:", error);
|
|
} finally {
|
|
setConsentLoading(false);
|
|
}
|
|
},
|
|
[api, 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 };
|
|
};
|
|
|
|
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 wherebyLoaded = useWhereby();
|
|
const wherebyRef = useRef<HTMLElement>(null);
|
|
const roomName = details.params.roomName;
|
|
const meeting = useRoomMeeting(roomName);
|
|
const router = useRouter();
|
|
const { isLoading, isAuthenticated, data: session } = useSessionStatus();
|
|
const [showMeetingInfo, setShowMeetingInfo] = useState(false);
|
|
const [room, setRoom] = useState<Room | null>(null);
|
|
const api = useApi();
|
|
|
|
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]);
|
|
|
|
// Fetch room details
|
|
useEffect(() => {
|
|
if (!api || !roomName) return;
|
|
|
|
api.v1RoomsRetrieve({ roomName }).then(setRoom).catch(console.error);
|
|
}, [api, roomName]);
|
|
|
|
const isOwner = session?.user?.id === room?.user_id;
|
|
|
|
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}
|
|
/>
|
|
)}
|
|
{meeting?.response && (
|
|
<>
|
|
<Button
|
|
position="absolute"
|
|
top="56px"
|
|
right={showMeetingInfo ? "320px" : "8px"}
|
|
zIndex={1000}
|
|
colorPalette="blue"
|
|
size="sm"
|
|
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
|
|
leftIcon={<Icon as={FaInfoCircle} />}
|
|
>
|
|
Meeting Info
|
|
</Button>
|
|
{showMeetingInfo && (
|
|
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|