"use strict"; /* ============================================================ * CONFIGURATION * ============================================================ */ /* * Configuration lives in assets/config.json and is loaded * asynchronously at startup (see START). */ let CONFIG = {}; const CONFIG_URL = "assets/config.json"; /* ============================================================ * Translations * ============================================================ */ /* * Translations live in assets/i18n.json and are loaded * asynchronously at startup (see START). */ let TRANSLATIONS = {}; const I18N_URL = "assets/i18n.json"; /* ============================================================ * Detect language * ============================================================ */ function detectLanguage() { /* * Saved language choice takes precedence. */ try { const saved = localStorage.getItem("jitsiLauncherLanguage"); if (saved && TRANSLATIONS[saved]) { return saved; } } catch (error) { /* * localStorage can be blocked, e.g. in private mode. */ } const languages = navigator.languages || [navigator.language]; for (const language of languages) { if (!language) { continue; } const languageCode = language .toLowerCase() .split("-")[0]; if (TRANSLATIONS[languageCode]) { return languageCode; } } /* * English is the default/fallback. */ return "en"; } let currentLanguage = "en"; let translation = {}; /* ============================================================ * Apply translations to webpage * ============================================================ */ function applyTranslations() { document.documentElement.lang = currentLanguage; if (translation.pageTitle) { document.title = translation.pageTitle; } document .querySelectorAll("[data-i18n]") .forEach((element) => { const key = element.dataset.i18n; const value = getTranslation(key); if (value !== null) { element.textContent = value; } }); /* * Translate placeholders of input fields. */ document .querySelectorAll("[data-i18n-placeholder]") .forEach((element) => { const value = getTranslation(element.dataset.i18nPlaceholder); if (value !== null) { element.placeholder = value; } }); } /* * Read a translation by path. * * Example: * * "downloads.windows.title" * * resolves to: * * translation.downloads.windows.title */ function getTranslation(key) { const parts = key.split("."); let value = translation; for (const part of parts) { if ( value === null || value === undefined || typeof value !== "object" || !(part in value) ) { return null; } value = value[part]; } return typeof value === "string" ? value : null; } /* ============================================================ * LANGUAGE MENU * ============================================================ */ const LANGUAGE_NAMES = { en: "English", de: "Deutsch", fr: "Français", zh: "中文" }; const LANGUAGE_FLAGS = { en: "assets/images/uk.svg", de: "assets/images/de.svg", fr: "assets/images/fr.svg", zh: "assets/images/cn.svg" }; function updateLanguageMenu() { langFlag.src = LANGUAGE_FLAGS[currentLanguage]; langLabel.textContent = LANGUAGE_NAMES[currentLanguage]; langDropdown .querySelectorAll("button[data-lang]") .forEach((item) => { item.classList.toggle( "active", item.dataset.lang === currentLanguage ); }); } function setLanguage(language) { if (!TRANSLATIONS[language]) { return; } currentLanguage = language; translation = TRANSLATIONS[language]; try { localStorage.setItem("jitsiLauncherLanguage", language); } catch (error) { /* * localStorage can be blocked, e.g. in private mode. */ } applyTranslations(); updateLanguageMenu(); } function toggleLanguageMenu() { const isOpen = langDropdown.classList.toggle("open"); langButton.setAttribute( "aria-expanded", isOpen ? "true" : "false" ); } function closeLanguageMenu() { langDropdown.classList.remove("open"); langButton.setAttribute("aria-expanded", "false"); } /* ============================================================ * NAV MENU (Hamburger) * ============================================================ */ function toggleNavMenu() { const isOpen = navActions.classList.toggle("open"); navToggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); } function closeNavMenu() { navActions.classList.remove("open"); navToggle.setAttribute("aria-expanded", "false"); } /* ============================================================ * THEME * ============================================================ */ const THEME_KEY = "jitsiLauncherTheme"; function savedTheme() { try { const saved = localStorage.getItem(THEME_KEY); if (saved === "dark" || saved === "light") { return saved; } } catch (error) { /* * localStorage can be blocked, e.g. in private mode. */ } return null; } function systemIsDark() { return window.matchMedia("(prefers-color-scheme: dark)").matches; } function detectTheme() { /* * Saved choice takes precedence, otherwise system setting. */ return savedTheme() || (systemIsDark() ? "dark" : "light"); } let currentTheme = detectTheme(); function updateThemeToggle() { themeIconSun.classList.toggle("hidden", currentTheme !== "dark"); themeIconMoon.classList.toggle("hidden", currentTheme === "dark"); } function applyTheme() { document.documentElement.classList.toggle("dark", currentTheme === "dark"); updateThemeToggle(); } function toggleTheme() { currentTheme = currentTheme === "dark" ? "light" : "dark"; try { localStorage.setItem(THEME_KEY, currentTheme); } catch (error) { /* * localStorage can be blocked, e.g. in private mode. */ } applyTheme(); } /* ============================================================ * BACKGROUND IMAGE * ============================================================ */ function applyBackgroundImage() { if (!CONFIG.backgroundImage) { return; } document.body.style.backgroundImage = `url("${CONFIG.backgroundImage}")`; document.body.style.backgroundSize = "cover"; document.body.style.backgroundPosition = "center"; } /* ============================================================ * LOGO * ============================================================ */ function applyLogo() { if (!CONFIG.logo) { return; } logoImage.src = CONFIG.logo; logoBar.classList.remove("hidden"); } /* ============================================================ * FAVICONS * ============================================================ */ /* * Overwrite the default icons from launch.html * (CONFIG.favicons in assets/config.json). */ function applyFavicons() { if (!CONFIG.favicons) { return; } if (CONFIG.favicons.ico) { faviconIco.href = CONFIG.favicons.ico; } if (CONFIG.favicons.svg) { faviconSvg.href = CONFIG.favicons.svg; } if (CONFIG.favicons.appleTouch) { appleTouchIcon.href = CONFIG.favicons.appleTouch; } } /* ============================================================ * NEXTCLOUD BUTTON * ============================================================ */ /* * Only show if a Nextcloud instance is configured * (CONFIG.nextcloudBase in assets/config.json). */ function applyNextcloudButton() { if (!CONFIG.nextcloudBase || !createNextcloudButton) { return; } createNextcloudButton.classList.remove("hidden"); } /* ============================================================ * ADHOC BUTTON * ============================================================ */ /* * Only show if explicitly enabled * (CONFIG.allowAdhocMeetings in assets/config.json). */ function applyAdhocButton() { if (!CONFIG.allowAdhocMeetings || !createAdhocButton) { return; } createAdhocButton.classList.remove("hidden"); } /* ============================================================ * DOM elements * ============================================================ */ const launcher = document.getElementById("launcher"); const starting = document.getElementById("starting"); const fallback = document.getElementById("fallback"); const invalidRoom = document.getElementById("invalidRoom"); const meetingCreator = document.getElementById("meetingCreator"); const roomName = document.getElementById("roomName"); const openButton = document.getElementById("openButton"); const retryButton = document.getElementById("retryButton"); const browserLink = document.getElementById("browserLink"); const windowsDownload = document.getElementById("windowsDownload"); const macosDownload = document.getElementById("macosDownload"); const linuxDownload = document.getElementById("linuxDownload"); const iosDownload = document.getElementById("iosDownload"); const androidDownload = document.getElementById("androidDownload"); const otherDownload = document.getElementById("otherDownload"); const windowsLink = document.getElementById("windowsLink"); const macosLink = document.getElementById("macosLink"); const linuxLink = document.getElementById("linuxLink"); const iosLink = document.getElementById("iosLink"); const androidLink = document.getElementById("androidLink"); const createNewMeetingButton = document.getElementById("createNewMeetingButton"); const createNewFromLauncherButton = document.getElementById("createNewFromLauncherButton"); /* Meeting Creator DOM elements */ const meetingNameInput = document.getElementById("meetingName"); const meetingDescriptionInput = document.getElementById("meetingDescription"); const meetingStartInput = document.getElementById("meetingStart"); const meetingEndInput = document.getElementById("meetingEnd"); const meetingOrganizerInput = document.getElementById("meetingOrganizer"); const createMeetingButton = document.getElementById("createMeetingButton"); const createNextcloudButton = document.getElementById('createNextcloudButton'); const createAdhocButton = document.getElementById("createAdhocButton"); const cancelCreateButton = document.getElementById("cancelCreateButton"); const icsSuccess = document.getElementById("icsSuccess"); const nextcloudStatus = document.getElementById('nextcloudStatus'); const nextcloudError = document.getElementById('nextcloudError'); const downloadIcsLink = document.getElementById("downloadIcsLink"); const adhocResult = document.getElementById("adhocResult"); const adhocLink = document.getElementById("adhocLink"); const copyAdhocLinkButton = document.getElementById("copyAdhocLinkButton"); /* Navbar DOM elements */ const navbar = document.querySelector(".navbar"); const navToggle = document.getElementById("navToggle"); const navActions = document.getElementById("navActions"); /* Language menu DOM elements */ const langMenu = document.getElementById("langMenu"); const langButton = document.getElementById("langButton"); const langDropdown = document.getElementById("langDropdown"); const langFlag = document.getElementById("langFlag"); const langLabel = document.getElementById("langLabel"); /* Invalid room view DOM elements */ const missingRoomTitle = document.getElementById("missingRoomTitle"); const missingRoomError = document.getElementById("missingRoomError"); const invalidNameTitle = document.getElementById("invalidNameTitle"); const invalidNameError = document.getElementById("invalidNameError"); const invalidRoomName = document.getElementById("invalidRoomName"); /* Logo DOM elements */ const logoBar = document.getElementById("logoBar"); const logoImage = document.getElementById("logoImage"); /* Favicon DOM elements */ const faviconIco = document.getElementById("faviconIco"); const faviconSvg = document.getElementById("faviconSvg"); const appleTouchIcon = document.getElementById("appleTouchIcon"); /* Theme DOM elements */ const themeButton = document.getElementById("themeButton"); const themeIconSun = document.getElementById("themeIconSun"); const themeIconMoon = document.getElementById("themeIconMoon"); /* ============================================================ * GET ROOM FROM QUERY-PARAMETER * ============================================================ * * Example: * * https:///launch.html?room=abc-defg-hij * * results in: * * abc-defg-hij * * ============================================================ */ function getRoom() { const params = new URLSearchParams( window.location.search ); /* * Parameter missing. */ if (!params.has("room")) { return null; } /* * Prevent whitespace etc. * * "?room=" without a value yields an empty string. */ return params.get("room").trim(); } /* ============================================================ * VALIDATE ROOM * ============================================================ * * Jitsi room names can contain rather different * characters depending on the configuration. * * We only prevent control characters and obvious * URL manipulation here. * * ============================================================ */ const ROOM_PATTERN = /^[A-Za-z0-9-]+$/; function isValidRoom(room) { if (!room) { return false; } /* * Only letters, numbers and hyphens. */ return ROOM_PATTERN.test(room); } /* ============================================================ * JITSI WEB URL * ============================================================ */ function createBrowserUrl(room) { return ( "https://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * JITSI DESKTOP PROTOCOL URL * ============================================================ */ function createAppUrl(room) { return ( "jitsi-meet://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * JITSI ANDROID URL * ============================================================ */ /* * The Android app registers "org.jitsi.meet", not the * desktop scheme. Chrome & co. require intent://; without * the app Chrome offers the Play Store entry from "package=". * Firefox ignores intent:// but knows the custom scheme. */ function createAndroidIntentUrl(room) { return ( "intent://" + CONFIG.jitsiHost + "/" + encodeURIComponent(room) + "#Intent;scheme=org.jitsi.meet;package=org.jitsi.meet;end" ); } /* * The mobile apps (iOS, Firefox on Android) register * "org.jitsi.meet", not the desktop scheme. */ const APP_SCHEME = "org.jitsi.meet://"; function createAppSchemeUrl(room) { return ( APP_SCHEME + CONFIG.jitsiHost + "/" + encodeURIComponent(room) ); } /* ============================================================ * URL BY PLATFORM/BROWSER * ============================================================ */ function selectProtocolUrl(room) { const os = detectOperatingSystem(); /* * The iOS app only knows the app scheme. */ if (os === "ios") { return createAppSchemeUrl(room); } if (os !== "android") { return createAppUrl(room); } return detectBrowser() === "firefox" ? createAppSchemeUrl(room) : createAndroidIntentUrl(room); } /* ============================================================ * DETECT OPERATING SYSTEM * ============================================================ */ /* * Parse once (assets/lib/ua-parser.min.js). * If the library is missing, the page stays usable. */ const uaResult = typeof UAParser === "function" ? new UAParser().getResult() : { os: {}, browser: {} }; /* * UAParser names -> internal shortcuts. * iPadOS reports as macOS – touch points count as iOS. */ const OS_NAMES = { Android: "android", iOS: "ios", Windows: "windows", macOS: "macos", Linux: "linux", Ubuntu: "linux", Debian: "linux", Fedora: "linux", Mint: "linux", CentOS: "linux", openSUSE: "linux", Raspbian: "linux" }; const BROWSER_NAMES = { Edge: "edge", "Mobile Edge": "edge", Firefox: "firefox", "Mobile Firefox": "firefox", Chrome: "chrome", "Mobile Chrome": "chrome", "Chrome Headless": "chrome", "Chrome WebView": "chrome", "Samsung Internet": "chrome", Safari: "safari", "Mobile Safari": "safari" }; function detectOperatingSystem() { /* * Chrome in "desktop mode" on Android sends a desktop * UA (e.g. X11; Linux). Client hints reveal the real * system. Firefox/Safari do not support them. */ const hints = navigator.userAgentData; if (hints && hints.platform === "Android") { return OS_NAMES.Android; } /* * iPadOS masquerades as macOS – touch points give the * iPad away. The platform check also works without the * ua-parser loaded. */ if ( navigator.maxTouchPoints > 1 && (navigator.platform === "MacIntel" || uaResult.os.name === "macOS") ) { return OS_NAMES.iOS; } return OS_NAMES[uaResult.os.name] || "unknown"; } /* ============================================================ * DETECT BROWSER * ============================================================ */ function detectBrowser() { return BROWSER_NAMES[uaResult.browser.name] || "unknown"; } /* ============================================================ * DOWNLOADS * ============================================================ */ function configureDownloads() { const os = detectOperatingSystem(); windowsDownload.classList.add("hidden"); macosDownload.classList.add("hidden"); linuxDownload.classList.add("hidden"); iosDownload.classList.add("hidden"); androidDownload.classList.add("hidden"); otherDownload.classList.add("hidden"); /* Windows */ if (os === "windows") { windowsLink.href = CONFIG.downloads.windows; windowsDownload.classList.remove("hidden"); return; } /* macOS */ if (os === "macos") { macosLink.href = CONFIG.downloads.macos; macosDownload.classList.remove("hidden"); return; } /* Linux */ if (os === "linux") { linuxLink.href = CONFIG.downloads.linux; linuxDownload.classList.remove("hidden"); return; } /* iOS */ if (os === "ios") { iosLink.href = CONFIG.downloads.ios; iosDownload.classList.remove("hidden"); return; } /* Android */ if (os === "android") { androidLink.href = CONFIG.downloads.android; androidDownload.classList.remove("hidden"); return; } /* Everything else */ otherDownload.classList.remove("hidden"); } /* ============================================================ * SHOW FALLBACK * ============================================================ */ function showFallback() { starting.classList.add("hidden"); launcher.classList.add("hidden"); fallback.classList.remove("hidden"); configureDownloads(); } /* ============================================================ * START JITSI DESKTOP * ============================================================ */ function launchJitsi() { const room = getRoom(); if (!isValidRoom(room)) { return; } const protocolUrl = selectProtocolUrl(room); const browserUrl = createBrowserUrl(room); /* * Prepare the browser fallback. */ browserLink.href = browserUrl; /* * Set the UI to the "starting" state. */ launcher.classList.add("hidden"); fallback.classList.add("hidden"); starting.classList.remove("hidden"); let completed = false; let timeout = null; /* * Invisible iframe for the protocol handler. */ const iframe = document.createElement("iframe"); iframe.style.display = "none"; /* * Clean up. */ function cleanup() { if (completed) { return; } completed = true; if (timeout !== null) { clearTimeout(timeout); } iframe.remove(); } /* * -------------------------------------------------------- * IMPORTANT: * * Do not use "blur" as a success criterion anymore. * * The browser can also trigger blur when showing * the protocol dialog. * * If the user cancels the dialog, the page stays * visible and our timeout keeps running. * -------------------------------------------------------- */ document.body.appendChild(iframe); /* * Start the protocol handler. * * Firefox ignores external protocols from iframes – * start the custom scheme directly in the main navigation. */ try { if (protocolUrl.startsWith(APP_SCHEME)) { window.location.href = protocolUrl; } else { iframe.src = protocolUrl; } } catch (error) { console.error( "Error opening the Jitsi Protocol Handler:", error ); cleanup(); showFallback(); return; } /* * -------------------------------------------------------- * FALLBACK * -------------------------------------------------------- * * We wait regardless of whether the browser briefly * loses focus. * * If Jitsi starts successfully: * * -> Jitsi comes to the foreground * -> the user no longer sees this page * * If the user cancels the browser dialog: * * -> this page stays visible * -> the timeout expires * -> the fallback is shown * -------------------------------------------------------- */ timeout = setTimeout(() => { if (completed) { return; } cleanup(); showFallback(); }, CONFIG.launchTimeout); } /* ============================================================ * ROOM NAME * ============================================================ */ /* * Convert special characters to the closest ASCII * character. * * Example: * * "Grundstück" becomes "Grundstueck" * "François" becomes "Francois" */ function transliterate(text) { const extras = { "ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe", "ø": "o", "Ø": "o" }; return text .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[ßẞæÆœŒøØ]/g, (char) => extras[char]); } /* * Derive a valid room name from the meeting name. * * Example: * * "Grund Stück Planung" becomes "grund-stuck-planung" */ function createRoomName(meetingName) { return transliterate(meetingName) .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, ""); } /* ============================================================ * ICS FILE GENERATION * ============================================================ */ const ICS_LINE_LIMIT = 75; /* * UTF-8 byte length of a string. */ function byteLength(text) { let length = 0; for (const char of text) { const code = char.codePointAt(0); length += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4; } return length; } /* * Escape a text value per RFC 5545: backslash first, * then semicolon and comma, newlines as literal "\n". */ function escapeICS(text) { return text .replace(/\\/g, "\\\\") .replace(/;/g, "\\;") .replace(/,/g, "\\,") .replace(/\r?\n/g, "\\n"); } /* * Fold lines longer than 75 octets into CRLF + space * continuations (RFC 5545). */ function foldICS(content) { return content .split("\r\n") .map((line) => { if (byteLength(line) <= ICS_LINE_LIMIT) { return line; } const parts = []; let current = ""; for (const char of line) { if (byteLength(current + char) > ICS_LINE_LIMIT) { parts.push(current); current = " " + char; } else { current += char; } } parts.push(current); return parts.join("\r\n"); }) .join("\r\n"); } function generateICS(meetingName, description, startTime, endTime, organizer) { /* Generate a unique room name from the meeting name */ const roomName = createRoomName(meetingName); const meetingUrl = createLaunchUrl(roomName); /* Format dates for ICS */ const formatICSDate = (date) => { const pad = (num) => num.toString().padStart(2, '0'); const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); const seconds = pad(date.getSeconds()); return `${year}${month}${day}T${hours}${minutes}${seconds}`; }; const uid = `jitsi-${Date.now()}@${CONFIG.jitsiHost}`; const dtStamp = formatICSDate(new Date()); const dtStart = formatICSDate(startTime); const dtEnd = formatICSDate(endTime); /* * One property per line, CRLF endings, folded at * 75 octets (RFC 5545). */ const lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", `PRODID:-//Jitsi Meeting Launcher//NONSGML (${CONFIG.jitsiLauncher})//EN`, "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", `UID:${uid}`, `DTSTAMP:${dtStamp}`, `DTSTART:${dtStart}`, `DTEND:${dtEnd}`, `SUMMARY:${escapeICS(meetingName)}`, `DESCRIPTION:${escapeICS(description)}`, `LOCATION:${meetingUrl}`, `ORGANIZER:MAILTO:${organizer}`, "STATUS:CONFIRMED", `URL:${meetingUrl}`, "SEQUENCE:0", "TRANSP:OPAQUE", "BEGIN:VALARM", "ACTION:DISPLAY", "DESCRIPTION:Reminder", "TRIGGER:-PT15M", "END:VALARM", "END:VEVENT", "END:VCALENDAR" ]; const icsContent = foldICS(lines.join("\r\n")) + "\r\n"; return { content: icsContent, roomName: roomName, url: meetingUrl }; } let icsObjectUrl = null; /* * Download URL for the ICS content. Revokes the URL of * the previous download. */ function downloadICS(content) { const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); if (icsObjectUrl !== null) { URL.revokeObjectURL(icsObjectUrl); } icsObjectUrl = URL.createObjectURL(blob); return icsObjectUrl; } const MINUTES_STEP = 15; const DEFAULT_DURATION_MINUTES = 30; /* * Round minutes up to the 15-minute grid. * * Example: * * 14:07 becomes 14:15 * 14:50 becomes 15:00 */ function roundMinutes(date) { const rounded = new Date(date); rounded.setSeconds(0, 0); rounded.setMinutes( Math.ceil(date.getMinutes() / MINUTES_STEP) * MINUTES_STEP ); return rounded; } /* * Format local time for datetime-local. * * Example: * * January 1, 2026, 14:15 becomes "2026-01-01T14:15" */ function formatLocal(date) { const pad = (num) => num.toString().padStart(2, "0"); return ( date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "T" + pad(date.getHours()) + ":" + pad(date.getMinutes()) ); } function showMeetingCreator() { /* Hide all other views */ launcher.classList.add("hidden"); starting.classList.add("hidden"); fallback.classList.add("hidden"); invalidRoom.classList.add("hidden"); /* Show meeting creator */ meetingCreator.classList.remove("hidden"); /* Reset form */ meetingNameInput.value = ''; meetingDescriptionInput.value = ''; meetingStartInput.value = ''; meetingEndInput.value = ''; meetingOrganizerInput.value = ''; icsSuccess.classList.add("hidden"); adhocResult.classList.add("hidden"); /* Set default start and end times */ const start = roundMinutes(new Date()); const end = new Date( start.getTime() + DEFAULT_DURATION_MINUTES * 60 * 1000 ); meetingStartInput.value = formatLocal(start); meetingEndInput.value = formatLocal(end); } function openNextcloudCalendarCreator() { const meetingName = meetingNameInput.value.trim(); const description = meetingDescriptionInput.value.trim(); const startTime = meetingStartInput.value; const endTime = meetingEndInput.value; const organizer = meetingOrganizerInput.value.trim(); if (!meetingName) { alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name.'); meetingNameInput.focus(); return; } if (!startTime || !endTime) { alert(getTranslation('creator.startTimeRequired') || 'Please select a start time.'); return; } const start = new Date(startTime); const end = new Date(endTime); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { alert('Invalid start or end date.'); return; } if (end <= start) { alert(getTranslation('creator.endAfterStart') || 'End time must be after start time.'); return; } /* * Nextcloud validates the email server-side and * aborts with an error page otherwise. */ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (organizer && !emailPattern.test(organizer)) { alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address.'); return; } /* * Use exactly the same room-generation logic as the * existing ICS creation. */ const roomName = createRoomName(meetingName); const meetingUrl = createLaunchUrl(roomName); const params = new URLSearchParams(); params.set('summary', meetingName); if (description) { params.set('description', description); } params.set('start', startTime); params.set('end', endTime); if (organizer) { params.set('organizer', organizer); } params.set('location', meetingUrl); params.set('url', meetingUrl); const nextcloudUrl = CONFIG.nextcloudBase + '/index.php/apps/atocal/confirm?' + params.toString(); window.location.href = nextcloudUrl; } function createMeeting() { const meetingName = meetingNameInput.value.trim(); const description = meetingDescriptionInput.value.trim(); const startTime = new Date(meetingStartInput.value); const endTime = new Date(meetingEndInput.value); const organizer = meetingOrganizerInput.value.trim(); if (!meetingName) { alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name'); return; } if (!ROOM_PATTERN.test(createRoomName(meetingName))) { alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens'); return; } if (!meetingStartInput.value) { alert(getTranslation('creator.startTimeRequired') || 'Please select a start time'); return; } if (!meetingEndInput.value) { alert(getTranslation('creator.endTimeRequired') || 'Please select an end time'); return; } if (startTime >= endTime) { alert(getTranslation('creator.endAfterStart') || 'End time must be after start time'); return; } if (!organizer) { alert(getTranslation('creator.organizerRequired') || 'Please enter an organizer email'); return; } const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(organizer)) { alert(getTranslation('creator.organizerInvalid') || 'Please enter a valid organizer email address'); return; } /* Generate ICS */ const icsData = generateICS(meetingName, description || meetingName, startTime, endTime, organizer); /* Create download link */ downloadIcsLink.href = downloadICS(icsData.content); downloadIcsLink.download = `${icsData.roomName}.ics`; /* Show success message */ icsSuccess.classList.remove("hidden"); adhocResult.classList.add("hidden"); /* Scroll to success message */ icsSuccess.scrollIntoView({ behavior: 'smooth' }); } /* ============================================================ * ADHOC LINK * ============================================================ */ /* * Time span after which the "Copied!" text returns * to the normal button label. */ const COPY_RESET_MS = 2000; /* * Launch URL that opens the meeting in the Jitsi client. * * Example: * * "projekt-kickoff" becomes * https://meet.bnvb.eu/launch.html?room=projekt-kickoff */ function createLaunchUrl(roomName) { return `https://${CONFIG.jitsiLauncher}/launch.html?room=${encodeURIComponent(roomName)}`; } function createAdhocLink() { const meetingName = meetingNameInput.value.trim(); if (!meetingName) { alert(getTranslation('creator.meetingNameRequired') || 'Please enter a meeting name'); return; } const roomName = createRoomName(meetingName); if (!ROOM_PATTERN.test(roomName)) { alert(getTranslation('creator.meetingNameInvalid') || 'Meeting name may only contain letters, numbers and hyphens'); return; } const url = createLaunchUrl(roomName); adhocLink.href = url; adhocLink.textContent = url; icsSuccess.classList.add("hidden"); adhocResult.classList.remove("hidden"); adhocResult.scrollIntoView({ behavior: 'smooth' }); } async function copyAdhocLink() { const url = adhocLink.href; /* Clipboard API, with a fallback for older browsers */ try { await navigator.clipboard.writeText(url); } catch (error) { const input = document.createElement('textarea'); input.value = url; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); } /* Brief feedback, then back to the normal label */ copyAdhocLinkButton.textContent = getTranslation('creator.copiedLabel') || 'Copied!'; setTimeout(() => { copyAdhocLinkButton.textContent = getTranslation('creator.copyLinkButton') || 'Copy Link'; }, COPY_RESET_MS); } function cancelCreateMeeting() { meetingCreator.classList.add("hidden"); /* If there was a room parameter, show the launcher */ const room = getRoom(); if (isValidRoom(room)) { roomName.textContent = room; browserLink.href = createBrowserUrl(room); launcher.classList.remove("hidden"); } else { invalidRoom.classList.remove("hidden"); } } /* ============================================================ * INITIALIZATION * ============================================================ */ function initialize() { const room = getRoom(); /* * No room parameter: hide the messages, * only show the Create button. */ if (room === null) { missingRoomTitle.classList.add("hidden"); missingRoomError.classList.add("hidden"); invalidNameTitle.classList.add("hidden"); invalidNameError.classList.add("hidden"); invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Empty room parameter ("?room="): * show "No meeting specified". */ if (room === "") { missingRoomTitle.classList.remove("hidden"); missingRoomError.classList.remove("hidden"); invalidNameTitle.classList.add("hidden"); invalidNameError.classList.add("hidden"); invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Invalid room parameter: only show * the invalid-name message. */ if (!isValidRoom(room)) { missingRoomTitle.classList.add("hidden"); missingRoomError.classList.add("hidden"); invalidNameTitle.classList.remove("hidden"); invalidNameError.classList.remove("hidden"); invalidRoomName.textContent = room; invalidRoom.classList.remove("hidden"); createNewMeetingButton.classList.remove("hidden"); return; } /* * Show the room. */ roomName.textContent = room; /* * Prepare the browser fallback. */ browserLink.href = createBrowserUrl(room); /* * Show the launcher. */ launcher.classList.remove("hidden"); /* * Hide the "Create New Meeting" button when a valid room is specified */ createNewFromLauncherButton.classList.add("hidden"); /* * Debug information. */ console.log( "Jitsi Launcher", { language: currentLanguage, room: room, operatingSystem: detectOperatingSystem(), touchPoints: navigator.maxTouchPoints, browser: detectBrowser(), protocol: selectProtocolUrl(room), browserUrl: createBrowserUrl(room) } ); } /* ============================================================ * EVENTS * ============================================================ */ openButton.addEventListener( "click", launchJitsi ); retryButton.addEventListener( "click", launchJitsi ); /* Meeting Creator Events */ createNewMeetingButton.addEventListener( "click", showMeetingCreator ); createNewFromLauncherButton.addEventListener( "click", showMeetingCreator ); createMeetingButton.addEventListener( "click", createMeeting ); if (createNextcloudButton) { createNextcloudButton.addEventListener( 'click', openNextcloudCalendarCreator ); } createAdhocButton.addEventListener( "click", createAdhocLink ); copyAdhocLinkButton.addEventListener( "click", copyAdhocLink ); cancelCreateButton.addEventListener( "click", cancelCreateMeeting ); /* Language menu events */ langButton.addEventListener( "click", toggleLanguageMenu ); langDropdown.addEventListener( "click", (event) => { const item = event.target.closest("button[data-lang]"); if (!item) { return; } setLanguage(item.dataset.lang); closeLanguageMenu(); } ); document.addEventListener( "click", (event) => { if (!langMenu.contains(event.target)) { closeLanguageMenu(); } if (!navbar.contains(event.target)) { closeNavMenu(); } } ); /* Theme events */ themeButton.addEventListener( "click", toggleTheme ); /* Hamburger events */ navToggle.addEventListener( "click", toggleNavMenu ); /* * Follow the system if no choice is saved. */ window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", () => { if (savedTheme() === null) { currentTheme = systemIsDark() ? "dark" : "light"; applyTheme(); } }); /* ============================================================ * START * ============================================================ */ async function loadConfig() { try { const response = await fetch(CONFIG_URL); CONFIG = await response.json(); } catch (error) { /* * Without the file the launcher stays unusable – * report the error and continue with an empty config. */ console.error("Error loading config:", error); } } async function loadTranslations() { try { const response = await fetch(I18N_URL); TRANSLATIONS = await response.json(); } catch (error) { /* * Without the file the English text from the HTML stays. */ console.error("Error loading translations:", error); } currentLanguage = detectLanguage(); translation = TRANSLATIONS[currentLanguage] || {}; } /* * Apply theme/logo immediately so nothing flickers – * text only after the translations have loaded. */ async function start() { applyTheme(); await loadConfig(); applyBackgroundImage(); applyLogo(); applyFavicons(); applyNextcloudButton(); applyAdhocButton(); await loadTranslations(); applyTranslations(); updateLanguageMenu(); initialize(); } start();