Files
jitsi-launch/assets/launch.js
T
stefan.tollkuehn 7961732323 Show Windows privacy hints in launcher
The launcher sentence around the room name was replaced
by a standalone instruction ("meetingText"); the room name
now sits on its own line below it.

Windows users additionally get camera/microphone
permission instructions in the launcher view. The block
reuses the existing i18n/OS detection and only appears
when translations loaded, so a failed i18n.json fetch
cannot render an empty box.
2026-11-05 16:36:43 +01:00

1676 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
/*
* localStorage is scoped per origin, not per path.
* Prefix keys with the app directory so parallel
* instances (e.g. meet.bnvb.eu/i and /p) stay
* independent.
*
* "/p/launch.html" becomes "/p/"
*/
const STORAGE_PREFIX =
window.location.pathname.replace(/[^/]*$/, "");
const LANGUAGE_KEY = STORAGE_PREFIX + "jitsiLauncherLanguage";
const THEME_KEY = STORAGE_PREFIX + "jitsiLauncherTheme";
/* ============================================================
* 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(LANGUAGE_KEY);
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(LANGUAGE_KEY, 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
* ============================================================
*/
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");
}
/* ============================================================
* LEGAL NOTICE LINK
* ============================================================ */
/*
* Only show if a legal notice URL is configured
* (CONFIG.legalNotice in assets/config.json).
*/
function applyLegalNoticeLink() {
if (!CONFIG.legalNotice || !legalNoticeLink) {
return;
}
legalNoticeLink.href = CONFIG.legalNotice;
legalNoticeLink.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");
const privacyInfo = document.getElementById("privacyInfo");
/* 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");
const legalNoticeLink = document.getElementById("legalNoticeLink");
/* 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://<YOURJITSIINSTANCEADDRESS>/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
* ============================================================
*/
/* ============================================================
* SHOW LAUNCHER
* ============================================================ */
/*
* Camera/microphone hints (meetingPrivacySettings in
* i18n.json) only matter on Windows – and only with
* loaded translations, otherwise the box would be empty.
*/
function showPrivacyInfo() {
if (detectOperatingSystem() !== OS_NAMES.Windows) {
return;
}
if (getTranslation("meetingPrivacySettings.title") === null) {
return;
}
privacyInfo.classList.remove("hidden");
}
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");
showPrivacyInfo();
/*
* 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();
applyLegalNoticeLink();
await loadTranslations();
applyTranslations();
updateLanguageMenu();
initialize();
}
start();