Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/video_conferences/+page.svelte
Scott Idem 2a5adda6cb idaa/video_conferences: restrict invite button to trusted_access staff only
The Jitsi invite dialog can expose backend room URLs and paths.
Previously invite was gated on is_moderator (any Novi group moderator).

Now restricted to $ae_loc.trusted_access (IDAA staff in Aether) so
regular member moderators cannot send invites. All other toolbar
buttons are unchanged.
2026-04-02 13:27:05 -04:00

1347 lines
54 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
import { idaa_loc } from '$lib/stores/ae_idaa_stores';
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
import {
create_ae_obj__activity_log,
update_ae_obj__activity_log
} from '$lib/ae_core/ae_core__activity_log';
let log_lvl: number = $state(0);
interface Props {
data: any;
}
let { data }: Props = $props();
// Component UI State
let show_jitsi_container: boolean = $state(true);
let show_jitsi_tools: boolean = $state(true);
let expand_jitsi_tools: boolean = $state(false);
let show_meeting_details: boolean = $state(false);
let show_live_stats: boolean = $state(false);
let show_profile_editor: boolean = $state(false);
let show_sound_settings: boolean = $state(false);
// User & Meeting State
let user_id: null | string = $state(null);
let display_name: null | string = $state(null);
let email: null | string = $state(null);
let is_moderator: boolean = $state(false);
let room_name: null | string = $state(null);
let domain: null | string = $state(null);
// Jitsi API & Sound Settings
let jitsi_api: any = $state(null);
const jitsi_container_id = 'jitsi_meet_external_api_container';
let disable_incoming_msg_sound: boolean = $state(true);
let disable_participant_joined_sound: boolean = $state(false);
let disable_participant_left_sound: boolean = $state(false);
let disable_reaction_sound: boolean = $state(true);
let disable_raise_hand_sound: boolean = $state(true);
let name_input: string = $state('');
let email_input: string = $state('');
// --- NEW Logging & Stats State ---
let jitsi_meeting_id: string | null = $state(null);
let primary_activity_log_id: string | null = $state(null);
let meeting_participants = $state(new Map<string, any>());
let meeting_start_time: Date | null = $state(null);
let meeting_duration: string = $state('00:00:00');
let duration_timer_id: any = $state(null);
// reporting_timer_id is now removed
// Breakout modal — lets users open the meeting outside the Novi iframe
let show_breakout_modal: boolean = $state(false);
let breakout_link_copied: boolean = $state(false);
function copy_meeting_link() {
navigator.clipboard.writeText($page.url.href).then(() => {
breakout_link_copied = true;
setTimeout(() => (breakout_link_copied = false), 2000);
});
}
/**
* Creates a new activity log entry for a discrete event (e.g., raise hand).
*/
async function create_discrete_activity_log(
action: string,
action_with: string,
meta_kv: object
) {
if (!is_moderator || !jitsi_meeting_id) return;
if (log_lvl)
console.log(
`Jitsi: Creating discrete activity log for action: ${action}`
);
const data_kv = {
external_client_id: jitsi_meeting_id,
name: 'jitsi_meeting_event',
description: `Event in room: ${room_name}`,
url_root: $page.url.origin,
url_full_path: $page.url.pathname,
url_params: $page.url.searchParams.toString(),
action: action,
action_with: action_with,
meta_json: meta_kv
};
try {
await create_ae_obj__activity_log({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: data_kv,
log_lvl: log_lvl
});
} catch (error) {
console.error(
`Jitsi: Error creating discrete activity log for ${action}:`,
error
);
}
}
/**
* Updates the primary activity log entry with the latest meeting state.
*/
async function update_primary_activity_log() {
if (!is_moderator || !primary_activity_log_id) return;
if (log_lvl)
console.log(
`Jitsi: Updating primary activity log: ${primary_activity_log_id}`
);
const participants_array = Array.from(meeting_participants.values());
const data_kv = {
meta_json: {
duration: meeting_duration,
participants: participants_array,
participant_count: participants_array.length
}
};
try {
await update_ae_obj__activity_log({
api_cfg: $ae_api,
activity_log_id: primary_activity_log_id,
data_kv: data_kv,
log_lvl: log_lvl
});
} catch (error) {
console.error('Jitsi: Error updating primary activity log:', error);
}
}
function add_jitsi_event_listeners(api: any) {
// --- Meeting/Participant State Changes ---
api.on(
'videoConferenceJoined',
async (jitsi_data: {
id: string;
displayName: string;
roomName: string;
}) => {
// Map Jitsi's camelCase to our internal snake_case
const {
id: participant_id,
displayName: participant_name,
roomName: jitsi_room_name
} = jitsi_data;
console.log('Jitsi Event: videoConferenceJoined', jitsi_data);
meeting_start_time = new Date();
if (jitsi_meeting_id === null)
jitsi_meeting_id = `${jitsi_room_name}-${Date.now()}`;
// Start duration timer
if (duration_timer_id) clearInterval(duration_timer_id);
duration_timer_id = setInterval(() => {
if (meeting_start_time) {
const now = new Date();
const diff = now.getTime() - meeting_start_time.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60))
.toString()
.padStart(2, '0');
const minutes = Math.floor((diff / (1000 * 60)) % 60)
.toString()
.padStart(2, '0');
const seconds = Math.floor((diff / 1000) % 60)
.toString()
.padStart(2, '0');
meeting_duration = `${hours}:${minutes}:${seconds}`;
}
}, 1000);
// Populate initial participant list
meeting_participants.set(participant_id, {
id: participant_id,
displayName: participant_name,
role: 'participant'
});
api.getParticipantsInfo().forEach(
(p: { participantId: string; displayName: string }) => {
if (!meeting_participants.has(p.participantId)) {
meeting_participants.set(p.participantId, {
id: p.participantId,
displayName: p.displayName,
role: 'participant'
});
}
}
);
// --- CREATE INITIAL LOG ENTRY (if moderator) ---
if (is_moderator && !primary_activity_log_id) {
console.log(
'Jitsi: Moderator joined, creating initial activity log...'
);
const participants_array = Array.from(
meeting_participants.values()
);
const data_kv = {
external_client_id: jitsi_meeting_id,
name: 'jitsi_meeting_stats',
description: room_name,
url_root: $page.url.origin,
url_full_path: $page.url.pathname,
url_params: $page.url.searchParams.toString(),
action: 'jitsi_meeting_init',
action_with: 'on_init',
meta_json: {
duration: meeting_duration,
participants: participants_array,
participant_count: participants_array.length,
// Verified Novi UUID of the moderator who started this log
moderator_novi_uuid: $idaa_loc.novi_uuid ?? null
}
};
try {
const result = await create_ae_obj__activity_log({
api_cfg: $ae_api,
account_id: $ae_loc.account_id,
data_kv: data_kv,
log_lvl: log_lvl
});
if (result && result.activity_log_id_random) {
primary_activity_log_id = result.activity_log_id_random;
console.log(
`Jitsi: Initial activity log created: ${primary_activity_log_id}`
);
}
} catch (error) {
console.error(
'Jitsi: Error creating initial activity log:',
error
);
}
}
}
);
api.on(
'participantJoined',
(participant: { id: string; displayName: string }) => {
console.log('Jitsi Event: participantJoined', participant);
meeting_participants.set(participant.id, {
id: participant.id,
displayName: participant.displayName,
role: 'participant'
});
update_primary_activity_log();
// NOTE: We also want to log this as a discrete event
create_discrete_activity_log(
'jitsi_meeting_participant_joined',
'participantJoined',
{
attendee_id: participant.id,
full_name: participant.displayName
}
);
}
);
api.on('participantLeft', (participant: { id: string }) => {
console.log('Jitsi Event: participantLeft', participant);
// Capture name before removing from map — it won't be available after delete
const p_info = meeting_participants.get(participant.id);
if (p_info) {
meeting_participants.delete(participant.id);
update_primary_activity_log();
create_discrete_activity_log(
'jitsi_meeting_participant_left',
'participantLeft',
{
attendee_id: participant.id,
full_name: p_info.displayName
}
);
}
});
api.on(
'participantRoleChanged',
(participant: { id: string; role: string }) => {
console.log('Jitsi Event: participantRoleChanged', participant);
if (meeting_participants.has(participant.id)) {
const p = meeting_participants.get(participant.id);
p.role = participant.role;
meeting_participants.set(participant.id, p);
update_primary_activity_log();
}
}
);
// --- Discrete Event Logging ---
api.on(
'raiseHandUpdated',
(participant: { id: string; raisesHand: boolean }) => {
if (participant.raisesHand) {
const p_info = meeting_participants.get(participant.id);
if (p_info) {
console.log('Jitsi Event: raiseHandUpdated', p_info);
create_discrete_activity_log(
'jitsi_meeting_raise_hand',
'raiseHandUpdated',
{
attendee_id: p_info.id,
full_name: p_info.displayName
}
);
}
}
}
);
api.on('videoConferenceLeft', () => {
console.log('Jitsi Event: videoConferenceLeft');
if (duration_timer_id) clearInterval(duration_timer_id);
// Do a final update to the primary log so it captures the true end state,
// then log the meeting end as a discrete event for the timeline.
if (is_moderator && primary_activity_log_id) {
update_primary_activity_log();
create_discrete_activity_log(
'jitsi_meeting_end',
'videoConferenceLeft',
{
final_duration: meeting_duration,
final_participant_count: meeting_participants.size
}
);
}
});
api.on('readyToClose', () => {
console.log('Jitsi Event: readyToClose');
show_jitsi_container = false;
});
}
async function handle_profile_update() {
const name_changed =
name_input && name_input.trim() !== '' && name_input !== display_name;
const email_changed =
email_input && email_input.trim() !== '' && email_input !== email;
if (name_changed || email_changed) {
console.log(`Jitsi: User updating profile.`);
if (name_changed) {
console.log(` - Name from "${display_name}" to "${name_input}"`);
display_name = name_input.trim();
}
if (email_changed) {
console.log(` - Email from "${email}" to "${email_input}"`);
email = email_input.trim();
}
await init_jitsi();
} else {
console.log('Jitsi: Profile update skipped. No changes detected.');
}
}
async function get_jitsi_jwt(
display_name: string,
email: string,
is_moderator: boolean,
room_name: string,
user_id: string
) {
const token_endpoint = $ae_loc.site_cfg_json?.jitsi_token_endpoint;
const payload = {
name: display_name,
email: email,
is_moderator: is_moderator,
room: room_name,
user: {
id: user_id,
name: display_name,
email: email,
moderator: is_moderator
},
features: {
livestreaming: false,
recording: false,
transcription: false,
'outbound-call': false
},
settings: {
startMuted: data.params.start_muted === 'true',
startHidden: data.params.start_hidden === 'true',
reactionsMuted: disable_reaction_sound
},
config: {
'prejoinConfig.enabled': false
}
};
try {
const response = await fetch(token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok)
throw new Error('Failed to fetch JWT token from the server.');
const result = await response.json();
return result.token;
} catch (err) {
console.error('Error getting JWT:', err);
return null;
}
}
async function fetch_novi_data() {
const url_params = data.params;
// --- Start with fallback data from URL ---
user_id = url_params.uuid; // Novi Customer GUID
display_name = url_params.full_name ?? 'Guest'; // May be overridden
email = (url_params.email ?? 'guest@example.com').replace(/\s+/g, '+'); // May be overridden
is_moderator = url_params.moderator === 'true'; // URL fallback
room_name = url_params.room ?? 'Default-Room';
domain = url_params.domain ?? 'jitsi.dgrzone.com';
// Override sound defaults from URL params only when explicitly provided.
// If the param is absent (null), keep the $state defaults — the iframe template
// does not pass these params, so unconditional assignment would wipe out the defaults.
if (url_params.incoming_msg_sound !== null)
disable_incoming_msg_sound = url_params.incoming_msg_sound === 'true';
if (url_params.participant_joined_sound !== null)
disable_participant_joined_sound = url_params.participant_joined_sound === 'true';
if (url_params.participant_left_sound !== null)
disable_participant_left_sound = url_params.participant_left_sound === 'true';
if (url_params.reaction_sound !== null)
disable_reaction_sound = url_params.reaction_sound === 'true';
if (url_params.raise_hand_sound !== null)
disable_raise_hand_sound = url_params.raise_hand_sound === 'true';
if (log_lvl) {
console.log(
`Jitsi: Initial data: user_id: ${user_id}, display_name: ${display_name}, email: ${email}, room_name: ${room_name}, domain: ${domain}`
);
}
if (!user_id) {
const container = document.getElementById(jitsi_container_id);
if (container) {
container.innerHTML =
'<h1>User ID (uuid) is missing. Cannot start meeting.</h1>';
}
console.error(
'Jitsi: User ID (uuid) is missing. Cannot start meeting.'
);
return;
}
// --- Fetch dynamic data from Novi API ---
const novi_api_root_url = $ae_loc.site_cfg_json?.novi_api_root_url;
const novi_api_key = $ae_loc.site_cfg_json?.novi_idaa_api_key;
if (novi_api_root_url && novi_api_key) {
// If the IDAA layout already verified this UUID, re-use those results rather than
// making a duplicate Novi API call. The layout runs on every IDAA route, so by the
// time onMount fires here it will usually have completed verification already.
if ($idaa_loc.novi_verified && $idaa_loc.novi_email) {
display_name = $idaa_loc.novi_full_name ?? display_name;
email = $idaa_loc.novi_email ?? email;
console.log(
`Jitsi: Using layout-verified Novi data for user ${user_id}`
);
} else {
const member_details = await get_novi_member_details(
user_id,
novi_api_root_url,
novi_api_key
);
if (member_details.display_name) {
display_name = member_details.display_name;
console.log(
`Jitsi: Updated display_name from Novi: ${display_name}`
);
}
if (member_details.email) {
email = member_details.email;
console.log(`Jitsi: Updated email from Novi: ${email}`);
}
}
// Trusted/admin users are always moderators. Check the UUID directly against the
// known lists rather than $ae_loc.trusted_access — that flag is only upgraded, never
// downgraded, so it sticks across Novi impersonation (which does a full iframe reload
// with a different UUID but doesn't reset the inherited access level).
const admin_li: string[] = $idaa_loc.novi_admin_li ?? [];
const trusted_li: string[] = $idaa_loc.novi_trusted_li ?? [];
const is_trusted_uuid = user_id
? admin_li.includes(user_id) || trusted_li.includes(user_id)
: false;
if (is_trusted_uuid) {
is_moderator = true;
console.log(`Jitsi: User ${user_id} is moderator via admin/trusted UUID list.`);
} else {
// For regular authenticated members, check the specific meeting group.
// Prefer g_uuid from URL (per-meeting, more precise); fall back to the global
// novi_idaa_group_guid_li list for older Novi pages not yet passing g_uuid.
const group_uuid = url_params.g_uuid ?? null;
const group_guid_li = group_uuid
? [group_uuid]
: ($ae_loc.site_cfg_json?.novi_idaa_group_guid_li ?? []);
if (group_uuid) {
console.log(`Jitsi: Checking moderator via URL g_uuid: ${group_uuid}`);
} else {
console.log(`Jitsi: No g_uuid in URL — falling back to site config group list (${group_guid_li.length} groups).`);
}
const moderatorIdSet = await get_novi_group_moderators(
group_guid_li,
novi_api_root_url,
novi_api_key
);
const normalizedUserId = String(user_id ?? '')
.toLowerCase()
.trim();
if (normalizedUserId && moderatorIdSet.has(normalizedUserId)) {
is_moderator = true;
console.log(`Jitsi: User ${user_id} is a moderator.`);
} else {
is_moderator = false;
console.log(`Jitsi: User ${user_id} is not a moderator.`);
}
}
} else {
console.warn(
'Jitsi: Novi API not configured. Using URL fallback for user details/moderator check.'
);
}
// Set initial value for the profile editor inputs
name_input = display_name ?? '';
email_input = email ?? '';
}
/**
* Dynamically loads the Jitsi external API script for a given domain and waits for it.
* Using <svelte:head> for this script is not reliable — it loads asynchronously and there
* is no lifecycle hook to await its completion, causing a race with onMount/init_jitsi.
* Loading it here lets us sequence it correctly: fetch_novi_data → load script → init_jitsi.
*/
function load_jitsi_script(jitsi_domain: string): Promise<void> {
return new Promise((resolve, reject) => {
// @ts-expect-error — JitsiMeetExternalAPI is a global injected by the Jitsi script
if (typeof JitsiMeetExternalAPI !== 'undefined') {
resolve(); // Already loaded (e.g. resync after first load)
return;
}
const script = document.createElement('script');
script.src = `https://${jitsi_domain}/external_api.js`;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load Jitsi script from ${jitsi_domain}`));
document.head.appendChild(script);
});
}
async function handle_novi_resync() {
console.log('Jitsi: Manually re-syncing Novi Data...');
await fetch_novi_data();
await load_jitsi_script(domain ?? 'jitsi.dgrzone.com');
await init_jitsi();
}
onMount(async () => {
if (log_lvl) {
console.log(
'Jitsi: onMount - fetching user data and initializing Jitsi...'
);
}
await fetch_novi_data();
// Defense-in-depth: the parent layout should have blocked unverified users,
// but guard here as a second layer. For non-trusted users, the UUID in the URL
// must match the UUID that was verified by the layout. Prevents joining Jitsi
// with a fake UUID even if the layout guard is somehow bypassed.
if (!$ae_loc.trusted_access && (!$idaa_loc.novi_verified || $idaa_loc.novi_uuid !== user_id)) {
const container = document.getElementById(jitsi_container_id);
if (container)
container.innerHTML =
'<p style="padding:1rem;color:red;font-weight:bold;">Access denied: Novi identity not verified for this meeting.</p>';
console.error(
`Jitsi: Aborting — UUID not verified or mismatch. verified=${$idaa_loc.novi_verified}, stored=${$idaa_loc.novi_uuid}, url=${user_id}`
);
return;
}
if (!domain) {
console.error('Jitsi: domain not set after fetch_novi_data — cannot load Jitsi script.');
return;
}
try {
await load_jitsi_script(domain);
} catch (err) {
console.error('Jitsi: Failed to load external API script:', err);
const container = document.getElementById(jitsi_container_id);
if (container) container.innerHTML = '<h1>Jitsi API script failed to load. Please refresh the page.</h1>';
return;
}
// --- All data fetched and script ready, now initialize Jitsi ---
await init_jitsi();
});
onDestroy(() => {
if (duration_timer_id) clearInterval(duration_timer_id);
// No longer using reporting_timer_id
if (jitsi_api) {
console.log(
'Jitsi: Disposing of Jitsi API instance on component destroy.'
);
jitsi_api.dispose();
jitsi_api = null;
}
});
/**
* Fetches member details from the Novi API.
* @param user_id - The Novi Customer GUID.
* @param api_root_url - The root URL of the Novi API.
* @param api_key - The API key for authorization.
* @returns An object with the user's display name and email, or null if not found.
*/
async function get_novi_member_details(
user_id: string,
api_root_url: string,
api_key: string
): Promise<{ display_name: string | null; email: string | null }> {
if (!user_id || !api_root_url || !api_key) {
console.error('get_novi_member_details: Missing required arguments.');
return { display_name: null, email: null };
}
const headers = new Headers();
headers.append('Authorization', `Basic ${api_key}`);
const requestOptions = { method: 'GET', headers: headers };
const url = `${api_root_url}/customers/${user_id}`;
if (log_lvl) {
console.log('Jitsi: Fetching Novi member details from:', url);
}
try {
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(
`Novi API request failed with status ${response.status}`
);
}
const result = await response.json();
if (log_lvl > 1) {
console.log(`Jitsi: Novi's Current User Obj:`, result);
}
let full_name = result?.Name;
const first_name = result?.FirstName;
const last_initial = result?.LastName
? `${result.LastName.charAt(0).toUpperCase()}.`
: '';
if (first_name && last_initial) {
full_name = `${first_name} ${last_initial}`;
}
const novi_email = result?.Email
? result.Email.replace(/\s+/g, '+')
: null;
return { display_name: full_name, email: novi_email };
} catch (error) {
console.error('Error fetching Novi member details:', error);
return { display_name: null, email: null };
}
}
/**
* Fetches a set of moderator IDs from Novi group(s).
* @param group_guid_li - A list of Novi group GUIDs to check for moderators.
* @param api_root_url - The root URL of the Novi API.
* @param api_key - The API key for authorization.
* @returns A Set of normalized (lowercase, trimmed) moderator UniqueIDs.
*/
async function get_novi_group_moderators(
group_guid_li: string[],
api_root_url: string,
api_key: string
): Promise<Set<string>> {
if (
!group_guid_li ||
group_guid_li.length === 0 ||
!api_root_url ||
!api_key
) {
console.warn(
'get_novi_group_moderators: Missing required arguments or empty group list.'
);
return new Set();
}
const headers = new Headers();
headers.append('Authorization', `Basic ${api_key}`);
const requestOptions = { method: 'GET', headers: headers };
let allModeratorsRaw: any[] = [];
console.log('[DEBUG] Novi Moderator Group Fetch:');
console.log(' Group GUIDs:', group_guid_li);
console.log(' Novi API Root URL:', api_root_url);
for (const group_guid of group_guid_li) {
const url = `${api_root_url}/groups/${group_guid}/members?pageSize=200`;
console.log(`[DEBUG] Fetching moderator list for group:`, group_guid);
console.log(' Request URL:', url);
try {
const response = await fetch(url, requestOptions);
if (response.ok) {
const result = await response.json();
let groupModList: any[] = [];
if (Array.isArray(result)) groupModList = result;
else if (Array.isArray(result.Results))
groupModList = result.Results;
else if (Array.isArray(result.Members))
groupModList = result.Members;
else {
console.warn(
`[DEBUG] Moderator list format unexpected for group ${group_guid}. Raw result:`,
result
);
}
allModeratorsRaw = allModeratorsRaw.concat(groupModList);
console.log(
`[DEBUG] Group ${group_guid}: fetched ${groupModList.length} moderators. Total so far: ${allModeratorsRaw.length}`
);
if (groupModList.length > 0) {
console.log(`[DEBUG] First 3 moderator objects:`, groupModList.slice(0, 3));
}
} else {
console.warn(
`[DEBUG] Failed to fetch moderators for group ${group_guid}. Status: ${response.status}`
);
}
} catch (error) {
console.error(
`[DEBUG] Error fetching moderators for group ${group_guid}:`,
error
);
}
}
if (allModeratorsRaw.length === 0) {
console.warn('[DEBUG] No moderators found across all specified Novi groups.');
}
console.log('[DEBUG] Combined moderator list count:', allModeratorsRaw.length);
if (allModeratorsRaw.length > 0) {
console.log('[DEBUG] Example moderator IDs:', allModeratorsRaw.slice(0, 5).map(m => m?.UniqueID || m?.UniqueId || m?.DuesPayerUniqueID || m?.id || ''));
}
const modIdSet = new Set(
allModeratorsRaw
.map(
(m: any) =>
m?.UniqueID ??
m?.UniqueId ??
m?.DuesPayerUniqueID ??
m?.id ??
''
)
.filter(Boolean)
.map((id: string) => String(id).toLowerCase().trim())
);
console.log('[DEBUG] Final moderator ID set:', Array.from(modIdSet));
return modIdSet;
}
/**
* Initializes or re-initializes the Jitsi meeting.
* This function handles fetching user/moderator data, getting JWTs, and configuring/loading the Jitsi external API.
* If an existing Jitsi instance is present, it will be disposed of before creating a new one.
*/
async function init_jitsi() {
// Clear stats and timers
if (duration_timer_id) clearInterval(duration_timer_id);
// No longer using reporting_timer_id
meeting_participants.clear();
meeting_start_time = null;
meeting_duration = '00:00:00';
// Dispose of any existing Jitsi instance to allow for a clean restart
if (jitsi_api) {
console.log(
'Jitsi: Disposing of existing Jitsi API instance before re-initialization.'
);
jitsi_api.dispose();
jitsi_api = null;
}
// --- Check for Jitsi API script ---
// @ts-ignore
if (typeof JitsiMeetExternalAPI === 'undefined') {
console.error('Jitsi: JitsiMeetExternalAPI script not loaded yet.');
const container = document.getElementById(jitsi_container_id);
if (container) {
container.innerHTML =
'<h1>Jitsi API script not loaded. Please refresh the page.</h1>';
}
return;
}
console.log('Jitsi: Initializing Jitsi meeting interface...');
// These variables are now expected to be set in the component's state
if (!user_id || !display_name || !email || !room_name || !domain) {
console.error(
'Jitsi: Missing required data to initialize meeting. Aborting.'
);
const container = document.getElementById(jitsi_container_id);
if (container) {
container.innerHTML =
'<h1>Could not initialize meeting: missing user data.</h1>';
}
return;
}
const url_params = data.params;
// --- Initialize Jitsi ---
// Issue JWT to all verified Novi users. The token's moderator flag controls
// whether the user gets host privileges in the Jitsi room.
console.log(`Jitsi: Attempting to get JWT (is_moderator=${is_moderator})...`);
const jwt_token = await get_jitsi_jwt(
display_name,
email,
is_moderator,
room_name,
user_id
);
if (!jwt_token) {
const container = document.getElementById(jitsi_container_id);
if (container)
container.innerHTML =
'<h1>Authentication Failed. Please try again.</h1>';
console.error('Jitsi: Authentication failed. JWT not received.');
return;
}
console.log('Jitsi: Successfully received JWT.');
try {
// Decode JWT payload (base64url → JSON) for debug verification.
// This does NOT verify the signature — for logging only.
const jwt_payload = JSON.parse(atob(jwt_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
console.log('%cJitsi JWT decoded payload:', 'font-weight:bold;color:#4f8ef7', jwt_payload);
console.log(`Jitsi JWT: user=${jwt_payload?.context?.user?.name ?? jwt_payload?.name}, moderator=${jwt_payload?.context?.user?.moderator ?? jwt_payload?.user?.moderator}, room=${jwt_payload?.room ?? jwt_payload?.context?.room}`);
} catch (e) {
console.warn('Jitsi: Could not decode JWT payload for debug logging.', e);
}
const disabled_sounds = [
disable_incoming_msg_sound ? 'INCOMING_MSG_SOUND' : null,
disable_participant_joined_sound ? 'PARTICIPANT_JOINED_SOUND' : null,
disable_participant_left_sound ? 'PARTICIPANT_LEFT_SOUND' : null,
disable_reaction_sound ? 'REACTION_SOUND' : null,
disable_raise_hand_sound ? 'RAISE_HAND_SOUND' : null
].filter((sound) => sound);
const options = {
roomName: room_name,
width: '100%',
height: '100%',
parentNode: document.getElementById(jitsi_container_id),
userInfo: { displayName: display_name, email: email },
configOverwrite: {
prejoinPageEnabled: false,
startWithAudioMuted: true,
startWithVideoMuted: true,
enableLobby: is_moderator,
disableReactionsModeration: false,
disabledSounds: disabled_sounds,
// Explicit toolbar whitelist — omitting 'embedmeeting' entirely.
// "Embed Meeting" exposes the Jitsi host/room URL and must never appear
// for IDAA users (authenticated or not) — privacy requirement.
// 'invite' is restricted to trusted_access (IDAA staff) only — regular member
// moderators are excluded because the invite dialog can expose backend paths
// and room URLs that should not be visible to general members.
toolbarButtons: [
'camera', 'chat', 'closedcaptions', 'desktop', 'download',
'etherpad', 'feedback', 'filmstrip', 'fullscreen', 'hangup',
'help', ...($ae_loc.trusted_access ? ['invite'] : []), 'livestreaming', 'microphone',
'mute-everyone', 'mute-video-everyone', 'participants-pane',
'profile', 'raisehand', 'recording', 'security',
'select-background', 'settings', 'shareaudio', 'sharedvideo',
'shortcuts', 'stats', 'tileview', 'toggle-camera', 'videoquality'
]
},
interfaceConfigOverwrite: {
DISABLE_JOIN_LEAVE_NOTIFICATIONS: true,
NOTIFICATION_SOUND_URL: ''
},
jwt: jwt_token
};
console.log(
'Jitsi: Initializing JitsiMeetExternalAPI with options:',
options
);
// @ts-ignore
jitsi_api = new JitsiMeetExternalAPI(domain, options);
add_jitsi_event_listeners(jitsi_api);
console.log('Jitsi: JitsiMeetExternalAPI initialized:', jitsi_api);
}
</script>
{#if show_jitsi_container}
<div id={jitsi_container_id} class="jitsi-container"></div>
{/if}
{#if show_jitsi_tools && $ae_loc.edit_mode}
<div
class:bg-gray-400={expand_jitsi_tools}
class="jitsi-tools max-w-xl text-sm">
{#if expand_jitsi_tools}
<!-- NOTE: This is a <div> instead of <header> to work with IDAA's Novi styles. -->
<div
class="flex w-full flex-row items-center justify-between gap-1">
<!-- NOTE: This is a <div> instead of <h1> to work with IDAA's Novi styles. -->
<div class="w-fit text-base">
IDAA's Jitsi
{#if display_name && email}
<span class="rounded bg-amber-50 px-1 py-0.5 font-bold"
>{display_name} ({email})</span>
{/if}
</div>
<button
type="button"
class="rounded bg-red-200 px-2 py-1 text-white hover:bg-red-400"
onclick={() => (expand_jitsi_tools = false)}
title="Close Jitsi Tools">
<span class="fas fa-times" aria-hidden="true"></span>
<span class="sr-only">Close Jitsi Tools</span>
</button>
</div>
<!-- Meeting Details -->
<div class="mt-1">
<button
type="button"
onclick={() =>
(show_meeting_details = !show_meeting_details)}
class="flex w-full items-center justify-between text-left font-bold">
Meeting Details
<span
class="fas {show_meeting_details
? 'fa-chevron-up'
: 'fa-chevron-down'}"></span>
</button>
{#if show_meeting_details}
<ul class="mt-1 pl-2">
<!-- {#if display_name && email}
<li class="bg-amber-50">{display_name} ({email})</li>
{/if} -->
<li>
Room: <span class="font-mono text-sm font-bold"
>{room_name}</span>
</li>
<li>
Domain: <span class="font-mono text-sm"
>{domain}</span>
</li>
<li>
User ID: <span class="font-mono text-sm"
>{user_id}</span>
</li>
<li>Moderator: {is_moderator ? 'Yes' : 'No'}</li>
</ul>
{/if}
</div>
<!-- Live Meeting Stats -->
<div class="mt-2 border-t-2 border-dashed border-gray-400 pt-2">
<button
type="button"
onclick={() => (show_live_stats = !show_live_stats)}
class="flex w-full items-center justify-between text-left font-bold">
Live Meeting Stats
<span
class="fas {show_live_stats
? 'fa-chevron-up'
: 'fa-chevron-down'}"></span>
</button>
{#if show_live_stats}
<div class="mt-1 pl-2">
<p>
Duration: <span class="font-mono"
>{meeting_duration}</span>
</p>
<p>Total Participants: {meeting_participants.size}</p>
<div class="mt-1">
<div class="font-bold">Moderators:</div>
<ul class="list-disc pl-4">
{#each Array.from(meeting_participants.values()).filter((p) => p.role === 'moderator') as mod (mod.id)}
<li>{mod.displayName}</li>
{:else}
<li class="text-gray-500 italic">None</li>
{/each}
</ul>
</div>
<div class="mt-1">
<div class="font-bold">Participants:</div>
<ul class="list-disc pl-4">
{#each Array.from(meeting_participants.values()).filter((p) => p.role !== 'moderator') as person (person.id)}
<li>{person.displayName}</li>
{:else}
<li class="text-gray-500 italic">None</li>
{/each}
</ul>
</div>
</div>
{/if}
</div>
<!-- Profile -->
<div class="mt-2 border-t-2 border-dashed border-gray-400 pt-2">
<button
type="button"
onclick={() => (show_profile_editor = !show_profile_editor)}
class="flex w-full items-center justify-between text-left font-bold">
Profile
<span
class="fas {show_profile_editor
? 'fa-chevron-up'
: 'fa-chevron-down'}"></span>
</button>
{#if show_profile_editor}
<div class="mt-1 space-y-2 pl-2">
<div>
<label
for="display_name_input"
class="block font-semibold">Name:</label>
<input
type="text"
id="display_name_input"
bind:value={name_input}
class="w-full rounded border px-2 py-1"
placeholder="Enter new display name" />
</div>
<div>
<label for="email_input" class="block font-semibold"
>Email:</label>
<input
type="email"
id="email_input"
bind:value={email_input}
class="w-full rounded border px-2 py-1"
placeholder="Enter new email" />
</div>
<button
type="button"
onclick={handle_profile_update}
class="mt-1 w-full rounded bg-green-500 px-2 py-1 text-white hover:bg-green-600"
title="Update your profile">
<span class="fas fa-user-edit" aria-hidden="true"
></span>
Update Profile
</button>
</div>
{/if}
</div>
<!-- Jitsi Sound Settings -->
<div class="mt-2 border-t-2 border-dashed border-gray-400 pt-2">
<button
type="button"
onclick={() => (show_sound_settings = !show_sound_settings)}
class="flex w-full items-center justify-between text-left font-bold">
Sound Settings
<span
class="fas {show_sound_settings
? 'fa-chevron-up'
: 'fa-chevron-down'}"></span>
</button>
{#if show_sound_settings}
<div class="mt-1 flex flex-col pl-2">
<!-- WARNING: Fully disables the Play sound on option in the Jitsi > Settings > Notifications tab. -->
<label class="mt-1 flex items-center space-x-2">
<input
type="checkbox"
bind:checked={disable_incoming_msg_sound}
disabled={!is_moderator}
onchange={init_jitsi} />
<span>Disable Incoming Message Sound</span>
</label>
<label class="mt-1 flex items-center space-x-2">
<input
type="checkbox"
bind:checked={disable_participant_joined_sound}
disabled={!is_moderator}
onchange={init_jitsi} />
<span>Disable Participant Joined Sound</span>
</label>
<label class="mt-1 flex items-center space-x-2">
<input
type="checkbox"
bind:checked={disable_participant_left_sound}
disabled={!is_moderator}
onchange={init_jitsi} />
<span>Disable Participant Left Sound</span>
</label>
<!-- FUTURE: Under Notifications: Talk while muted -->
<!-- FUTURE: Under Notifications: Participant entered lobby -->
<!--
FUTURE: Under Moderator:
"Everyone starts muted"
"Everyone starts hidden"
"Everyone follows me"
"Recorder follows me"
-->
<!-- WARNING: This does not seem to work. It should be unchecking Moderator options under Jitsi > Settings > Moderator tab. -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="mt-1 flex items-center space-x-2">
<input
type="checkbox"
bind:checked={disable_reaction_sound}
disabled={!is_moderator}
onchange={init_jitsi} />
<!-- Full text from Jitsi Settings popup: "Mute reaction sounds for everyone" -->
<span>Disable Reaction Sound</span>
</label>
<!-- WARNING: What does this correspond to in the Jitsi settings? -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="mt-1 flex items-center space-x-2">
<input
type="checkbox"
bind:checked={disable_raise_hand_sound}
disabled={!is_moderator}
onchange={init_jitsi} />
<span>Disable Raise Hand Sound</span>
</label>
</div>
{/if}
</div>
<div
class="
mt-2 flex w-full flex-col flex-wrap items-center justify-center gap-1
border-t-2 border-dashed border-gray-400 pt-2 sm:flex-row">
<MyClipboard
value={encodeURI($page.url.href)}
btn_text="Copy Break-out Link"
btn_title="Copy a link to this meeting that can be opened outside of the Novi iframe."
btn_class="mt-2 px-2 py-1 bg-indigo-400 text-white rounded hover:bg-indigo-500"
></MyClipboard>
<button
type="button"
class="mt-2 rounded bg-orange-200 px-2 py-1 text-white hover:bg-orange-400"
onclick={() => init_jitsi()}
title="Re-initialize Jitsi Meeting">
<span class="fas fa-redo" aria-hidden="true"></span>
Re-initialize Jitsi
</button>
<!-- https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-iframe-commands/ -->
<button
type="button"
onclick={() =>
jitsi_api && jitsi_api.executeCommand('endConference')}
class="mt-2 rounded bg-red-200 px-2 py-1 text-white hover:bg-red-400"
title="End meeting for all participants">
<span class="fas fa-phone-slash" aria-hidden="true"></span>
End Meeting for *All*
</button>
<button
type="button"
onclick={() =>
(show_jitsi_container = !show_jitsi_container)}
class="mt-2 rounded bg-gray-200 px-2 py-1 text-white hover:bg-gray-400"
title="{show_jitsi_container
? 'Hide'
: 'Show'} Jitsi Meeting">
{#if show_jitsi_container}
<span class="fas fa-video-slash" aria-hidden="true"
></span>
Hide
{:else}
<span class="fas fa-video" aria-hidden="true"></span>
Show
{/if}
Jitsi Meeting
<span class="sr-only">Container</span>
</button>
<button
type="button"
class="mt-2 rounded bg-blue-200 px-2 py-1 text-white hover:bg-blue-400"
onclick={handle_novi_resync}
title="Re-synchronize Novi data">
<span class="fas fa-sync" aria-hidden="true"></span>
Re-sync Novi Data
</button>
</div>
{:else}
<button
type="button"
class="rounded bg-red-200 px-2 py-1 text-white hover:bg-red-400"
onclick={() => (expand_jitsi_tools = true)}
aria-label="Open Jitsi Tools"
title="Open Jitsi tools and settings for IDAA Jitsi meetings">
<span class="fas fa-tools" aria-hidden="true"></span>
<span class="sr-only">Open Jitsi Tools</span>
</button>
{/if}
</div>
{/if}
<!-- Breakout button: only shown in iframe mode — pointless outside an iframe since the user is
already in a full tab. WHY: Novi iframes squish the layout and scrolling is unreliable.
Members need a way to escape to a proper full-tab context. -->
{#if $ae_loc.iframe || $ae_loc.edit_mode}
<div class="fixed bottom-0.5 left-8 z-20 print:hidden">
<button
type="button"
onclick={() => (show_breakout_modal = true)}
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-3 py-2 mb-2 text-sm shadow-md backdrop-blur-sm hover:bg-white"
title="Open this meeting outside the Novi iframe">
<span class="fas fa-external-link-alt" aria-hidden="true"></span>
Open Externally
</button>
</div>
{/if}
{#if show_breakout_modal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 print:hidden"
onclick={() => (show_breakout_modal = false)}
onkeydown={(e) => e.key === 'Escape' && (show_breakout_modal = false)}
role="button"
tabindex="-1"
aria-label="Close meeting link dialog">
<div
class="mx-4 w-full max-w-sm rounded-xl bg-white px-6 py-2 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="breakout_modal_title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="mb-2 flex items-start justify-between">
<div>
<h2 id="breakout_modal_title" class="text-base font-bold text-center">
Open Meeting Externally
</h2>
<p class="mt-1 text-sm text-gray-500">
Open this meeting outside the embedded iframe on this page.
</p>
</div>
<button
type="button"
onclick={() => (show_breakout_modal = false)}
class="ml-4 shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close">
<span class="fas fa-times" aria-hidden="true"></span>
</button>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
onclick={copy_meeting_link}
class="flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors {breakout_link_copied
? 'border-green-300 bg-green-50 text-green-700'
: 'border-gray-300 bg-white hover:bg-gray-50'}">
<span
class="fas {breakout_link_copied ? 'fa-check' : 'fa-copy'}"
aria-hidden="true"></span>
{breakout_link_copied ? 'Copied!' : 'Copy Link'}
</button>
<a
href={$page.url.href}
target="_blank"
rel="noopener noreferrer"
onclick={() => (show_breakout_modal = false)}
class="flex items-center justify-center gap-2 rounded-lg border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100">
<span class="fas fa-external-link-alt" aria-hidden="true"></span>
Open in New Tab
</a>
<!-- Fallback for browsers that block clipboard access (common in iframes).
User can manually select all and copy from here. -->
<div class="mt-0.5">
<p class="mb-0.5 text-xs text-gray-400">Or copy the link manually:</p>
<textarea
readonly
rows="3"
value={$page.url.href}
onclick={(e) => (e.target as HTMLTextAreaElement).select()}
class="w-full cursor-text resize-none rounded border border-gray-200 bg-gray-50 px-2 py-1.5 font-mono text-xs text-gray-700 focus:outline-none"
title="Click to select all, then copy (ctrl/cmd + C)"
aria-label="Meeting link — click to select all"></textarea>
</div>
</div>
</div>
</div>
{/if}
<style>
.jitsi-container {
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
padding-bottom: 3em; /* Space for the tools button */
}
.jitsi-tools {
z-index: 10;
position: fixed;
bottom: 10em;
right: 20px;
/* background-color: rgba(255, 255, 255, 0.8); */
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
</style>