988aafce39
Stop ignoring amd/build so generated modules ship with the plugin ZIP. Co-authored-by: Cursor <cursoragent@cursor.com>
1053 lines
37 KiB
JavaScript
1053 lines
37 KiB
JavaScript
// This file is part of Moodle - http://moodle.org/
|
||
//
|
||
// Moodle is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU General Public License as published by
|
||
// the Free Software Foundation, either version 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// Moodle is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU General Public License
|
||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
/**
|
||
* @module mod_meetletacbfree/view
|
||
* @copyright 2026 MeetLet <info@meetlet.net>
|
||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||
*/
|
||
|
||
define(['core/ajax', 'core/notification', 'core/str'], function(Ajax, Notification, Str) {
|
||
function init() {
|
||
const SELECTORS = {
|
||
config: '[data-meetlet-config]',
|
||
app: '.meetlet-app',
|
||
tab: '.meetlet-tab',
|
||
panel: '[data-panel]',
|
||
join: '[data-action="join"]',
|
||
offline: '[data-action="makeoffline"]',
|
||
launchRecording: '[data-action="launch-recording"]',
|
||
launchContent: '[data-action="launch-content"]',
|
||
downloadContent: '[data-action="download-content"]',
|
||
refresh: '[data-action="refresh-tab"]',
|
||
overlay: '[data-join-overlay]',
|
||
sessionDetails: '[data-action="session-details"]',
|
||
modal: '[data-attendance-modal]',
|
||
modalBody: '[data-attendance-modal-body]',
|
||
modalTitle: '[data-attendance-modal-title]',
|
||
modalClose: '[data-attendance-modal-close]',
|
||
};
|
||
|
||
let state = {
|
||
cmid: 0,
|
||
groupid: 0,
|
||
current: 'details',
|
||
cache: {},
|
||
joinable: true,
|
||
status: 'open',
|
||
statusMessage: '',
|
||
pollSeconds: 15,
|
||
statusTimer: null,
|
||
recPollTimer: null,
|
||
licenseUsable: true,
|
||
};
|
||
|
||
const RECORDINGS_POLL_MS = 60000;
|
||
const DOWNLOAD_CONTENT_TIMEOUT_MS = 320000;
|
||
let modalLabels = {
|
||
name: '',
|
||
login: '',
|
||
timein: '',
|
||
timeout: '',
|
||
empty: '',
|
||
};
|
||
|
||
const detailsHtml = () => {
|
||
const tpl = document.querySelector('[data-meetlet-details-html]');
|
||
return tpl ? tpl.innerHTML : '';
|
||
};
|
||
|
||
const setLoading = (panel, text) => {
|
||
panel.innerHTML = '<div class="meetlet-loading"><span class="meetlet-loading__spinner"></span><span>' +
|
||
text + '</span></div>';
|
||
};
|
||
|
||
const showDetails = (panel) => {
|
||
panel.innerHTML = detailsHtml();
|
||
};
|
||
|
||
const escapeHtml = (value) => {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
};
|
||
|
||
let floatTipEl = null;
|
||
let floatTipTarget = null;
|
||
|
||
const ensureFloatTip = () => {
|
||
if (floatTipEl && document.body.contains(floatTipEl)) {
|
||
return floatTipEl;
|
||
}
|
||
floatTipEl = document.createElement('div');
|
||
floatTipEl.className = 'meetlet-floattip';
|
||
floatTipEl.setAttribute('role', 'tooltip');
|
||
floatTipEl.hidden = true;
|
||
document.body.appendChild(floatTipEl);
|
||
return floatTipEl;
|
||
};
|
||
|
||
const hideFloatTip = () => {
|
||
floatTipTarget = null;
|
||
const el = ensureFloatTip();
|
||
el.classList.remove('is-visible', 'is-below');
|
||
el.hidden = true;
|
||
el.textContent = '';
|
||
};
|
||
|
||
const placeFloatTip = (target) => {
|
||
const text = (target.getAttribute('data-tip') || '').trim();
|
||
if (!text) {
|
||
hideFloatTip();
|
||
return;
|
||
}
|
||
floatTipTarget = target;
|
||
const el = ensureFloatTip();
|
||
el.textContent = text;
|
||
el.hidden = false;
|
||
el.classList.remove('is-visible', 'is-below');
|
||
el.style.left = '0px';
|
||
el.style.top = '0px';
|
||
const tipW = el.offsetWidth;
|
||
const tipH = el.offsetHeight;
|
||
const r = target.getBoundingClientRect();
|
||
let left = r.left + (r.width / 2) - (tipW / 2);
|
||
left = Math.max(8, Math.min(left, window.innerWidth - tipW - 8));
|
||
let top = r.top - tipH - 10;
|
||
let below = false;
|
||
if (top < 8) {
|
||
top = r.bottom + 10;
|
||
below = true;
|
||
}
|
||
if (top + tipH > window.innerHeight - 8 && !below) {
|
||
top = Math.max(8, r.bottom + 10);
|
||
below = true;
|
||
}
|
||
el.style.left = left + 'px';
|
||
el.style.top = top + 'px';
|
||
el.classList.toggle('is-below', below);
|
||
requestAnimationFrame(() => {
|
||
if (floatTipTarget === target) {
|
||
el.classList.add('is-visible');
|
||
}
|
||
});
|
||
};
|
||
|
||
const bindFloatTips = (root) => {
|
||
if (!root || root.dataset.meetletTipsBound === '1') {
|
||
return;
|
||
}
|
||
root.dataset.meetletTipsBound = '1';
|
||
root.addEventListener('pointerover', (e) => {
|
||
const tip = e.target.closest('[data-tip]');
|
||
if (!tip || !root.contains(tip)) {
|
||
return;
|
||
}
|
||
placeFloatTip(tip);
|
||
});
|
||
root.addEventListener('pointerout', (e) => {
|
||
const tip = e.target.closest('[data-tip]');
|
||
if (!tip || !root.contains(tip)) {
|
||
return;
|
||
}
|
||
const next = e.relatedTarget;
|
||
if (next && tip.contains(next)) {
|
||
return;
|
||
}
|
||
if (floatTipTarget === tip) {
|
||
hideFloatTip();
|
||
}
|
||
});
|
||
root.addEventListener('focusin', (e) => {
|
||
const tip = e.target.closest('[data-tip]');
|
||
if (tip && root.contains(tip)) {
|
||
placeFloatTip(tip);
|
||
}
|
||
});
|
||
root.addEventListener('focusout', (e) => {
|
||
const tip = e.target.closest('[data-tip]');
|
||
if (!tip) {
|
||
return;
|
||
}
|
||
const next = e.relatedTarget;
|
||
if (next && tip.contains(next)) {
|
||
return;
|
||
}
|
||
if (floatTipTarget === tip) {
|
||
hideFloatTip();
|
||
}
|
||
});
|
||
};
|
||
|
||
const modalScroll = () => window.MeetletModalScroll || {
|
||
lock: () => {},
|
||
unlock: () => {},
|
||
focusNoScroll: (el) => {
|
||
if (el && typeof el.focus === 'function') {
|
||
el.focus();
|
||
}
|
||
},
|
||
};
|
||
|
||
const closeModal = () => {
|
||
const modal = document.querySelector(SELECTORS.modal);
|
||
if (!modal) {
|
||
return;
|
||
}
|
||
modal.setAttribute('hidden', 'hidden');
|
||
modal.style.display = 'none';
|
||
modalScroll().unlock();
|
||
};
|
||
|
||
const ensureModalShell = () => {
|
||
let modal = document.querySelector(SELECTORS.modal);
|
||
if (modal) {
|
||
return modal;
|
||
}
|
||
modal = document.createElement('div');
|
||
modal.className = 'meetlet-modal';
|
||
modal.setAttribute('data-attendance-modal', '1');
|
||
modal.setAttribute('hidden', 'hidden');
|
||
modal.style.display = 'none';
|
||
modal.innerHTML =
|
||
'<div class="meetlet-modal__backdrop" data-attendance-modal-close="1"></div>' +
|
||
'<div class="meetlet-modal__dialog" role="dialog" aria-modal="true">' +
|
||
'<div class="meetlet-modal__head">' +
|
||
'<h3 class="meetlet-modal__title" data-attendance-modal-title></h3>' +
|
||
'<button type="button" class="meetlet-modal__close" data-attendance-modal-close="1" aria-label="Close">×</button>' +
|
||
'</div>' +
|
||
'<div class="meetlet-modal__body" data-attendance-modal-body></div>' +
|
||
'</div>';
|
||
document.body.appendChild(modal);
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target.closest(SELECTORS.modalClose)) {
|
||
closeModal();
|
||
}
|
||
});
|
||
return modal;
|
||
};
|
||
|
||
const openModal = (title, attendees) => {
|
||
const modal = ensureModalShell();
|
||
const titleEl = modal.querySelector(SELECTORS.modalTitle);
|
||
const body = modal.querySelector(SELECTORS.modalBody);
|
||
if (titleEl) {
|
||
titleEl.textContent = title || '';
|
||
}
|
||
if (!attendees || !attendees.length) {
|
||
body.innerHTML = '<div class="meetlet-empty">' + escapeHtml(modalLabels.empty) + '</div>';
|
||
} else {
|
||
let rows = '';
|
||
attendees.forEach((row) => {
|
||
rows += '<tr>' +
|
||
'<td>' + escapeHtml(row.name || '') + '</td>' +
|
||
'<td>' + escapeHtml(row.login || '') + '</td>' +
|
||
'<td>' + escapeHtml(row.timein || '-') + '</td>' +
|
||
'<td>' + escapeHtml(row.timeout || '-') + '</td>' +
|
||
'</tr>';
|
||
});
|
||
body.innerHTML =
|
||
'<div class="meetlet-tablewrap">' +
|
||
'<table class="meetlet-table meetlet-table--modal">' +
|
||
'<thead><tr>' +
|
||
'<th>' + escapeHtml(modalLabels.name) + '</th>' +
|
||
'<th>' + escapeHtml(modalLabels.login) + '</th>' +
|
||
'<th>' + escapeHtml(modalLabels.timein) + '</th>' +
|
||
'<th>' + escapeHtml(modalLabels.timeout) + '</th>' +
|
||
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||
}
|
||
modalScroll().lock();
|
||
modal.removeAttribute('hidden');
|
||
modal.style.display = 'flex';
|
||
};
|
||
|
||
const readSessionsPayload = (panel) => {
|
||
const holder = panel.querySelector('[data-meetlet-sessions-json]');
|
||
if (!holder) {
|
||
return [];
|
||
}
|
||
try {
|
||
return JSON.parse(holder.textContent || '[]');
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const setButtonBusy = (btn, busy) => {
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
if (btn.tagName !== 'A') {
|
||
btn.disabled = !!busy;
|
||
}
|
||
btn.classList.toggle('is-busy', !!busy);
|
||
if (busy) {
|
||
btn.setAttribute('aria-busy', 'true');
|
||
if (!btn.querySelector('.meetlet-iconbtn__spinner')) {
|
||
const spin = document.createElement('span');
|
||
spin.className = 'meetlet-iconbtn__spinner';
|
||
spin.setAttribute('aria-hidden', 'true');
|
||
btn.appendChild(spin);
|
||
}
|
||
} else {
|
||
btn.removeAttribute('aria-busy');
|
||
const spin = btn.querySelector('.meetlet-iconbtn__spinner');
|
||
if (spin) {
|
||
spin.remove();
|
||
}
|
||
}
|
||
};
|
||
|
||
const parseDownloadFilename = (disposition, fallback) => {
|
||
if (!disposition) {
|
||
return fallback;
|
||
}
|
||
const utf8 = disposition.match(/filename\*=UTF-8''([^;\n]+)/i);
|
||
if (utf8 && utf8[1]) {
|
||
try {
|
||
return decodeURIComponent(utf8[1].trim());
|
||
} catch (e) {
|
||
return utf8[1].trim();
|
||
}
|
||
}
|
||
const quoted = disposition.match(/filename="([^"]+)"/i);
|
||
if (quoted && quoted[1]) {
|
||
return quoted[1].trim();
|
||
}
|
||
const plain = disposition.match(/filename=([^;\n]+)/i);
|
||
if (plain && plain[1]) {
|
||
return plain[1].trim().replace(/^"|"$/g, '');
|
||
}
|
||
return fallback;
|
||
};
|
||
|
||
const triggerFileDownload = (blob, filename) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = filename || 'download';
|
||
anchor.style.display = 'none';
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
};
|
||
|
||
const notifyDownloadError = (err) => {
|
||
const isAbort = err && err.name === 'AbortError';
|
||
const key = isAbort ? 'downloadcontenttimeout' : 'downloadcontentfailed';
|
||
return Str.get_string(key, 'meetletacbfree').then((message) => {
|
||
Notification.addNotification({message: message, type: 'error'});
|
||
return null;
|
||
}).catch(() => {
|
||
Notification.addNotification({
|
||
message: isAbort ? 'Download timed out.' : 'Download failed.',
|
||
type: 'error',
|
||
});
|
||
});
|
||
};
|
||
|
||
const downloadContentFile = (link) => {
|
||
const url = link.getAttribute('href');
|
||
const fallbackName = link.getAttribute('download') || 'download';
|
||
if (!url || link.classList.contains('is-busy')) {
|
||
return Promise.resolve();
|
||
}
|
||
setButtonBusy(link, true);
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), DOWNLOAD_CONTENT_TIMEOUT_MS);
|
||
return fetch(url, {
|
||
credentials: 'same-origin',
|
||
signal: controller.signal,
|
||
}).then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error('http-' + response.status);
|
||
}
|
||
const filename = parseDownloadFilename(
|
||
response.headers.get('Content-Disposition'),
|
||
fallbackName
|
||
);
|
||
// Proxy delay is over once headers arrive — re-enable before blob finishes.
|
||
setButtonBusy(link, false);
|
||
return response.blob().then((blob) => {
|
||
if (!blob || blob.size <= 0) {
|
||
throw new Error('empty');
|
||
}
|
||
triggerFileDownload(blob, filename);
|
||
});
|
||
}).catch((err) => {
|
||
setButtonBusy(link, false);
|
||
return notifyDownloadError(err);
|
||
}).finally(() => {
|
||
window.clearTimeout(timer);
|
||
});
|
||
};
|
||
|
||
const endInlineRename = (wrap, restore) => {
|
||
const text = wrap.querySelector('.meetlet-recname__text');
|
||
const input = wrap.querySelector('.meetlet-recname__input');
|
||
const btn = wrap.querySelector('[data-action="inline-rename"]');
|
||
wrap.classList.remove('is-editing');
|
||
if (input) {
|
||
input.hidden = true;
|
||
if (restore && text) {
|
||
input.value = wrap.getAttribute('data-name') || text.textContent || '';
|
||
}
|
||
}
|
||
if (text) {
|
||
text.hidden = false;
|
||
}
|
||
if (btn) {
|
||
btn.hidden = false;
|
||
setButtonBusy(btn, false);
|
||
}
|
||
};
|
||
|
||
const saveInlineRename = (wrap) => {
|
||
const input = wrap.querySelector('.meetlet-recname__input');
|
||
const text = wrap.querySelector('.meetlet-recname__text');
|
||
const btn = wrap.querySelector('[data-action="inline-rename"]');
|
||
if (!input || wrap.dataset.saving === '1') {
|
||
return;
|
||
}
|
||
const current = wrap.getAttribute('data-name') || '';
|
||
const name = String(input.value || '').trim();
|
||
if (name === '' || name === current) {
|
||
endInlineRename(wrap, true);
|
||
return;
|
||
}
|
||
wrap.dataset.saving = '1';
|
||
if (btn) {
|
||
setButtonBusy(btn, true);
|
||
}
|
||
input.disabled = true;
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_rename_recording',
|
||
args: {
|
||
cmid: state.cmid,
|
||
recordingscoid: parseInt(wrap.getAttribute('data-recording'), 10),
|
||
name: name,
|
||
groupid: state.groupid,
|
||
},
|
||
}])[0].then((res) => {
|
||
wrap.setAttribute('data-name', name);
|
||
if (text) {
|
||
text.textContent = name;
|
||
}
|
||
input.value = name;
|
||
Notification.addNotification({message: res.message, type: 'success'});
|
||
endInlineRename(wrap, false);
|
||
const app = document.querySelector(SELECTORS.app);
|
||
const panel = app && app.querySelector(SELECTORS.panel);
|
||
if (state.current === 'recordings' && panel) {
|
||
state.cache.recordings = panel.innerHTML;
|
||
}
|
||
}).catch((err) => {
|
||
Notification.exception(err);
|
||
endInlineRename(wrap, true);
|
||
}).finally(() => {
|
||
wrap.dataset.saving = '0';
|
||
input.disabled = false;
|
||
});
|
||
};
|
||
|
||
const startInlineRename = (wrap) => {
|
||
const text = wrap.querySelector('.meetlet-recname__text');
|
||
const input = wrap.querySelector('.meetlet-recname__input');
|
||
const btn = wrap.querySelector('[data-action="inline-rename"]');
|
||
if (!input) {
|
||
return;
|
||
}
|
||
wrap.classList.add('is-editing');
|
||
if (text) {
|
||
text.hidden = true;
|
||
}
|
||
if (btn) {
|
||
btn.hidden = true;
|
||
}
|
||
input.hidden = false;
|
||
input.value = wrap.getAttribute('data-name') || (text ? text.textContent : '') || '';
|
||
input.focus();
|
||
input.select();
|
||
const onKey = (ev) => {
|
||
if (ev.key === 'Enter') {
|
||
ev.preventDefault();
|
||
input.removeEventListener('keydown', onKey);
|
||
input.removeEventListener('blur', onBlur);
|
||
saveInlineRename(wrap);
|
||
} else if (ev.key === 'Escape') {
|
||
ev.preventDefault();
|
||
input.removeEventListener('keydown', onKey);
|
||
input.removeEventListener('blur', onBlur);
|
||
endInlineRename(wrap, true);
|
||
}
|
||
};
|
||
const onBlur = () => {
|
||
input.removeEventListener('keydown', onKey);
|
||
input.removeEventListener('blur', onBlur);
|
||
saveInlineRename(wrap);
|
||
};
|
||
input.addEventListener('keydown', onKey);
|
||
input.addEventListener('blur', onBlur);
|
||
};
|
||
|
||
const renderCached = (panel, tab) => {
|
||
panel.innerHTML = state.cache[tab];
|
||
bindPanelActions(panel);
|
||
};
|
||
|
||
const bindPanelActions = (panel) => {
|
||
panel.querySelectorAll(SELECTORS.offline).forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
setButtonBusy(btn, true);
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_make_offline',
|
||
args: {
|
||
cmid: state.cmid,
|
||
recordingscoid: parseInt(btn.dataset.recording, 10),
|
||
groupid: state.groupid,
|
||
},
|
||
}])[0].then((res) => {
|
||
setButtonBusy(btn, false);
|
||
Notification.addNotification({
|
||
message: res.message,
|
||
type: res.success ? 'success' : 'warning',
|
||
});
|
||
// Force re-fetch recordings after job request.
|
||
return loadTab('recordings', true);
|
||
}).catch((err) => {
|
||
setButtonBusy(btn, false);
|
||
const msg = (err && (err.message || err.error)) ? (err.message || err.error) : '';
|
||
if (msg) {
|
||
Notification.addNotification({message: msg, type: 'error'});
|
||
} else {
|
||
Notification.exception(err);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
panel.querySelectorAll(SELECTORS.launchRecording).forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const scoid = parseInt(btn.dataset.recording, 10);
|
||
const mode = (btn.dataset.mode || 'play').toLowerCase();
|
||
if (!scoid) {
|
||
return;
|
||
}
|
||
setButtonBusy(btn, true);
|
||
setOverlay(true, 'recording');
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_recording_launch_url',
|
||
args: {
|
||
cmid: state.cmid,
|
||
recordingscoid: scoid,
|
||
mode: mode,
|
||
groupid: state.groupid,
|
||
},
|
||
}])[0].then((response) => {
|
||
setOverlay(false);
|
||
setButtonBusy(btn, false);
|
||
handleLaunchResponse(response);
|
||
return null;
|
||
}).catch((err) => {
|
||
setOverlay(false);
|
||
setButtonBusy(btn, false);
|
||
handleLaunchResponse(null, err);
|
||
});
|
||
});
|
||
});
|
||
|
||
panel.querySelectorAll(SELECTORS.launchContent).forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const scoid = parseInt(btn.dataset.content, 10);
|
||
if (!scoid) {
|
||
return;
|
||
}
|
||
setButtonBusy(btn, true);
|
||
setOverlay(true, 'content');
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_content_launch_url',
|
||
args: {
|
||
cmid: state.cmid,
|
||
contentscoid: scoid,
|
||
groupid: state.groupid,
|
||
},
|
||
}])[0].then((response) => {
|
||
setOverlay(false);
|
||
setButtonBusy(btn, false);
|
||
handleLaunchResponse(response);
|
||
return null;
|
||
}).catch((err) => {
|
||
setOverlay(false);
|
||
setButtonBusy(btn, false);
|
||
handleLaunchResponse(null, err);
|
||
});
|
||
});
|
||
});
|
||
|
||
panel.querySelectorAll(SELECTORS.downloadContent).forEach((link) => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
downloadContentFile(link);
|
||
});
|
||
});
|
||
|
||
panel.querySelectorAll('[data-action="inline-rename"]').forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const wrap = btn.closest('.meetlet-recname');
|
||
if (!wrap || wrap.classList.contains('is-editing')) {
|
||
return;
|
||
}
|
||
startInlineRename(wrap);
|
||
});
|
||
});
|
||
|
||
panel.onclick = (e) => {
|
||
const refreshBtn = e.target.closest(SELECTORS.refresh);
|
||
if (refreshBtn && panel.contains(refreshBtn)) {
|
||
e.preventDefault();
|
||
const tab = refreshBtn.getAttribute('data-tab') || state.current;
|
||
loadTab(tab, true);
|
||
return;
|
||
}
|
||
const btn = e.target.closest(SELECTORS.sessionDetails);
|
||
if (!btn || !panel.contains(btn)) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const sessions = readSessionsPayload(panel);
|
||
const index = parseInt(btn.getAttribute('data-session-index') || '0', 10);
|
||
const session = sessions[index] || {title: '', attendees: []};
|
||
openModal(session.title || '', session.attendees || []);
|
||
};
|
||
};
|
||
|
||
const loadTab = (tab, force) => {
|
||
const app = document.querySelector(SELECTORS.app);
|
||
if (!app) {
|
||
return Promise.resolve();
|
||
}
|
||
const panel = app.querySelector(SELECTORS.panel);
|
||
state.current = tab;
|
||
app.querySelectorAll(SELECTORS.tab).forEach((btn) => {
|
||
const active = btn.dataset.tab === tab;
|
||
btn.classList.toggle('is-active', active);
|
||
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
|
||
if (tab === 'details') {
|
||
showDetails(panel);
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (!force && state.cache[tab]) {
|
||
renderCached(panel, tab);
|
||
if (tab === 'recordings') {
|
||
ensureRecordingsPoll();
|
||
}
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return Str.get_string('loading', 'meetletacbfree').then((loading) => {
|
||
setLoading(panel, loading);
|
||
return Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_tab',
|
||
args: {cmid: state.cmid, tab: tab, groupid: state.groupid},
|
||
}])[0];
|
||
}).then((response) => {
|
||
state.cache[tab] = response.html;
|
||
panel.innerHTML = response.html;
|
||
bindPanelActions(panel);
|
||
if (tab === 'recordings') {
|
||
resetRecordingsPoll();
|
||
}
|
||
return null;
|
||
}).catch(Notification.exception);
|
||
};
|
||
|
||
const pollRecordingsBackground = () => {
|
||
if (!state.cache.recordings) {
|
||
return;
|
||
}
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_tab',
|
||
args: {cmid: state.cmid, tab: 'recordings', groupid: state.groupid},
|
||
}])[0].then((response) => {
|
||
state.cache.recordings = response.html;
|
||
if (state.current !== 'recordings') {
|
||
return null;
|
||
}
|
||
const app = document.querySelector(SELECTORS.app);
|
||
const panel = app && app.querySelector(SELECTORS.panel);
|
||
if (panel) {
|
||
panel.innerHTML = response.html;
|
||
bindPanelActions(panel);
|
||
}
|
||
return null;
|
||
}).catch(() => null);
|
||
};
|
||
|
||
const stopRecordingsPoll = () => {
|
||
if (state.recPollTimer) {
|
||
window.clearInterval(state.recPollTimer);
|
||
state.recPollTimer = null;
|
||
}
|
||
};
|
||
|
||
const resetRecordingsPoll = () => {
|
||
stopRecordingsPoll();
|
||
state.recPollTimer = window.setInterval(pollRecordingsBackground, RECORDINGS_POLL_MS);
|
||
};
|
||
|
||
const ensureRecordingsPoll = () => {
|
||
if (!state.recPollTimer && state.cache.recordings) {
|
||
resetRecordingsPoll();
|
||
}
|
||
};
|
||
|
||
const setOverlay = (show, kind) => {
|
||
const overlay = document.querySelector(SELECTORS.overlay);
|
||
if (!overlay) {
|
||
return;
|
||
}
|
||
if (show) {
|
||
const titleEl = overlay.querySelector('.meetlet-joinoverlay__title');
|
||
const textEl = overlay.querySelector('.meetlet-joinoverlay__text');
|
||
const isRecording = kind === 'recording';
|
||
if (titleEl) {
|
||
titleEl.textContent = isRecording
|
||
? (state.strRecordingTitle || titleEl.textContent)
|
||
: (state.strJoiningTitle || titleEl.textContent);
|
||
}
|
||
if (textEl) {
|
||
textEl.textContent = isRecording
|
||
? (state.strRecordingText || textEl.textContent)
|
||
: (state.strJoiningText || textEl.textContent);
|
||
}
|
||
overlay.removeAttribute('hidden');
|
||
} else {
|
||
overlay.setAttribute('hidden', 'hidden');
|
||
}
|
||
};
|
||
|
||
const showGateModal = (title, message) => {
|
||
showHelpError({
|
||
message: message || '',
|
||
helpcode: '',
|
||
detailhtml: '',
|
||
}, title);
|
||
};
|
||
|
||
const bindCopyCode = (root) => {
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.querySelectorAll('[data-copy-code]').forEach((el) => {
|
||
el.style.cursor = 'pointer';
|
||
el.title = el.getAttribute('data-copy-hint') || el.textContent || '';
|
||
el.addEventListener('click', () => {
|
||
const code = el.getAttribute('data-copy-code') || el.textContent || '';
|
||
if (!code || !navigator.clipboard) {
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
el.classList.add('is-copied');
|
||
window.setTimeout(() => el.classList.remove('is-copied'), 1200);
|
||
}).catch(() => null);
|
||
});
|
||
});
|
||
};
|
||
|
||
const showHelpError = (payload, title) => {
|
||
const modal = ensureModalShell();
|
||
const titleEl = modal.querySelector(SELECTORS.modalTitle);
|
||
const body = modal.querySelector(SELECTORS.modalBody);
|
||
if (titleEl) {
|
||
titleEl.textContent = title || '';
|
||
}
|
||
const code = (payload && (payload.helpcode || payload.code)) || '';
|
||
const message = (payload && payload.message) || '';
|
||
const detail = (payload && payload.detailhtml) || '';
|
||
if (body) {
|
||
if (detail) {
|
||
body.innerHTML = detail;
|
||
} else {
|
||
let html = '<div class="meetlet-err meetlet-err--user">';
|
||
html += '<p class="meetlet-err__lead">' + escapeHtml(message) + '</p>';
|
||
if (code) {
|
||
html += '<div class="meetlet-err__codebox">';
|
||
html += '<span class="meetlet-err__codelabel"></span>';
|
||
html += '<code data-copy-code="' + escapeHtml(code) + '">' + escapeHtml(code) + '</code>';
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
body.innerHTML = html;
|
||
}
|
||
bindCopyCode(body);
|
||
}
|
||
modalScroll().lock();
|
||
modal.removeAttribute('hidden');
|
||
modal.style.display = 'flex';
|
||
};
|
||
|
||
const handleLaunchResponse = (response, fallbackErr) => {
|
||
if (response && response.ok && response.url) {
|
||
window.open(response.url, '_blank', 'noopener');
|
||
return;
|
||
}
|
||
if (response && (response.message || response.detailhtml || response.helpcode)) {
|
||
Str.get_string('joindeniedtitle', 'meetletacbfree').then((title) => {
|
||
showHelpError(response, title);
|
||
return null;
|
||
}).catch(() => showHelpError(response, ''));
|
||
return;
|
||
}
|
||
if (fallbackErr) {
|
||
const msg = (fallbackErr.message || fallbackErr.error) ? (fallbackErr.message || fallbackErr.error) : '';
|
||
if (msg) {
|
||
Str.get_string('joindeniedtitle', 'meetletacbfree').then((title) => {
|
||
showHelpError({message: msg}, title);
|
||
return null;
|
||
}).catch(() => Notification.exception(fallbackErr));
|
||
} else {
|
||
Notification.exception(fallbackErr);
|
||
}
|
||
}
|
||
};
|
||
|
||
const applyMeetingStatus = (info) => {
|
||
state.joinable = !!info.joinable;
|
||
state.status = info.status || state.status;
|
||
let message = info.message || '';
|
||
if (!state.joinable && (info.status === 'waitinghost' || info.errorcode === 'waitingforhost')) {
|
||
// Prefer the clearer moderator-wait explanation for the join tip.
|
||
message = state.strJoinWaiting || message;
|
||
}
|
||
state.statusMessage = message;
|
||
const wrap = document.querySelector('[data-meetlet-status-wrap]');
|
||
const badge = document.querySelector('[data-meetlet-status]');
|
||
const joinBtn = document.querySelector(SELECTORS.join);
|
||
const joinWrap = document.querySelector('[data-join-wrap]');
|
||
if (wrap) {
|
||
wrap.setAttribute('data-status', state.status);
|
||
}
|
||
if (badge) {
|
||
badge.className = 'meetlet-status meetlet-status--' + state.status;
|
||
badge.textContent = info.statuslabel || badge.textContent;
|
||
if (!state.joinable && state.statusMessage) {
|
||
badge.setAttribute('data-tip', state.statusMessage);
|
||
badge.setAttribute('tabindex', '0');
|
||
} else {
|
||
badge.removeAttribute('data-tip');
|
||
badge.removeAttribute('tabindex');
|
||
}
|
||
}
|
||
if (joinBtn) {
|
||
joinBtn.classList.toggle('is-disabled', !state.joinable);
|
||
if (state.joinable) {
|
||
joinBtn.removeAttribute('aria-disabled');
|
||
} else {
|
||
joinBtn.setAttribute('aria-disabled', 'true');
|
||
}
|
||
}
|
||
if (joinWrap) {
|
||
joinWrap.classList.toggle('is-blocked', !state.joinable);
|
||
joinWrap.removeAttribute('data-tip');
|
||
}
|
||
};
|
||
|
||
const CIRC = 2 * Math.PI * 16;
|
||
const setRingProgress = (remaining, total) => {
|
||
const ring = document.querySelector('[data-status-ring]');
|
||
const count = document.querySelector('[data-status-countdown]');
|
||
if (count) {
|
||
count.textContent = String(remaining);
|
||
}
|
||
if (ring) {
|
||
const ratio = total > 0 ? (remaining / total) : 0;
|
||
ring.style.strokeDasharray = String(CIRC);
|
||
ring.style.strokeDashoffset = String(CIRC * (1 - ratio));
|
||
}
|
||
};
|
||
|
||
const pollMeetingStatus = () => {
|
||
return Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_meeting_status',
|
||
args: {cmid: state.cmid, groupid: state.groupid},
|
||
}])[0].then((info) => {
|
||
applyMeetingStatus(info);
|
||
return info;
|
||
}).catch(() => null);
|
||
};
|
||
|
||
const startStatusTicker = () => {
|
||
if (!state.licenseUsable || state.pollSeconds <= 0) {
|
||
return;
|
||
}
|
||
const total = state.pollSeconds || 15;
|
||
let remaining = total;
|
||
setRingProgress(remaining, total);
|
||
if (state.statusTimer) {
|
||
window.clearInterval(state.statusTimer);
|
||
}
|
||
const tick = () => {
|
||
remaining -= 1;
|
||
if (remaining <= 0) {
|
||
remaining = total;
|
||
setRingProgress(remaining, total);
|
||
pollMeetingStatus();
|
||
return;
|
||
}
|
||
setRingProgress(remaining, total);
|
||
};
|
||
state.statusTimer = window.setInterval(tick, 1000);
|
||
// After first paint / idle: ask Adobe for real room status, then every 15s via ticker.
|
||
const kickoff = () => {
|
||
pollMeetingStatus().then(() => {
|
||
remaining = total;
|
||
setRingProgress(remaining, total);
|
||
});
|
||
};
|
||
if (window.requestIdleCallback) {
|
||
window.requestIdleCallback(kickoff, {timeout: 1200});
|
||
} else {
|
||
window.setTimeout(kickoff, 0);
|
||
}
|
||
};
|
||
|
||
const joinMeeting = () => {
|
||
if (!state.joinable) {
|
||
Str.get_strings([
|
||
{key: 'joindeniedtitle', component: 'meetletacbfree'},
|
||
]).then((strings) => {
|
||
const msg = (state.status === 'waitinghost' || state.status === 'checking')
|
||
? (state.strJoinWaiting || state.statusMessage || '')
|
||
: (state.statusMessage || '');
|
||
showGateModal(strings[0], msg);
|
||
return null;
|
||
}).catch(() => {
|
||
showGateModal('', state.statusMessage || '');
|
||
});
|
||
return;
|
||
}
|
||
const joinBtn = document.querySelector(SELECTORS.join);
|
||
if (joinBtn) {
|
||
joinBtn.classList.add('is-busy');
|
||
}
|
||
setOverlay(true, 'meeting');
|
||
Ajax.call([{
|
||
methodname: 'mod_meetletacbfree_get_join_url',
|
||
args: {cmid: state.cmid, groupid: state.groupid},
|
||
}])[0].then((response) => {
|
||
setOverlay(false);
|
||
if (joinBtn) {
|
||
joinBtn.classList.remove('is-busy');
|
||
}
|
||
handleLaunchResponse(response);
|
||
return null;
|
||
}).catch((err) => {
|
||
setOverlay(false);
|
||
if (joinBtn) {
|
||
joinBtn.classList.remove('is-busy');
|
||
}
|
||
handleLaunchResponse(null, err);
|
||
pollMeetingStatus();
|
||
});
|
||
};
|
||
|
||
const cfgEl = document.querySelector(SELECTORS.config);
|
||
if (!cfgEl) {
|
||
return;
|
||
}
|
||
let config = {};
|
||
try {
|
||
config = JSON.parse(cfgEl.getAttribute('data-meetlet-config'));
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
|
||
state.cmid = config.cmid;
|
||
state.groupid = config.groupid || 0;
|
||
state.pollSeconds = parseInt(config.statusPollSeconds || '15', 10) || 15;
|
||
state.strJoinWaiting = config.strJoinWaiting || '';
|
||
state.strJoiningTitle = config.strJoiningTitle || '';
|
||
state.strJoiningText = config.strJoiningText || '';
|
||
state.strRecordingTitle = config.strRecordingTitle || '';
|
||
state.strRecordingText = config.strRecordingText || '';
|
||
state.licenseUsable = config.licenseUsable !== false;
|
||
if (typeof config.joinable === 'boolean') {
|
||
state.joinable = config.joinable;
|
||
}
|
||
if (config.status) {
|
||
state.status = config.status;
|
||
}
|
||
if (config.statusMessage) {
|
||
state.statusMessage = config.statusMessage;
|
||
}
|
||
modalLabels = {
|
||
name: config.strAttendeeName || '',
|
||
login: config.strAttendeeLogin || '',
|
||
timein: config.strTimeIn || '',
|
||
timeout: config.strTimeOut || '',
|
||
empty: config.strNoAttendance || '',
|
||
};
|
||
|
||
const app = document.querySelector(SELECTORS.app);
|
||
if (!app) {
|
||
return;
|
||
}
|
||
|
||
bindFloatTips(app);
|
||
window.addEventListener('scroll', () => {
|
||
if (floatTipTarget) {
|
||
hideFloatTip();
|
||
}
|
||
}, true);
|
||
window.addEventListener('resize', hideFloatTip);
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
hideFloatTip();
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('pagehide', stopRecordingsPoll);
|
||
|
||
app.querySelectorAll(SELECTORS.tab).forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
loadTab(btn.dataset.tab, false);
|
||
});
|
||
});
|
||
const joinBtn = app.querySelector(SELECTORS.join);
|
||
if (joinBtn) {
|
||
joinBtn.addEventListener('click', joinMeeting);
|
||
}
|
||
|
||
startStatusTicker();
|
||
loadTab('details', false);
|
||
}
|
||
|
||
return {
|
||
init: init
|
||
};
|
||
});
|