Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/video_conferences/+page.svelte
Scott Idem f111670f60 feat(idaa): use URL g_uuid for Jitsi moderator group check
Instead of checking membership across all groups in novi_idaa_group_guid_li
(site config), pass the single g_uuid from the URL param. Each Novi iframe
page supplies the group relevant to that specific meeting, so checking just
that one group is both more precise and avoids unnecessary Novi API calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:27:06 -04:00

1155 lines
43 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
/**
* 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';
// Initialize sound settings from URL params
disable_incoming_msg_sound = url_params.incoming_msg_sound === 'true';
disable_participant_joined_sound =
url_params.participant_joined_sound === 'true';
disable_participant_left_sound =
url_params.participant_left_sound === 'true';
disable_reaction_sound = url_params.reaction_sound === 'true';
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}`);
}
}
// Use the group UUID from the URL param to check membership in that specific group.
// Each Jitsi iframe page passes the relevant group's UUID — this is more precise than
// checking against a global list of all IDAA moderator groups from site config.
const group_uuid = url_params.g_uuid ?? null;
if (!group_uuid) {
console.warn('Jitsi: No g_uuid in URL — skipping group moderator check.');
}
const moderatorIdSet = await get_novi_group_moderators(
group_uuid ? [group_uuid] : [],
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; // Explicitly set to false if not in the set
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 ?? '';
}
async function handle_novi_resync() {
console.log('Jitsi: Manually re-syncing Novi Data...');
await fetch_novi_data();
await init_jitsi();
}
onMount(async () => {
if (log_lvl) {
console.log(
'Jitsi: onMount - fetching user data and initializing Jitsi...'
);
}
// $ae_loc.sys_menu.hide = true;
await fetch_novi_data();
// --- All data fetched, 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 ---
let jwt_token = null;
if (is_moderator) {
console.log('Jitsi: Attempting to get JWT for moderator...');
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.');
} else {
console.log('Jitsi: Not a moderator, proceeding without JWT.');
}
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
},
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>
<svelte:head>
<script src="https://jitsi.dgrzone.com/external_api.js"></script>
</svelte:head>
{#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}
<style>
.jitsi-container {
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.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>