mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 04:39:06 +00:00
consent skip feature
This commit is contained in:
@@ -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")
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
3
www/public/recording-icon.svg
Normal file
3
www/public/recording-icon.svg
Normal 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 |
Reference in New Issue
Block a user