diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx index 2780acbd..63743668 100644 --- a/www/app/[roomName]/MeetingSelection.tsx +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -26,6 +26,7 @@ import { useRouter } from "next/navigation"; import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; import { NonEmptyString } from "../lib/utils"; +import { MeetingId } from "../lib/types"; type Meeting = components["schemas"]["Meeting"]; @@ -98,7 +99,7 @@ export default function MeetingSelection({ onMeetingSelect(meeting); }; - const handleEndMeeting = async (meetingId: string) => { + const handleEndMeeting = async (meetingId: MeetingId) => { try { await deactivateMeetingMutation.mutateAsync({ params: { diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 9af28f0e..44fa6315 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -21,13 +21,11 @@ import DailyIframe, { } from "@daily-co/daily-js"; import type { components } from "../../reflector-api"; import { useAuth } from "../../lib/AuthProvider"; -import { - recordingTypeRequiresConsent, - useConsentDialog, -} from "../../lib/consent"; +import { useConsentDialog } from "../../lib/consent"; import { useRoomJoinMeeting } from "../../lib/apiHooks"; import { omit } from "remeda"; import { assertExists } from "../../lib/utils"; +import { assertMeetingId } from "../../lib/types"; const CONSENT_BUTTON_ID = "recording-consent"; const RECORDING_INDICATOR_ID = "recording-indicator"; @@ -179,21 +177,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const roomName = params?.roomName as string; - const needsConsent = - meeting.recording_type && - recordingTypeRequiresConsent(meeting.recording_type) && - !room.skip_consent; - const { showConsentModal, consentState, hasAnswered, hasAccepted } = - useConsentDialog(meeting.id); - - // Show recording indicator when: - // - skip_consent=true, OR - // - user has accepted consent - // If user rejects, recording still happens but we don't show indicator - const showRecordingInTray = - meeting.recording_type && - recordingTypeRequiresConsent(meeting.recording_type) && - (room.skip_consent || hasAccepted(meeting.id)); + const { + showConsentModal, + showRecordingIndicator: showRecordingInTray, + showConsentButton, + } = useConsentDialog({ + meetingId: assertMeetingId(meeting.id), + recordingType: meeting.recording_type, + skipConsent: room.skip_consent, + }); const showConsentModalRef = useRef(showConsentModal); showConsentModalRef.current = showConsentModal; @@ -293,8 +285,6 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { ); }, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]); - const showConsentButton = needsConsent && !hasAnswered(meeting.id); - useEffect(() => { setCustomTrayButton( CONSENT_BUTTON_ID, diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx index 4023cc69..88a5210f 100644 --- a/www/app/[roomName]/components/RoomContainer.tsx +++ b/www/app/[roomName]/components/RoomContainer.tsx @@ -18,6 +18,7 @@ import { useAuth } from "../../lib/AuthProvider"; import { useError } from "../../(errors)/errorContext"; import { parseNonEmptyString } from "../../lib/utils"; import { printApiError } from "../../api/_error"; +import { assertMeetingId } from "../../lib/types"; type Meeting = components["schemas"]["Meeting"]; @@ -67,7 +68,10 @@ export default function RoomContainer(details: RoomDetails) { room && !room.ics_enabled && !pageMeetingId ? roomName : null, ); - const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + const explicitMeeting = useRoomGetMeeting( + roomName, + pageMeetingId ? assertMeetingId(pageMeetingId) : null, + ); const meeting = explicitMeeting.data || defaultMeeting.response; diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx index 508e3ed0..6fbc9eda 100644 --- a/www/app/[roomName]/components/WherebyRoom.tsx +++ b/www/app/[roomName]/components/WherebyRoom.tsx @@ -5,13 +5,12 @@ import { useRouter } from "next/navigation"; import type { components } from "../../reflector-api"; import { useAuth } from "../../lib/AuthProvider"; import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient"; -import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils"; import { ConsentDialogButton as BaseConsentDialogButton, RecordingIndicator, useConsentDialog, - recordingTypeRequiresConsent, } from "../../lib/consent"; +import { assertMeetingId, MeetingId } from "../../lib/types"; type Meeting = components["schemas"]["Meeting"]; type Room = components["schemas"]["RoomDetails"]; @@ -23,9 +22,13 @@ interface WherebyRoomProps { function WherebyConsentDialogButton({ meetingId, + recordingType, + skipConsent, wherebyRef, }: { - meetingId: NonEmptyString; + meetingId: MeetingId; + recordingType: Meeting["recording_type"]; + skipConsent: boolean; wherebyRef: React.RefObject; }) { const previousFocusRef = useRef(null); @@ -48,7 +51,13 @@ function WherebyConsentDialogButton({ }; }, [wherebyRef]); - return ; + return ( + + ); } export default function WherebyRoom({ meeting, room }: WherebyRoomProps) { @@ -60,9 +69,14 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) { const isAuthenticated = status === "authenticated"; const wherebyRoomUrl = getWherebyUrl(meeting); - const recordingType = meeting.recording_type; const meetingId = meeting.id; + const { showRecordingIndicator, showConsentButton } = useConsentDialog({ + meetingId: assertMeetingId(meetingId), + recordingType: meeting.recording_type, + skipConsent: room.skip_consent, + }); + const isLoading = status === "loading"; const handleLeave = useCallback(() => { @@ -91,17 +105,15 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) { room={wherebyRoomUrl} style={{ width: "100vw", height: "100vh" }} /> - {recordingType && - recordingTypeRequiresConsent(recordingType) && - meetingId && - (room.skip_consent ? ( - - ) : ( - - ))} + {showRecordingIndicator && } + {showConsentButton && ( + + )} ); } diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx index aeeb9765..2c9280e7 100644 --- a/www/app/[roomName]/room.tsx +++ b/www/app/[roomName]/room.tsx @@ -6,7 +6,6 @@ import { useEffect, useRef, useState, - useContext, RefObject, use, } from "react"; @@ -25,8 +24,6 @@ import { useRecordingConsent } from "../recordingConsentContext"; import { useMeetingAudioConsent, useRoomGetByName, - useRoomActiveMeetings, - useRoomUpcomingMeetings, useRoomsCreateMeeting, useRoomGetMeeting, } from "../lib/apiHooks"; @@ -39,12 +36,9 @@ 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 { parseNonEmptyString } from "../lib/utils"; import { printApiError } from "../api/_error"; +import { assertMeetingId, MeetingId } from "../lib/types"; export type RoomDetails = { params: Promise<{ @@ -92,7 +86,7 @@ const useConsentWherebyFocusManagement = ( }; const useConsentDialog = ( - meetingId: string, + meetingId: MeetingId, wherebyRef: RefObject /*accessibility*/, ) => { const { state: consentState, touch, hasAnswered } = useRecordingConsent(); @@ -101,7 +95,7 @@ const useConsentDialog = ( const audioConsentMutation = useMeetingAudioConsent(); const handleConsent = useCallback( - async (meetingId: string, given: boolean) => { + async (meetingId: MeetingId, given: boolean) => { try { await audioConsentMutation.mutateAsync({ params: { @@ -225,7 +219,7 @@ function ConsentDialogButton({ meetingId, wherebyRef, }: { - meetingId: NonEmptyString; + meetingId: MeetingId; wherebyRef: React.RefObject; }) { const { showConsentModal, consentState, hasAnswered, consentLoading } = @@ -284,7 +278,10 @@ export default function Room(details: RoomDetails) { room && !room.ics_enabled && !pageMeetingId ? roomName : null, ); - const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + const explicitMeeting = useRoomGetMeeting( + roomName, + pageMeetingId ? assertMeetingId(pageMeetingId) : null, + ); const wherebyRoomUrl = explicitMeeting.data ? getWherebyUrl(explicitMeeting.data) : defaultMeeting.response @@ -437,7 +434,7 @@ export default function Room(details: RoomDetails) { recordingTypeRequiresConsent(recordingType) && meetingId && ( )} diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 726e5441..a59c31eb 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -5,6 +5,7 @@ import { useError } from "../(errors)/errorContext"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import type { components } from "../reflector-api"; import { useAuth } from "./AuthProvider"; +import { MeetingId } from "./types"; /* * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other @@ -718,7 +719,7 @@ export function useRoomActiveMeetings(roomName: string | null) { export function useRoomGetMeeting( roomName: string | null, - meetingId: string | null, + meetingId: MeetingId | null, ) { return $api.useQuery( "get", diff --git a/www/app/lib/consent/ConsentDialogButton.tsx b/www/app/lib/consent/ConsentDialogButton.tsx index 453a2604..2f192331 100644 --- a/www/app/lib/consent/ConsentDialogButton.tsx +++ b/www/app/lib/consent/ConsentDialogButton.tsx @@ -9,16 +9,26 @@ import { CONSENT_BUTTON_Z_INDEX, CONSENT_DIALOG_TEXT, } from "./constants"; +import { MeetingId } from "../types"; +import type { components } from "../../reflector-api"; -interface ConsentDialogButtonProps { - meetingId: string; -} +type Meeting = components["schemas"]["Meeting"]; -export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { - const { showConsentModal, consentState, hasAnswered, consentLoading } = - useConsentDialog(meetingId); +type ConsentDialogButtonProps = { + meetingId: MeetingId; + recordingType: Meeting["recording_type"]; + skipConsent: boolean; +}; - if (!consentState.ready || hasAnswered(meetingId) || consentLoading) { +export function ConsentDialogButton({ + meetingId, + recordingType, + skipConsent, +}: ConsentDialogButtonProps) { + const { showConsentModal, consentState, showConsentButton, consentLoading } = + useConsentDialog({ meetingId, recordingType, skipConsent }); + + if (!consentState.ready || !showConsentButton || consentLoading) { return null; } diff --git a/www/app/lib/consent/types.ts b/www/app/lib/consent/types.ts index 7a3c6aad..62446805 100644 --- a/www/app/lib/consent/types.ts +++ b/www/app/lib/consent/types.ts @@ -1,10 +1,14 @@ -export interface ConsentDialogResult { +import { MeetingId } from "../types"; + +export type ConsentDialogResult = { showConsentModal: () => void; consentState: { ready: boolean; - consentForMeetings?: Map; + consentForMeetings?: Map; }; - hasAnswered: (meetingId: string) => boolean; - hasAccepted: (meetingId: string) => boolean; + hasAnswered: (meetingId: MeetingId) => boolean; + hasAccepted: (meetingId: MeetingId) => boolean; consentLoading: boolean; -} + showRecordingIndicator: boolean; + showConsentButton: boolean; +}; diff --git a/www/app/lib/consent/useConsentDialog.tsx b/www/app/lib/consent/useConsentDialog.tsx index f21c05f6..ea13ba5d 100644 --- a/www/app/lib/consent/useConsentDialog.tsx +++ b/www/app/lib/consent/useConsentDialog.tsx @@ -7,8 +7,23 @@ import { useMeetingAudioConsent } from "../apiHooks"; import { ConsentDialog } from "./ConsentDialog"; import { TOAST_CHECK_INTERVAL_MS } from "./constants"; import type { ConsentDialogResult } from "./types"; +import { MeetingId } from "../types"; +import { recordingTypeRequiresConsent } from "./utils"; +import type { components } from "../../reflector-api"; -export function useConsentDialog(meetingId: string): ConsentDialogResult { +type Meeting = components["schemas"]["Meeting"]; + +type UseConsentDialogParams = { + meetingId: MeetingId; + recordingType: Meeting["recording_type"]; + skipConsent: boolean; +}; + +export function useConsentDialog({ + meetingId, + recordingType, + skipConsent, +}: UseConsentDialogParams): ConsentDialogResult { const { state: consentState, touch, @@ -105,11 +120,23 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult { }); }, [handleConsent, modalOpen]); + const requiresConsent = Boolean( + recordingType && recordingTypeRequiresConsent(recordingType), + ); + + const showRecordingIndicator = + requiresConsent && (skipConsent || hasAccepted(meetingId)); + + const showConsentButton = + requiresConsent && !skipConsent && !hasAnswered(meetingId); + return { showConsentModal, consentState, hasAnswered, hasAccepted, consentLoading: audioConsentMutation.isPending, + showRecordingIndicator, + showConsentButton, }; } diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 7bcb522b..c5ab8ce7 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,6 +1,10 @@ import type { Session } from "next-auth"; import type { JWT } from "next-auth/jwt"; -import { parseMaybeNonEmptyString } from "./utils"; +import { + assertExistsAndNonEmptyString, + NonEmptyString, + parseMaybeNonEmptyString, +} from "./utils"; export interface JWTWithAccessToken extends JWT { accessToken: string; @@ -78,3 +82,10 @@ export const assertCustomSession = ( export type Mutable = { -readonly [P in keyof T]: T[P]; }; + +export type MeetingId = NonEmptyString & { __type: "MeetingId" }; +export const assertMeetingId = (s: string): MeetingId => { + const nes = assertExistsAndNonEmptyString(s); + // just cast for now + return nes as MeetingId; +}; diff --git a/www/app/recordingConsentContext.tsx b/www/app/recordingConsentContext.tsx index e12fc2db..0b39b882 100644 --- a/www/app/recordingConsentContext.tsx +++ b/www/app/recordingConsentContext.tsx @@ -1,9 +1,9 @@ "use client"; import React, { createContext, useContext, useEffect, useState } from "react"; +import { MeetingId } from "./lib/types"; -// Map of meetingId -> accepted (true/false) -type ConsentMap = Map; +type ConsentMap = Map; type ConsentContextState = | { ready: false } @@ -14,9 +14,9 @@ type ConsentContextState = interface RecordingConsentContextValue { state: ConsentContextState; - touch: (meetingId: string, accepted: boolean) => void; - hasAnswered: (meetingId: string) => boolean; - hasAccepted: (meetingId: string) => boolean; + touch: (meetingId: MeetingId, accepted: boolean) => void; + hasAnswered: (meetingId: MeetingId) => boolean; + hasAccepted: (meetingId: MeetingId) => boolean; } const RecordingConsentContext = createContext< @@ -39,24 +39,33 @@ interface RecordingConsentProviderProps { const LOCAL_STORAGE_KEY = "recording_consent_meetings"; +const ACCEPTED = "T" as const; +type Accepted = typeof ACCEPTED; +const REJECTED = "F" as const; +type Rejected = typeof REJECTED; +type Consent = Accepted | Rejected; +const SEPARATOR = "|" as const; +type Separator = typeof SEPARATOR; +const DEFAULT_CONSENT = ACCEPTED; +type Entry = `${MeetingId}${Separator}${Consent}`; +type EntryAndDefault = Entry | MeetingId; + // Format: "meetingId|T" or "meetingId|F", legacy format "meetingId" is treated as accepted -const encodeEntry = (meetingId: string, accepted: boolean): string => - `${meetingId}|${accepted ? "T" : "F"}`; +const encodeEntry = (meetingId: MeetingId, accepted: boolean): Entry => + `${meetingId}|${accepted ? ACCEPTED : REJECTED}`; const decodeEntry = ( - entry: string, -): { meetingId: string; accepted: boolean } | null => { - if (!entry || typeof entry !== "string") return null; - const pipeIndex = entry.lastIndexOf("|"); + entry: EntryAndDefault, +): { meetingId: MeetingId; accepted: boolean } | null => { + const pipeIndex = entry.lastIndexOf(SEPARATOR); if (pipeIndex === -1) { // Legacy format: no pipe means accepted (backward compat) - return { meetingId: entry, accepted: true }; + return { meetingId: entry as MeetingId, accepted: true }; } const suffix = entry.slice(pipeIndex + 1); - const meetingId = entry.slice(0, pipeIndex); - if (!meetingId) return null; + const meetingId = entry.slice(0, pipeIndex) as MeetingId; // T = accepted, F = rejected, anything else = accepted (safe default) - const accepted = suffix !== "F"; + const accepted = suffix !== REJECTED; return { meetingId, accepted }; }; @@ -78,7 +87,7 @@ export const RecordingConsentProvider: React.FC< } }; - const touch = (meetingId: string, accepted: boolean): void => { + const touch = (meetingId: MeetingId, accepted: boolean): void => { if (!state.ready) { console.warn("Attempted to touch consent before context is ready"); return; @@ -90,12 +99,12 @@ export const RecordingConsentProvider: React.FC< setState({ ready: true, consentForMeetings: newMap }); }; - const hasAnswered = (meetingId: string): boolean => { + const hasAnswered = (meetingId: MeetingId): boolean => { if (!state.ready) return false; return state.consentForMeetings.has(meetingId); }; - const hasAccepted = (meetingId: string): boolean => { + const hasAccepted = (meetingId: MeetingId): boolean => { if (!state.ready) return false; return state.consentForMeetings.get(meetingId) === true; }; @@ -121,7 +130,7 @@ export const RecordingConsentProvider: React.FC< return; } - const consentForMeetings = new Map(); + const consentForMeetings = new Map(); for (const entry of parsed) { const decoded = decodeEntry(entry); if (decoded) {