From 3929a8066553c81fcd4fe591364f57223b5f0d3b Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Fri, 19 Dec 2025 16:14:28 -0500 Subject: [PATCH] consent skip feature --- ...20251217000000_add_skip_consent_to_room.py | 35 ++++++++ www/app/[roomName]/components/DailyRoom.tsx | 50 ++++++----- www/app/[roomName]/room.tsx | 10 +-- www/app/lib/consent/ConsentDialog.tsx | 17 +++- www/app/lib/consent/ConsentDialogButton.tsx | 4 +- www/app/lib/consent/types.ts | 5 +- www/app/lib/consent/useConsentDialog.tsx | 12 ++- www/app/recordingConsentContext.tsx | 88 +++++++++++++------ www/public/recording-icon.svg | 3 + 9 files changed, 158 insertions(+), 66 deletions(-) create mode 100644 server/migrations/versions/20251217000000_add_skip_consent_to_room.py create mode 100644 www/public/recording-icon.svg diff --git a/server/migrations/versions/20251217000000_add_skip_consent_to_room.py b/server/migrations/versions/20251217000000_add_skip_consent_to_room.py new file mode 100644 index 00000000..924c7350 --- /dev/null +++ b/server/migrations/versions/20251217000000_add_skip_consent_to_room.py @@ -0,0 +1,35 @@ +"""add skip_consent to room + +Revision ID: 20251217000000 +Revises: 05f8688d6895 +Create Date: 2025-12-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20251217000000" +down_revision: Union[str, None] = "05f8688d6895" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "skip_consent", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("skip_consent") diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 7e993d86..9af28f0e 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -162,9 +162,7 @@ const useFrame = ( ); return [ frame_, - setFrame, { - joined, setCustomTrayButton, }, ] as const; @@ -181,19 +179,21 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const roomName = params?.roomName as string; - const showRecordingInTray = - meeting.recording_type && - recordingTypeRequiresConsent(meeting.recording_type) && - // users know about recording in case of no-skip-consent from the consent dialog - room.skip_consent; - const needsConsent = meeting.recording_type && recordingTypeRequiresConsent(meeting.recording_type) && !room.skip_consent; - const { showConsentModal, consentState, hasConsent } = useConsentDialog( - meeting.id, - ); + 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 showConsentModalRef = useRef(showConsentModal); showConsentModalRef.current = showConsentModal; @@ -255,7 +255,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { [], ); - const [frame, setFrame, { setCustomTrayButton }] = useFrame(container, { + const [frame, { setCustomTrayButton }] = useFrame(container, { onLeftMeeting: handleLeave, onCustomButtonClick: handleCustomButtonClick, onJoinMeeting: handleFrameJoinMeeting, @@ -293,18 +293,20 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { ); }, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]); - /* - if (needsConsent && !hasConsent(meeting.id)) { - const iconUrl = new URL("/consent-icon.svg", window.location.origin); - frameOptions.customTrayButtons = { - [CONSENT_BUTTON_ID]: { - iconPath: iconUrl.href, - label: "Consent", - tooltip: "Recording consent - click to respond", - }, - }; - } - */ + const showConsentButton = needsConsent && !hasAnswered(meeting.id); + + useEffect(() => { + setCustomTrayButton( + CONSENT_BUTTON_ID, + showConsentButton + ? { + iconPath: recordingIconUrl.href, + label: "Recording (click to consent)", + tooltip: "Recording (click to consent)", + } + : null, + ); + }, [showConsentButton, recordingIconUrl, setCustomTrayButton]); if (authLastUserId === undefined) { return ( diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx index e7b68b42..aeeb9765 100644 --- a/www/app/[roomName]/room.tsx +++ b/www/app/[roomName]/room.tsx @@ -95,7 +95,7 @@ const useConsentDialog = ( meetingId: string, wherebyRef: RefObject /*accessibility*/, ) => { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const { state: consentState, touch, hasAnswered } = useRecordingConsent(); // toast would open duplicates, even with using "id=" prop const [modalOpen, setModalOpen] = useState(false); const audioConsentMutation = useMeetingAudioConsent(); @@ -114,7 +114,7 @@ const useConsentDialog = ( }, }); - touch(meetingId); + touch(meetingId, given); } catch (error) { console.error("Error submitting consent:", error); } @@ -216,7 +216,7 @@ const useConsentDialog = ( return { showConsentModal, consentState, - hasConsent, + hasAnswered, consentLoading: audioConsentMutation.isPending, }; }; @@ -228,10 +228,10 @@ function ConsentDialogButton({ meetingId: NonEmptyString; wherebyRef: React.RefObject; }) { - const { showConsentModal, consentState, hasConsent, consentLoading } = + const { showConsentModal, consentState, hasAnswered, consentLoading } = useConsentDialog(meetingId, wherebyRef); - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + if (!consentState.ready || hasAnswered(meetingId) || consentLoading) { return null; } diff --git a/www/app/lib/consent/ConsentDialog.tsx b/www/app/lib/consent/ConsentDialog.tsx index 488599d0..6dac9102 100644 --- a/www/app/lib/consent/ConsentDialog.tsx +++ b/www/app/lib/consent/ConsentDialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react"; import { CONSENT_DIALOG_TEXT } from "./constants"; @@ -9,6 +10,15 @@ interface ConsentDialogProps { } export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) { + const [acceptButton, setAcceptButton] = useState( + null, + ); + + useEffect(() => { + // Auto-focus accept button so Escape key works (Daily iframe captures keyboard otherwise) + acceptButton?.focus(); + }, [acceptButton]); + return ( {CONSENT_DIALOG_TEXT.rejectButton} - diff --git a/www/app/lib/consent/ConsentDialogButton.tsx b/www/app/lib/consent/ConsentDialogButton.tsx index 2c1d084b..453a2604 100644 --- a/www/app/lib/consent/ConsentDialogButton.tsx +++ b/www/app/lib/consent/ConsentDialogButton.tsx @@ -15,10 +15,10 @@ interface ConsentDialogButtonProps { } export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { - const { showConsentModal, consentState, hasConsent, consentLoading } = + const { showConsentModal, consentState, hasAnswered, consentLoading } = useConsentDialog(meetingId); - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + if (!consentState.ready || hasAnswered(meetingId) || consentLoading) { return null; } diff --git a/www/app/lib/consent/types.ts b/www/app/lib/consent/types.ts index 0bd15202..7a3c6aad 100644 --- a/www/app/lib/consent/types.ts +++ b/www/app/lib/consent/types.ts @@ -2,8 +2,9 @@ export interface ConsentDialogResult { showConsentModal: () => void; consentState: { ready: boolean; - consentAnsweredForMeetings?: Set; + consentForMeetings?: Map; }; - hasConsent: (meetingId: string) => boolean; + hasAnswered: (meetingId: string) => boolean; + hasAccepted: (meetingId: string) => boolean; consentLoading: boolean; } diff --git a/www/app/lib/consent/useConsentDialog.tsx b/www/app/lib/consent/useConsentDialog.tsx index 2a5c0ab3..f21c05f6 100644 --- a/www/app/lib/consent/useConsentDialog.tsx +++ b/www/app/lib/consent/useConsentDialog.tsx @@ -9,7 +9,12 @@ import { TOAST_CHECK_INTERVAL_MS } from "./constants"; import type { ConsentDialogResult } from "./types"; export function useConsentDialog(meetingId: string): ConsentDialogResult { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const { + state: consentState, + touch, + hasAnswered, + hasAccepted, + } = useRecordingConsent(); const [modalOpen, setModalOpen] = useState(false); const audioConsentMutation = useMeetingAudioConsent(); const intervalRef = useRef(null); @@ -42,7 +47,7 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult { }, }); - touch(meetingId); + touch(meetingId, given); } catch (error) { console.error("Error submitting consent:", error); } @@ -103,7 +108,8 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult { return { showConsentModal, consentState, - hasConsent, + hasAnswered, + hasAccepted, consentLoading: audioConsentMutation.isPending, }; } diff --git a/www/app/recordingConsentContext.tsx b/www/app/recordingConsentContext.tsx index b0aef9de..e12fc2db 100644 --- a/www/app/recordingConsentContext.tsx +++ b/www/app/recordingConsentContext.tsx @@ -2,17 +2,21 @@ import React, { createContext, useContext, useEffect, useState } from "react"; +// Map of meetingId -> accepted (true/false) +type ConsentMap = Map; + type ConsentContextState = | { ready: false } | { ready: true; - consentAnsweredForMeetings: Set; + consentForMeetings: ConsentMap; }; interface RecordingConsentContextValue { state: ConsentContextState; - touch: (meetingId: string) => void; - hasConsent: (meetingId: string) => boolean; + touch: (meetingId: string, accepted: boolean) => void; + hasAnswered: (meetingId: string) => boolean; + hasAccepted: (meetingId: string) => boolean; } const RecordingConsentContext = createContext< @@ -35,81 +39,107 @@ interface RecordingConsentProviderProps { const LOCAL_STORAGE_KEY = "recording_consent_meetings"; +// 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 decodeEntry = ( + entry: string, +): { meetingId: string; accepted: boolean } | null => { + if (!entry || typeof entry !== "string") return null; + const pipeIndex = entry.lastIndexOf("|"); + if (pipeIndex === -1) { + // Legacy format: no pipe means accepted (backward compat) + return { meetingId: entry, accepted: true }; + } + const suffix = entry.slice(pipeIndex + 1); + const meetingId = entry.slice(0, pipeIndex); + if (!meetingId) return null; + // T = accepted, F = rejected, anything else = accepted (safe default) + const accepted = suffix !== "F"; + return { meetingId, accepted }; +}; + export const RecordingConsentProvider: React.FC< RecordingConsentProviderProps > = ({ children }) => { const [state, setState] = useState({ ready: false }); - const safeWriteToStorage = (meetingIds: string[]): void => { + const safeWriteToStorage = (consentMap: ConsentMap): void => { try { if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds)); + const entries = Array.from(consentMap.entries()) + .slice(-5) + .map(([id, accepted]) => encodeEntry(id, accepted)); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries)); } } catch (error) { console.error("Failed to save consent data to localStorage:", error); } }; - // writes to local storage and to the state of context both - const touch = (meetingId: string): void => { + const touch = (meetingId: string, accepted: boolean): void => { if (!state.ready) { console.warn("Attempted to touch consent before context is ready"); return; } - // has success regardless local storage write success: we don't handle that - // and don't want to crash anything with just consent functionality - const newSet = state.consentAnsweredForMeetings.has(meetingId) - ? state.consentAnsweredForMeetings - : new Set([...state.consentAnsweredForMeetings, meetingId]); - // note: preserves the set insertion order - const array = Array.from(newSet).slice(-5); // Keep latest 5 - safeWriteToStorage(array); - setState({ ready: true, consentAnsweredForMeetings: newSet }); + const newMap = new Map(state.consentForMeetings); + newMap.set(meetingId, accepted); + safeWriteToStorage(newMap); + setState({ ready: true, consentForMeetings: newMap }); }; - const hasConsent = (meetingId: string): boolean => { + const hasAnswered = (meetingId: string): boolean => { if (!state.ready) return false; - return state.consentAnsweredForMeetings.has(meetingId); + return state.consentForMeetings.has(meetingId); + }; + + const hasAccepted = (meetingId: string): boolean => { + if (!state.ready) return false; + return state.consentForMeetings.get(meetingId) === true; }; // initialize on mount useEffect(() => { try { if (typeof window === "undefined" || !window.localStorage) { - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } const stored = localStorage.getItem(LOCAL_STORAGE_KEY); if (!stored) { - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } const parsed = JSON.parse(stored); if (!Array.isArray(parsed)) { console.warn("Invalid consent data format in localStorage, resetting"); - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); return; } - // pre-historic way of parsing! - const consentAnsweredForMeetings = new Set( - parsed.filter((id) => !!id && typeof id === "string"), - ); - setState({ ready: true, consentAnsweredForMeetings }); + const consentForMeetings = new Map(); + for (const entry of parsed) { + const decoded = decodeEntry(entry); + if (decoded) { + consentForMeetings.set(decoded.meetingId, decoded.accepted); + } + } + setState({ ready: true, consentForMeetings }); } catch (error) { - // we don't want to fail the page here; the component is not essential. console.error("Failed to parse consent data from localStorage:", error); - setState({ ready: true, consentAnsweredForMeetings: new Set() }); + setState({ ready: true, consentForMeetings: new Map() }); } }, []); const value: RecordingConsentContextValue = { state, touch, - hasConsent, + hasAnswered, + hasAccepted, }; return ( diff --git a/www/public/recording-icon.svg b/www/public/recording-icon.svg new file mode 100644 index 00000000..b7d544c1 --- /dev/null +++ b/www/public/recording-icon.svg @@ -0,0 +1,3 @@ + + +