consent skip feature

This commit is contained in:
Igor Loskutov
2025-12-19 16:56:31 -05:00
parent 3929a80665
commit 15afd57ed9
11 changed files with 152 additions and 86 deletions

View File

@@ -26,6 +26,7 @@ import { useRouter } from "next/navigation";
import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
import { NonEmptyString } from "../lib/utils"; import { NonEmptyString } from "../lib/utils";
import { MeetingId } from "../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
@@ -98,7 +99,7 @@ export default function MeetingSelection({
onMeetingSelect(meeting); onMeetingSelect(meeting);
}; };
const handleEndMeeting = async (meetingId: string) => { const handleEndMeeting = async (meetingId: MeetingId) => {
try { try {
await deactivateMeetingMutation.mutateAsync({ await deactivateMeetingMutation.mutateAsync({
params: { params: {

View File

@@ -21,13 +21,11 @@ import DailyIframe, {
} from "@daily-co/daily-js"; } from "@daily-co/daily-js";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { import { useConsentDialog } from "../../lib/consent";
recordingTypeRequiresConsent,
useConsentDialog,
} from "../../lib/consent";
import { useRoomJoinMeeting } from "../../lib/apiHooks"; import { useRoomJoinMeeting } from "../../lib/apiHooks";
import { omit } from "remeda"; import { omit } from "remeda";
import { assertExists } from "../../lib/utils"; import { assertExists } from "../../lib/utils";
import { assertMeetingId } from "../../lib/types";
const CONSENT_BUTTON_ID = "recording-consent"; const CONSENT_BUTTON_ID = "recording-consent";
const RECORDING_INDICATOR_ID = "recording-indicator"; const RECORDING_INDICATOR_ID = "recording-indicator";
@@ -179,21 +177,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const roomName = params?.roomName as string; const roomName = params?.roomName as string;
const needsConsent = const {
meeting.recording_type && showConsentModal,
recordingTypeRequiresConsent(meeting.recording_type) && showRecordingIndicator: showRecordingInTray,
!room.skip_consent; showConsentButton,
const { showConsentModal, consentState, hasAnswered, hasAccepted } = } = useConsentDialog({
useConsentDialog(meeting.id); meetingId: assertMeetingId(meeting.id),
recordingType: meeting.recording_type,
// Show recording indicator when: skipConsent: room.skip_consent,
// - 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 showConsentModalRef = useRef(showConsentModal); const showConsentModalRef = useRef(showConsentModal);
showConsentModalRef.current = showConsentModal; showConsentModalRef.current = showConsentModal;
@@ -293,8 +285,6 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
); );
}, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]); }, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]);
const showConsentButton = needsConsent && !hasAnswered(meeting.id);
useEffect(() => { useEffect(() => {
setCustomTrayButton( setCustomTrayButton(
CONSENT_BUTTON_ID, CONSENT_BUTTON_ID,

View File

@@ -18,6 +18,7 @@ import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils"; import { parseNonEmptyString } from "../../lib/utils";
import { printApiError } from "../../api/_error"; import { printApiError } from "../../api/_error";
import { assertMeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
@@ -67,7 +68,10 @@ export default function RoomContainer(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null, 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; const meeting = explicitMeeting.data || defaultMeeting.response;

View File

@@ -5,13 +5,12 @@ import { useRouter } from "next/navigation";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient"; import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
import { import {
ConsentDialogButton as BaseConsentDialogButton, ConsentDialogButton as BaseConsentDialogButton,
RecordingIndicator, RecordingIndicator,
useConsentDialog, useConsentDialog,
recordingTypeRequiresConsent,
} from "../../lib/consent"; } from "../../lib/consent";
import { assertMeetingId, MeetingId } from "../../lib/types";
type Meeting = components["schemas"]["Meeting"]; type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["RoomDetails"]; type Room = components["schemas"]["RoomDetails"];
@@ -23,9 +22,13 @@ interface WherebyRoomProps {
function WherebyConsentDialogButton({ function WherebyConsentDialogButton({
meetingId, meetingId,
recordingType,
skipConsent,
wherebyRef, wherebyRef,
}: { }: {
meetingId: NonEmptyString; meetingId: MeetingId;
recordingType: Meeting["recording_type"];
skipConsent: boolean;
wherebyRef: React.RefObject<HTMLElement>; wherebyRef: React.RefObject<HTMLElement>;
}) { }) {
const previousFocusRef = useRef<HTMLElement | null>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -48,7 +51,13 @@ function WherebyConsentDialogButton({
}; };
}, [wherebyRef]); }, [wherebyRef]);
return <BaseConsentDialogButton meetingId={meetingId} />; return (
<BaseConsentDialogButton
meetingId={meetingId}
recordingType={recordingType}
skipConsent={skipConsent}
/>
);
} }
export default function WherebyRoom({ meeting, room }: WherebyRoomProps) { export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
@@ -60,9 +69,14 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
const isAuthenticated = status === "authenticated"; const isAuthenticated = status === "authenticated";
const wherebyRoomUrl = getWherebyUrl(meeting); const wherebyRoomUrl = getWherebyUrl(meeting);
const recordingType = meeting.recording_type;
const meetingId = meeting.id; const meetingId = meeting.id;
const { showRecordingIndicator, showConsentButton } = useConsentDialog({
meetingId: assertMeetingId(meetingId),
recordingType: meeting.recording_type,
skipConsent: room.skip_consent,
});
const isLoading = status === "loading"; const isLoading = status === "loading";
const handleLeave = useCallback(() => { const handleLeave = useCallback(() => {
@@ -91,17 +105,15 @@ export default function WherebyRoom({ meeting, room }: WherebyRoomProps) {
room={wherebyRoomUrl} room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }} style={{ width: "100vw", height: "100vh" }}
/> />
{recordingType && {showRecordingIndicator && <RecordingIndicator />}
recordingTypeRequiresConsent(recordingType) && {showConsentButton && (
meetingId && <WherebyConsentDialogButton
(room.skip_consent ? ( meetingId={assertMeetingId(meetingId)}
<RecordingIndicator /> recordingType={meeting.recording_type}
) : ( skipConsent={room.skip_consent}
<WherebyConsentDialogButton wherebyRef={wherebyRef}
meetingId={assertExistsAndNonEmptyString(meetingId)} />
wherebyRef={wherebyRef} )}
/>
))}
</> </>
); );
} }

View File

@@ -6,7 +6,6 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useContext,
RefObject, RefObject,
use, use,
} from "react"; } from "react";
@@ -25,8 +24,6 @@ import { useRecordingConsent } from "../recordingConsentContext";
import { import {
useMeetingAudioConsent, useMeetingAudioConsent,
useRoomGetByName, useRoomGetByName,
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomsCreateMeeting, useRoomsCreateMeeting,
useRoomGetMeeting, useRoomGetMeeting,
} from "../lib/apiHooks"; } from "../lib/apiHooks";
@@ -39,12 +36,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider"; import { useAuth } from "../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../lib/wherebyClient"; import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
import { useError } from "../(errors)/errorContext"; import { useError } from "../(errors)/errorContext";
import { import { parseNonEmptyString } from "../lib/utils";
assertExistsAndNonEmptyString,
NonEmptyString,
parseNonEmptyString,
} from "../lib/utils";
import { printApiError } from "../api/_error"; import { printApiError } from "../api/_error";
import { assertMeetingId, MeetingId } from "../lib/types";
export type RoomDetails = { export type RoomDetails = {
params: Promise<{ params: Promise<{
@@ -92,7 +86,7 @@ const useConsentWherebyFocusManagement = (
}; };
const useConsentDialog = ( const useConsentDialog = (
meetingId: string, meetingId: MeetingId,
wherebyRef: RefObject<HTMLElement> /*accessibility*/, wherebyRef: RefObject<HTMLElement> /*accessibility*/,
) => { ) => {
const { state: consentState, touch, hasAnswered } = useRecordingConsent(); const { state: consentState, touch, hasAnswered } = useRecordingConsent();
@@ -101,7 +95,7 @@ const useConsentDialog = (
const audioConsentMutation = useMeetingAudioConsent(); const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback( const handleConsent = useCallback(
async (meetingId: string, given: boolean) => { async (meetingId: MeetingId, given: boolean) => {
try { try {
await audioConsentMutation.mutateAsync({ await audioConsentMutation.mutateAsync({
params: { params: {
@@ -225,7 +219,7 @@ function ConsentDialogButton({
meetingId, meetingId,
wherebyRef, wherebyRef,
}: { }: {
meetingId: NonEmptyString; meetingId: MeetingId;
wherebyRef: React.RefObject<HTMLElement>; wherebyRef: React.RefObject<HTMLElement>;
}) { }) {
const { showConsentModal, consentState, hasAnswered, consentLoading } = const { showConsentModal, consentState, hasAnswered, consentLoading } =
@@ -284,7 +278,10 @@ export default function Room(details: RoomDetails) {
room && !room.ics_enabled && !pageMeetingId ? roomName : null, 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 const wherebyRoomUrl = explicitMeeting.data
? getWherebyUrl(explicitMeeting.data) ? getWherebyUrl(explicitMeeting.data)
: defaultMeeting.response : defaultMeeting.response
@@ -437,7 +434,7 @@ export default function Room(details: RoomDetails) {
recordingTypeRequiresConsent(recordingType) && recordingTypeRequiresConsent(recordingType) &&
meetingId && ( meetingId && (
<ConsentDialogButton <ConsentDialogButton
meetingId={assertExistsAndNonEmptyString(meetingId)} meetingId={assertMeetingId(meetingId)}
wherebyRef={wherebyRef} wherebyRef={wherebyRef}
/> />
)} )}

View File

@@ -5,6 +5,7 @@ import { useError } from "../(errors)/errorContext";
import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api"; import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider"; 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 * 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( export function useRoomGetMeeting(
roomName: string | null, roomName: string | null,
meetingId: string | null, meetingId: MeetingId | null,
) { ) {
return $api.useQuery( return $api.useQuery(
"get", "get",

View File

@@ -9,16 +9,26 @@ import {
CONSENT_BUTTON_Z_INDEX, CONSENT_BUTTON_Z_INDEX,
CONSENT_DIALOG_TEXT, CONSENT_DIALOG_TEXT,
} from "./constants"; } from "./constants";
import { MeetingId } from "../types";
import type { components } from "../../reflector-api";
interface ConsentDialogButtonProps { type Meeting = components["schemas"]["Meeting"];
meetingId: string;
}
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { type ConsentDialogButtonProps = {
const { showConsentModal, consentState, hasAnswered, consentLoading } = meetingId: MeetingId;
useConsentDialog(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; return null;
} }

View File

@@ -1,10 +1,14 @@
export interface ConsentDialogResult { import { MeetingId } from "../types";
export type ConsentDialogResult = {
showConsentModal: () => void; showConsentModal: () => void;
consentState: { consentState: {
ready: boolean; ready: boolean;
consentForMeetings?: Map<string, boolean>; consentForMeetings?: Map<MeetingId, boolean>;
}; };
hasAnswered: (meetingId: string) => boolean; hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: string) => boolean; hasAccepted: (meetingId: MeetingId) => boolean;
consentLoading: boolean; consentLoading: boolean;
} showRecordingIndicator: boolean;
showConsentButton: boolean;
};

View File

@@ -7,8 +7,23 @@ import { useMeetingAudioConsent } from "../apiHooks";
import { ConsentDialog } from "./ConsentDialog"; import { ConsentDialog } from "./ConsentDialog";
import { TOAST_CHECK_INTERVAL_MS } from "./constants"; import { TOAST_CHECK_INTERVAL_MS } from "./constants";
import type { ConsentDialogResult } from "./types"; 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 { const {
state: consentState, state: consentState,
touch, touch,
@@ -105,11 +120,23 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult {
}); });
}, [handleConsent, modalOpen]); }, [handleConsent, modalOpen]);
const requiresConsent = Boolean(
recordingType && recordingTypeRequiresConsent(recordingType),
);
const showRecordingIndicator =
requiresConsent && (skipConsent || hasAccepted(meetingId));
const showConsentButton =
requiresConsent && !skipConsent && !hasAnswered(meetingId);
return { return {
showConsentModal, showConsentModal,
consentState, consentState,
hasAnswered, hasAnswered,
hasAccepted, hasAccepted,
consentLoading: audioConsentMutation.isPending, consentLoading: audioConsentMutation.isPending,
showRecordingIndicator,
showConsentButton,
}; };
} }

View File

@@ -1,6 +1,10 @@
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import type { JWT } from "next-auth/jwt"; import type { JWT } from "next-auth/jwt";
import { parseMaybeNonEmptyString } from "./utils"; import {
assertExistsAndNonEmptyString,
NonEmptyString,
parseMaybeNonEmptyString,
} from "./utils";
export interface JWTWithAccessToken extends JWT { export interface JWTWithAccessToken extends JWT {
accessToken: string; accessToken: string;
@@ -78,3 +82,10 @@ export const assertCustomSession = <T extends Session>(
export type Mutable<T> = { export type Mutable<T> = {
-readonly [P in keyof T]: T[P]; -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;
};

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { MeetingId } from "./lib/types";
// Map of meetingId -> accepted (true/false) type ConsentMap = Map<MeetingId, boolean>;
type ConsentMap = Map<string, boolean>;
type ConsentContextState = type ConsentContextState =
| { ready: false } | { ready: false }
@@ -14,9 +14,9 @@ type ConsentContextState =
interface RecordingConsentContextValue { interface RecordingConsentContextValue {
state: ConsentContextState; state: ConsentContextState;
touch: (meetingId: string, accepted: boolean) => void; touch: (meetingId: MeetingId, accepted: boolean) => void;
hasAnswered: (meetingId: string) => boolean; hasAnswered: (meetingId: MeetingId) => boolean;
hasAccepted: (meetingId: string) => boolean; hasAccepted: (meetingId: MeetingId) => boolean;
} }
const RecordingConsentContext = createContext< const RecordingConsentContext = createContext<
@@ -39,24 +39,33 @@ interface RecordingConsentProviderProps {
const LOCAL_STORAGE_KEY = "recording_consent_meetings"; 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 // Format: "meetingId|T" or "meetingId|F", legacy format "meetingId" is treated as accepted
const encodeEntry = (meetingId: string, accepted: boolean): string => const encodeEntry = (meetingId: MeetingId, accepted: boolean): Entry =>
`${meetingId}|${accepted ? "T" : "F"}`; `${meetingId}|${accepted ? ACCEPTED : REJECTED}`;
const decodeEntry = ( const decodeEntry = (
entry: string, entry: EntryAndDefault,
): { meetingId: string; accepted: boolean } | null => { ): { meetingId: MeetingId; accepted: boolean } | null => {
if (!entry || typeof entry !== "string") return null; const pipeIndex = entry.lastIndexOf(SEPARATOR);
const pipeIndex = entry.lastIndexOf("|");
if (pipeIndex === -1) { if (pipeIndex === -1) {
// Legacy format: no pipe means accepted (backward compat) // 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 suffix = entry.slice(pipeIndex + 1);
const meetingId = entry.slice(0, pipeIndex); const meetingId = entry.slice(0, pipeIndex) as MeetingId;
if (!meetingId) return null;
// T = accepted, F = rejected, anything else = accepted (safe default) // T = accepted, F = rejected, anything else = accepted (safe default)
const accepted = suffix !== "F"; const accepted = suffix !== REJECTED;
return { meetingId, accepted }; 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) { if (!state.ready) {
console.warn("Attempted to touch consent before context is ready"); console.warn("Attempted to touch consent before context is ready");
return; return;
@@ -90,12 +99,12 @@ export const RecordingConsentProvider: React.FC<
setState({ ready: true, consentForMeetings: newMap }); setState({ ready: true, consentForMeetings: newMap });
}; };
const hasAnswered = (meetingId: string): boolean => { const hasAnswered = (meetingId: MeetingId): boolean => {
if (!state.ready) return false; if (!state.ready) return false;
return state.consentForMeetings.has(meetingId); return state.consentForMeetings.has(meetingId);
}; };
const hasAccepted = (meetingId: string): boolean => { const hasAccepted = (meetingId: MeetingId): boolean => {
if (!state.ready) return false; if (!state.ready) return false;
return state.consentForMeetings.get(meetingId) === true; return state.consentForMeetings.get(meetingId) === true;
}; };
@@ -121,7 +130,7 @@ export const RecordingConsentProvider: React.FC<
return; return;
} }
const consentForMeetings = new Map<string, boolean>(); const consentForMeetings = new Map<MeetingId, boolean>();
for (const entry of parsed) { for (const entry of parsed) {
const decoded = decodeEntry(entry); const decoded = decodeEntry(entry);
if (decoded) { if (decoded) {