consent skip feature

This commit is contained in:
Igor Loskutov
2025-12-19 16:14:28 -05:00
parent a988c3aa92
commit 3929a80665
9 changed files with 158 additions and 66 deletions

View File

@@ -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")

View File

@@ -162,9 +162,7 @@ const useFrame = (
); );
return [ return [
frame_, frame_,
setFrame,
{ {
joined,
setCustomTrayButton, setCustomTrayButton,
}, },
] as const; ] as const;
@@ -181,19 +179,21 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
const roomName = params?.roomName as string; 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 = const needsConsent =
meeting.recording_type && meeting.recording_type &&
recordingTypeRequiresConsent(meeting.recording_type) && recordingTypeRequiresConsent(meeting.recording_type) &&
!room.skip_consent; !room.skip_consent;
const { showConsentModal, consentState, hasConsent } = useConsentDialog( const { showConsentModal, consentState, hasAnswered, hasAccepted } =
meeting.id, 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); const showConsentModalRef = useRef(showConsentModal);
showConsentModalRef.current = 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, onLeftMeeting: handleLeave,
onCustomButtonClick: handleCustomButtonClick, onCustomButtonClick: handleCustomButtonClick,
onJoinMeeting: handleFrameJoinMeeting, onJoinMeeting: handleFrameJoinMeeting,
@@ -293,18 +293,20 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
); );
}, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]); }, [showRecordingInTray, recordingIconUrl, setCustomTrayButton]);
/* const showConsentButton = needsConsent && !hasAnswered(meeting.id);
if (needsConsent && !hasConsent(meeting.id)) {
const iconUrl = new URL("/consent-icon.svg", window.location.origin); useEffect(() => {
frameOptions.customTrayButtons = { setCustomTrayButton(
[CONSENT_BUTTON_ID]: { CONSENT_BUTTON_ID,
iconPath: iconUrl.href, showConsentButton
label: "Consent", ? {
tooltip: "Recording consent - click to respond", iconPath: recordingIconUrl.href,
}, label: "Recording (click to consent)",
}; tooltip: "Recording (click to consent)",
} }
*/ : null,
);
}, [showConsentButton, recordingIconUrl, setCustomTrayButton]);
if (authLastUserId === undefined) { if (authLastUserId === undefined) {
return ( return (

View File

@@ -95,7 +95,7 @@ const useConsentDialog = (
meetingId: string, meetingId: string,
wherebyRef: RefObject<HTMLElement> /*accessibility*/, wherebyRef: RefObject<HTMLElement> /*accessibility*/,
) => { ) => {
const { state: consentState, touch, hasConsent } = useRecordingConsent(); const { state: consentState, touch, hasAnswered } = useRecordingConsent();
// toast would open duplicates, even with using "id=" prop // toast would open duplicates, even with using "id=" prop
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent(); const audioConsentMutation = useMeetingAudioConsent();
@@ -114,7 +114,7 @@ const useConsentDialog = (
}, },
}); });
touch(meetingId); touch(meetingId, given);
} catch (error) { } catch (error) {
console.error("Error submitting consent:", error); console.error("Error submitting consent:", error);
} }
@@ -216,7 +216,7 @@ const useConsentDialog = (
return { return {
showConsentModal, showConsentModal,
consentState, consentState,
hasConsent, hasAnswered,
consentLoading: audioConsentMutation.isPending, consentLoading: audioConsentMutation.isPending,
}; };
}; };
@@ -228,10 +228,10 @@ function ConsentDialogButton({
meetingId: NonEmptyString; meetingId: NonEmptyString;
wherebyRef: React.RefObject<HTMLElement>; wherebyRef: React.RefObject<HTMLElement>;
}) { }) {
const { showConsentModal, consentState, hasConsent, consentLoading } = const { showConsentModal, consentState, hasAnswered, consentLoading } =
useConsentDialog(meetingId, wherebyRef); useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) { if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
return null; return null;
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react"; import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
import { CONSENT_DIALOG_TEXT } from "./constants"; import { CONSENT_DIALOG_TEXT } from "./constants";
@@ -9,6 +10,15 @@ interface ConsentDialogProps {
} }
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) { export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
const [acceptButton, setAcceptButton] = useState<HTMLButtonElement | null>(
null,
);
useEffect(() => {
// Auto-focus accept button so Escape key works (Daily iframe captures keyboard otherwise)
acceptButton?.focus();
}, [acceptButton]);
return ( return (
<Box <Box
p={6} p={6}
@@ -26,7 +36,12 @@ export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
<Button variant="ghost" size="sm" onClick={onReject}> <Button variant="ghost" size="sm" onClick={onReject}>
{CONSENT_DIALOG_TEXT.rejectButton} {CONSENT_DIALOG_TEXT.rejectButton}
</Button> </Button>
<Button colorPalette="primary" size="sm" onClick={onAccept}> <Button
ref={setAcceptButton}
colorPalette="primary"
size="sm"
onClick={onAccept}
>
{CONSENT_DIALOG_TEXT.acceptButton} {CONSENT_DIALOG_TEXT.acceptButton}
</Button> </Button>
</HStack> </HStack>

View File

@@ -15,10 +15,10 @@ interface ConsentDialogButtonProps {
} }
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
const { showConsentModal, consentState, hasConsent, consentLoading } = const { showConsentModal, consentState, hasAnswered, consentLoading } =
useConsentDialog(meetingId); useConsentDialog(meetingId);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) { if (!consentState.ready || hasAnswered(meetingId) || consentLoading) {
return null; return null;
} }

View File

@@ -2,8 +2,9 @@ export interface ConsentDialogResult {
showConsentModal: () => void; showConsentModal: () => void;
consentState: { consentState: {
ready: boolean; ready: boolean;
consentAnsweredForMeetings?: Set<string>; consentForMeetings?: Map<string, boolean>;
}; };
hasConsent: (meetingId: string) => boolean; hasAnswered: (meetingId: string) => boolean;
hasAccepted: (meetingId: string) => boolean;
consentLoading: boolean; consentLoading: boolean;
} }

