mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49: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 [
|
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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
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