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 [
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 (

View File

@@ -95,7 +95,7 @@ const useConsentDialog = (
meetingId: string,
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
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<HTMLElement>;
}) {
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;
}

View File

@@ -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<HTMLButtonElement | null>(
null,
);
useEffect(() => {
// Auto-focus accept button so Escape key works (Daily iframe captures keyboard otherwise)
acceptButton?.focus();
}, [acceptButton]);
return (
<Box
p={6}
@@ -26,7 +36,12 @@ export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
<Button variant="ghost" size="sm" onClick={onReject}>
{CONSENT_DIALOG_TEXT.rejectButton}
</Button>
<Button colorPalette="primary" size="sm" onClick={onAccept}>
<Button
ref={setAcceptButton}
colorPalette="primary"
size="sm"
onClick={onAccept}
>
{CONSENT_DIALOG_TEXT.acceptButton}
</Button>
</HStack>

View File

@@ -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;
}

View File

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

View File

@@ -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<NodeJS.Timeout | null>(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,
};
}

View File

@@ -2,17 +2,21 @@
import React, { createContext, useContext, useEffect, useState } from "react";
// Map of meetingId -> accepted (true/false)
type ConsentMap = Map<string, boolean>;
type ConsentContextState =
| { ready: false }
| {
ready: true;
consentAnsweredForMeetings: Set<string>;
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<ConsentContextState>({ 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<string, boolean>();
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 (

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