View File

@@ -9,7 +9,12 @@ import { TOAST_CHECK_INTERVAL_MS } from "./constants";
import type { ConsentDialogResult } from "./types"; import type { ConsentDialogResult } from "./types";
export function useConsentDialog(meetingId: string): ConsentDialogResult { 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 [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent(); const audioConsentMutation = useMeetingAudioConsent();
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -42,7 +47,7 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult {
}, },
}); });
touch(meetingId); touch(meetingId, given);
} catch (error) { } catch (error) {
console.error("Error submitting consent:", error); console.error("Error submitting consent:", error);
} }
@@ -103,7 +108,8 @@ export function useConsentDialog(meetingId: string): ConsentDialogResult {
return { return {
showConsentModal, showConsentModal,
consentState, consentState,
hasConsent, hasAnswered,
hasAccepted,
consentLoading: audioConsentMutation.isPending, consentLoading: audioConsentMutation.isPending,
}; };
} }

View File

@@ -2,17 +2,21 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
// Map of meetingId -> accepted (true/false)
type ConsentMap = Map<string, boolean>;
type ConsentContextState = type ConsentContextState =
| { ready: false } | { ready: false }
| { | {
ready: true; ready: true;
consentAnsweredForMeetings: Set<string>; consentForMeetings: ConsentMap;
}; };
interface RecordingConsentContextValue { interface RecordingConsentContextValue {
state: ConsentContextState; state: ConsentContextState;
touch: (meetingId: string) => void; touch: (meetingId: string, accepted: boolean) => void;
hasConsent: (meetingId: string) => boolean; hasAnswered: (meetingId: string) => boolean;
hasAccepted: (meetingId: string) => boolean;
} }
const RecordingConsentContext = createContext< const RecordingConsentContext = createContext<
@@ -35,81 +39,107 @@ interface RecordingConsentProviderProps {
const LOCAL_STORAGE_KEY = "recording_consent_meetings"; 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< export const RecordingConsentProvider: React.FC<
RecordingConsentProviderProps RecordingConsentProviderProps
> = ({ children }) => { > = ({ children }) => {
const [state, setState] = useState<ConsentContextState>({ ready: false }); const [state, setState] = useState<ConsentContextState>({ ready: false });
const safeWriteToStorage = (meetingIds: string[]): void => { const safeWriteToStorage = (consentMap: ConsentMap): void => {
try { try {
if (typeof window !== "undefined" && window.localStorage) { 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) { } catch (error) {
console.error("Failed to save consent data to localStorage:", 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, accepted: boolean): void => {
const touch = (meetingId: string): 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;
} }
// has success regardless local storage write success: we don't handle that const newMap = new Map(state.consentForMeetings);
// and don't want to crash anything with just consent functionality newMap.set(meetingId, accepted);
const newSet = state.consentAnsweredForMeetings.has(meetingId) safeWriteToStorage(newMap);
? state.consentAnsweredForMeetings setState({ ready: true, consentForMeetings: newMap });
: 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 hasConsent = (meetingId: string): boolean => { const hasAnswered = (meetingId: string): boolean => {
if (!state.ready) return false; 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 // initialize on mount
useEffect(() => { useEffect(() => {
try { try {
if (typeof window === "undefined" || !window.localStorage) { if (typeof window === "undefined" || !window.localStorage) {
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
const stored = localStorage.getItem(LOCAL_STORAGE_KEY); const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!stored) { if (!stored) {
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
console.warn("Invalid consent data format in localStorage, resetting"); console.warn("Invalid consent data format in localStorage, resetting");
setState({ ready: true, consentAnsweredForMeetings: new Set() }); setState({ ready: true, consentForMeetings: new Map() });
return; return;
} }
// pre-historic way of parsing! const consentForMeetings = new Map<string, boolean>();
const consentAnsweredForMeetings = new Set( for (const entry of parsed) {
parsed.filter((id) => !!id && typeof id === "string"), const decoded = decodeEntry(entry);
); if (decoded) {
setState({ ready: true, consentAnsweredForMeetings }); consentForMeetings.set(decoded.meetingId, decoded.accepted);
}
}
setState({ ready: true, consentForMeetings });
} catch (error) { } 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); 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 = { const value: RecordingConsentContextValue = {
state, state,
touch, touch,
hasConsent, hasAnswered,
hasAccepted,
}; };
return ( return (

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ef4444" stroke="none">
<circle cx="12" cy="12" r="8"/>
</svg>

After

Width:  |  Height:  |  Size: 131 B