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.
1347 lines
54 KiB
Svelte
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>
|