var prot = window.location.protocol; async function updateProtocolAndUrl(host) { prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol; if (prot == "http:") { try { JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`)); } catch (error) { try { JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`)); prot = "https:"; } catch (error) {} } } url = `${prot}//${host}/_larpix`; } console.log(prot); var url = `${prot}//${window.location.hostname}/_larpix`; var params = new URLSearchParams(window.location.search); try { var loadingScreen = document.querySelector("loading"); var mainScreen = document.querySelector("main"); var loadingStatus = document.getElementById("loadingstatus"); var addDmBtn = document.getElementById("add-dm-btn"); var addGroupBtn = document.getElementById("add-group-btn"); var sidebarHome = document.getElementById("sidebar-home"); var sidebarHomeButton = sidebarHome.children.item(1); var sidebarHomeIndicator = sidebarHome.children.item(0); var sidebarAdd = document.getElementById("sidebar-add"); var sidebarAddButton = sidebarAdd.children.item(1); var sidebarAddIndicator = sidebarAdd.children.item(0); var roomsBarContainer = document.getElementById("roomsbar"); var roomDetailsBar = document.getElementById("roomdetailsbar"); var roomContent = document.getElementsByTagName("roomcontent")[0]; var roomsBar = document.getElementsByTagName("roomcontent2")[0]; var sideBar = document.getElementsByTagName("sidebar")[0]; var roomContentMain = document.getElementsByTagName("roomcontent2")[1]; var roomDetailsMain = document.getElementsByTagName("roomcontent2")[2]; var roomContentBar = roomContent.children[0]; var roomsTopBar = document.getElementsByTagName("roomtopbar")[0]; var roomTopBar = document.getElementsByTagName("roomtopbar")[1]; var detailsTopBar = document.getElementsByTagName("roomtopbar")[2]; var sidebarProfile = document.getElementById("sidebar-profile"); var sidebarProfileButton = sidebarProfile.children.item(1); var sidebarProfileIndicator = sidebarProfile.children.item(0); var sidebarPfp = sidebarProfileButton.children.item(0); var sidebarInbox = document.getElementById("sidebar-inbox"); var sidebarInboxButton = sidebarInbox.children.item(1); var sidebarInboxIndicator = sidebarInbox.children.item(0); var fixedContextMenu = document.getElementById("fixed-context-menu"); } catch (e) { } function delay(time) { return new Promise(resolve => setTimeout(resolve, time)); } async function packetEncPass(pass, key, accountId) { return await encryptWithNonce(pass, key, getNonce(accountId, key)); } async function getNonce(accountId, key) { let nonce; let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text(); try { nonce = await decryptString(fetchRes, key); } catch (err) { nonce = await decryptString(fetchRes, ""); } return nonce; } async function encryptWithNonce(value, key, nonce) { return await encryptString(value, nonce + key); } async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBase64) { const pubX25519Server = base64ToUint8(pubX25519ServerBase64); const pubMlKemServer = base64ToUint8(pubMlKemServerBase64); // X25519 const privX25519Client = window.x25519.utils.randomSecretKey(); const pubX25519Client = window.x25519.getPublicKey(privX25519Client); const secretX25519 = window.x25519.getSharedSecret(privX25519Client, pubX25519Server); // ML-KEM-768 const mlkem = new window.MlKem768(); const [ciphertextMlKem, secretMlKem] = await mlkem.encap(pubMlKemServer); // Combine and Hash: SHA256(X25519_Secret || MLKEM_Secret) const combined = new Uint8Array(secretX25519.length + secretMlKem.length); combined.set(secretX25519); combined.set(secretMlKem, secretX25519.length); const hashBuffer = await window.crypto.subtle.digest('SHA-256', combined); const aesKey = new Uint8Array(hashBuffer); return [uint8ToBase64(pubX25519Client), uint8ToBase64(ciphertextMlKem), aesKey]; } function keyDataFromServerJson(jsonFromServer) { const data = JSON.parse(jsonFromServer); return [data.pubX25519, data.pubMlKem, data.idKey] } function base64ToUint8(base64) { return Uint8Array.from(atob(base64), c => c.charCodeAt(0)); } function uint8ToBase64(uint8) { return fixBase64Padding(btoa(String.fromCharCode(...uint8))); } function concatUint8(a, b) { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; } async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(passwordPlain), { name: "PBKDF2" }, false, ["deriveKey"] ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } async function encryptJsonWithPassword(jsonObj, passwordPlain) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const iterations = 310000; const key = await deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations); const plaintext = new TextEncoder().encode(JSON.stringify(jsonObj)); const ciphertextBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); const ciphertext = new Uint8Array(ciphertextBuf); return JSON.stringify({ v: 1, kdf: "PBKDF2-SHA256", iter: iterations, alg: "AES-256-GCM", salt: uint8ToBase64(salt), iv: uint8ToBase64(iv), ct: uint8ToBase64(ciphertext) }); } async function decryptJsonWithPassword(envelopeJson, passwordPlain) { const env = JSON.parse(envelopeJson); if (!env || env.v !== 1 || env.kdf !== "PBKDF2-SHA256" || env.alg !== "AES-256-GCM") { throw new Error("unsupported.key.envelope"); } const salt = base64ToUint8(env.salt); const iv = base64ToUint8(env.iv); const ct = base64ToUint8(env.ct); const key = await deriveAesGcmKeyFromPassword(passwordPlain, salt, env.iter); const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } async function sha256Bytes(bytes) { const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); return new Uint8Array(hashBuffer); } async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = 32) { const info = new TextEncoder().encode(infoString); const salt = saltBytes ?? new Uint8Array(32); const keyMaterial = await crypto.subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info }, keyMaterial, length * 8 ); return new Uint8Array(bits); } async function importAesGcmKey(keyBytes) { return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); } async function encryptAesGcmToBase64(plainText, keyBytes) { const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await importAesGcmKey(keyBytes); const pt = new TextEncoder().encode(plainText); const ctBuf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt); const combined = new Uint8Array(iv.length + ctBuf.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ctBuf), iv.length); return uint8ToBase64(combined); } async function decryptAesGcmFromBase64(cipherBase64, keyBytes) { const combined = base64ToUint8(cipherBase64); const iv = combined.slice(0, 12); const ct = combined.slice(12); const key = await importAesGcmKey(keyBytes); const ptBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); return new TextDecoder().decode(ptBuf); } function randomRoomKey256Chars() { const bytes = crypto.getRandomValues(new Uint8Array(192)); return uint8ToBase64(bytes); } async function deriveDmSelfWrapKeyBytes() { if (!userKeysPrivate) throw new Error("missing.user.private.keys"); const privX = base64ToUint8(userKeysPrivate.privX25519); const privM = base64ToUint8(userKeysPrivate.privMlKem); return await hkdfSha256Bytes(concatUint8(privX, privM), "larpix:dm:selfwrap:v1"); } async function wrapRoomKeyForSelf(roomKey) { const keyBytes = await deriveDmSelfWrapKeyBytes(); const enc = await encryptAesGcmToBase64(roomKey, keyBytes); return `DMK1:${enc}`; } async function unwrapRoomKeyForSelf(wrapped) { if (!wrapped || !wrapped.startsWith("DMK1:")) throw new Error("unsupported.dm.key.format"); const keyBytes = await deriveDmSelfWrapKeyBytes(); return await decryptAesGcmFromBase64(wrapped.substring("DMK1:".length), keyBytes); } async function deriveHybridKeyToUser(targetPublicKeysObj) { const pubX25519Target = base64ToUint8(targetPublicKeysObj.pubX25519); const pubMlKemTarget = base64ToUint8(targetPublicKeysObj.pubMlKem); const privX25519Mine = base64ToUint8(userKeysPrivate.privX25519); const secretX25519 = window.x25519.getSharedSecret(privX25519Mine, pubX25519Target); const mlkem = new window.MlKem768(); const [ciphertextMlKem, secretMlKem] = await mlkem.encap(pubMlKemTarget); const combined = concatUint8(secretX25519, secretMlKem); const aesKeyBytes = await hkdfSha256Bytes(combined, "larpix:dm:roomkeywrap:v1"); return { aesKeyBytes, ciphertextMlKemBase64: uint8ToBase64(ciphertextMlKem) }; } async function deriveHybridKeyFromSetup(otherPublicKeysObj, ciphertextMlKemBase64) { const pubX25519Other = base64ToUint8(otherPublicKeysObj.pubX25519); const privX25519Mine = base64ToUint8(userKeysPrivate.privX25519); const secretX25519 = window.x25519.getSharedSecret(privX25519Mine, pubX25519Other); const ct = base64ToUint8(ciphertextMlKemBase64); const privMlKemMine = base64ToUint8(userKeysPrivate.privMlKem); const mlkem = new window.MlKem768(); const secretMlKem = await mlkem.decap(ct, privMlKemMine); const combined = concatUint8(secretX25519, secretMlKem); const aesKeyBytes = await hkdfSha256Bytes(combined, "larpix:dm:roomkeywrap:v1"); return aesKeyBytes; } function userPublicKeysFingerprint(pub) { if (!pub) return ""; return `${pub.pubX25519 || ""}|${pub.pubMlKem || ""}`; } async function fetchDmKey(dmId) { return await fetchEncrypted("dm/key/get", dmId); } async function updateDmKey(dmId, keyValue) { return await fetchEncrypted( "dm/key/update", JSON.stringify({ string1: dmId, string2: keyValue }) ); } async function ensureDmRoomKey(dmId) { //returns decrypted roomkey, and converts SETUP key await ensureUserKeys(); const raw = await fetchDmKey(dmId); if (!raw) throw new Error("error:dm.key.missing"); if (raw.startsWith("SETUP:")) { const rest = raw.substring("SETUP:".length); const parts = rest.split(";"); if (parts.length < 2) throw new Error("error:dm.key.setup.malformed"); const otherPublicJson = parts[0]; const setupPayloadJson = parts.slice(1).join(";"); const otherPublic = JSON.parse(otherPublicJson); const setupPayload = JSON.parse(setupPayloadJson); const aesKeyBytes = await deriveHybridKeyFromSetup(otherPublic, setupPayload.ctMlKem); const roomKey = await decryptAesGcmFromBase64(setupPayload.enc, aesKeyBytes); const selfWrapped = await wrapRoomKeyForSelf(roomKey); await unwrapRoomKeyForSelf(selfWrapped); await updateDmKey(dmId, selfWrapped); return roomKey; } return await unwrapRoomKeyForSelf(raw); } window.ensureDmRoomKey = ensureDmRoomKey; async function encrypt(plainText, keyBytes) { const iv = window.crypto.getRandomValues(new Uint8Array(16)); const encoder = new TextEncoder(); const data = encoder.encode(plainText); const cryptoKey = await window.crypto.subtle.importKey( "raw", keyBytes, "AES-CBC", false, ["encrypt"] ); const encryptedContent = await window.crypto.subtle.encrypt( {name: "AES-CBC", iv: iv}, cryptoKey, data ); const result = new Uint8Array(iv.length + encryptedContent.byteLength); result.set(iv); result.set(new Uint8Array(encryptedContent), iv.length); return uint8ToBase64(result); } async function decrypt(cipherTextBase64, keyBytes) { const fullData = base64ToUint8(cipherTextBase64); const iv = fullData.slice(0, 16); const cipherData = fullData.slice(16); const cryptoKey = await window.crypto.subtle.importKey( "raw", keyBytes, "AES-CBC", false, ["decrypt"] ); const decrypted = await window.crypto.subtle.decrypt( {name: "AES-CBC", iv: iv}, cryptoKey, cipherData ); return new TextDecoder().decode(decrypted); } async function getCryptoKey(passphrase) { const encoder = new TextEncoder(); const data = encoder.encode(passphrase); const hash = await crypto.subtle.digest('SHA-256', data); return await crypto.subtle.importKey('raw', hash, {name: 'AES-CBC'}, false, ['encrypt', 'decrypt']); } async function encryptBytes(plainBytes, passphrase) { const key = await getCryptoKey(passphrase); const iv = crypto.getRandomValues(new Uint8Array(16)); const encryptedContent = await crypto.subtle.encrypt( {name: 'AES-CBC', iv: iv}, key, plainBytes ); const result = new Uint8Array(iv.length + encryptedContent.byteLength); result.set(iv); result.set(new Uint8Array(encryptedContent), iv.length); return result; } async function decryptBytes(combinedBytes, passphrase) { const key = await getCryptoKey(passphrase); const iv = combinedBytes.slice(0, 16); const data = combinedBytes.slice(16); const decryptedContent = await crypto.subtle.decrypt( {name: 'AES-CBC', iv: iv}, key, data ); return new Uint8Array(decryptedContent); } async function encryptString(plainText, passphrase) { const encoder = new TextEncoder(); const data = encoder.encode(plainText); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(16)); const encrypted = await crypto.subtle.encrypt( {name: 'AES-CBC', iv: iv}, key, data ); const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv); combined.set(new Uint8Array(encrypted), iv.length); return fixBase64Padding(btoa(String.fromCharCode(...combined))); } async function decryptString(base64Text, passphrase) { const encoder = new TextEncoder(); const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0))); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const key = await crypto.subtle.importKey( 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt'] ); const iv = combined.slice(0, 16); const data = combined.slice(16); const decrypted = await crypto.subtle.decrypt( {name: 'AES-CBC', iv: iv}, key, data ); return new TextDecoder().decode(decrypted); } let requestMutex = Promise.resolve(); async function fetchPost(url, value) { let release; const lock = new Promise(resolve => release = resolve); const prevMutex = requestMutex; requestMutex = prevMutex.then(() => lock); await prevMutex; try { let response = await fetch(url, { method: "POST", body: value, headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); let data = await response.text(); return data; } finally { release(); } } async function fetchPostEnc(url, value) { let release; const lock = new Promise(resolve => release = resolve); const prevMutex = requestMutex; requestMutex = prevMutex.then(() => lock); await prevMutex; try { let nonce = await getNonce(id, passwordHash); let response = await fetch(url, { method: "POST", body: await encryptWithNonce(value, passwordHash, nonce), headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); let data = await response.text(); return data; } finally { release(); } } async function fetchAsync(url) { let response = await fetch(url, { method: "GET", }); let data = await response.text(); return data; } async function fetchAsyncWAuth(url) { let release; const lock = new Promise(resolve => release = resolve); const prevMutex = requestMutex; requestMutex = prevMutex.then(() => lock); await prevMutex; try { let response = await fetch(url, { method: "GET", headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) } }); let data = await response.text(); return data; } finally { release(); } } async function getServerInfo(host) { console.log(`${prot}//${host}/_larpix/serverinfo`) return JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`)); } async function Auth(loginUsername, loginPassword) { let release; const lock = new Promise(resolve => release = resolve); const prevMutex = requestMutex; requestMutex = prevMutex.then(() => lock); await prevMutex; try { let resolvedId = await fetchAsync(`${url}/nametoid?u=${loginUsername}`); if (!resolvedId || resolvedId.trim() === "" || resolvedId.startsWith("error:")) { return "error:invalid.username.or.password"; } let actualId = resolvedId.split(":")[0]; let passwordHash = await hashSHA3_512(loginPassword); let response = await fetch(`${url}/auth?id=${actualId}`, { method: "GET", headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash)) } }); let data = await response.text(); if (data.startsWith("success:")) { data += "|" + actualId; } return data; } finally { release(); } } async function fetchEncrypted(request, body = "") { let release; const lock = new Promise(resolve => release = resolve); const prevMutex = requestMutex; requestMutex = prevMutex.then(() => lock); await prevMutex; try { let nonce = await getNonce(id, passwordHash); let response = await fetch(`${url}/encryptedrequest?id=${id}`, { method: "POST", body: await encryptWithNonce( JSON.stringify({ string1: request, string2: body }) , passwordHash, nonce), headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, nonce) } }); let data = await response.text(); return await decryptString(data, passwordHash); } finally { release(); } } function power(base, exponent, mod) { let res = 1n; base = base % mod; while (exponent > 0n) { if (exponent % 2n === 1n) res = (res * base) % mod; base = (base * base) % mod; exponent = exponent / 2n; } return res; } function fixBase64Padding(base64String) { let str = base64String.replace(/-/g, '+').replace(/_/g, '/'); const pad = str.length % 4; if (pad) { if (pad === 1) { throw new Error(""); } str += '='.repeat(4 - pad); } return str; } function showBlahNotification(blahmessage, duration = 3500) { try { let split = blahmessage.split(":"); showNotification(processBlah(blahmessage), split[0], duration); } catch (e) { showNotification(blahmessage, "info", duration); } } function showNotification(message, type = 'info', duration = 3500) { let container = document.getElementById('notification-container'); if (!container) { container = document.createElement('div'); container.id = 'notification-container'; document.body.appendChild(container); } const notif = document.createElement('div'); notif.className = `notification ${type}`; notif.textContent = message; container.appendChild(notif); requestAnimationFrame(() => { requestAnimationFrame(() => { notif.classList.add('show'); }); }); setTimeout(() => { notif.classList.remove('show'); notif.addEventListener('transitionend', () => { notif.remove(); }); setTimeout(() => notif.remove(), 300); }, duration); } function showAction(message, actionid) { let container = document.getElementById('notification-container'); if (!container) { container = document.createElement('div'); container.id = 'notification-container'; document.body.appendChild(container); } const notif = document.createElement('div'); notif.className = `notification`; notif.textContent = processBlah(message); notif.id = `notification-${actionid}`; container.appendChild(notif); requestAnimationFrame(() => { requestAnimationFrame(() => { notif.classList.add('show'); }); }); } function clearAction(actionid) { const notifications = document.querySelectorAll(`[id="notification-${actionid}"]`); notifications.forEach(notif => { notif.classList.remove('show'); notif.addEventListener('transitionend', async () => { notif.remove(); }, { once: true }); setTimeout(() => notif.remove(), 300); }); } async function hashSHA3_512(input) { const encoder = new TextEncoder(); const data = encoder.encode(input); const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashHex; } //placeholder pfp function getInitials(name) { const cleanName = (name || "").trim(); //name empty if (!cleanName) return ""; const parts = cleanName.split(/\s+/); //at least 2 words if (parts.length >= 2) { const firstInitial = parts[0][0]; const secondInitial = parts[parts.length - 1][0]; //from last word return (firstInitial + secondInitial).toUpperCase(); } //only 1 word with 1 letter const word = parts[0]; if (word.length === 1) { return word.toUpperCase(); } //1 word at least 2 letters return word.substring(0, 2).toUpperCase(); } function stringToColor(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } const h = Math.abs(hash) % 360; const s = 50; const l = 65; return `hsl(${h}, ${s}%, ${l}%)`; } function createAvatarSvg(name, size = 512) { const initials = getInitials(name); const color = stringToColor(name); const svg = ` ${initials} `.trim(); return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } async function getAvatarUrl(id, username) { try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } return pfpUrl; } catch (e) { return createAvatarSvg(username); } } function updateLoadingStatus(message) { loadingStatus.innerHTML = processBlah(message); } async function loadingFadeOut() { mainScreen.style.transform = "scale(0.85)"; mainScreen.style.opacity = "0"; loadingScreen.style.transform = "scale(0.85)"; loadingScreen.style.opacity = "0"; await delay(200); loadingScreen.style.display = "none"; mainScreen.style.display = ""; await delay(200); mainScreen.style.transform = ""; mainScreen.style.opacity = ""; } async function loadingFadeIn() { loadingScreen.style.transform = "scale(0.85)"; loadingScreen.style.opacity = "0"; mainScreen.style.transform = "scale(0.85)"; mainScreen.style.opacity = "0"; await delay(200); mainScreen.style.display = "none"; loadingScreen.style.display = ""; await delay(200); loadingScreen.style.transform = ""; loadingScreen.style.opacity = ""; } var blah; async function initBlahs() { lang = lang.toLowerCase(); let res; let path = window.location.pathname.replace("/login/index.html", "/"); try { //try user lang first res = await fetchAsync(`${path}blah/${lang}.json`); } catch (e) { //fallback to en-us res = await fetchAsync(`${path}blah/en-us.json`); } blah = JSON.parse(res); let blahTags = document.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } blahTags = document.getElementsByClassName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } let placeholders = document.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } } } function splitLimit(str, separator, limit) { const parts = str.split(separator); if (parts.length <= limit) { return parts; } return [ ...parts.slice(0, limit - 1), parts.slice(limit - 1).join(separator) ]; } function processBlah(blahmessage) { let prepended = false; try { if (!blahmessage.includes(":")) { blahmessage = `:${blahmessage}`; prepended = true; } let split = splitLimit(blahmessage, ":", 3); let values = []; try { if (split[2]) { values = split[2].split(";"); } } catch (e) { } let message = blah[split[1]]; if (message === undefined) throw new Error(); let valueslist = ""; for (let i = 0; i < values.length; i++) { let value = processBlah(values[i]); valueslist+=`${value}, `; message = message.replaceAll(`{${i}}`, value); } if (values.length > 0) { valueslist = valueslist.slice(0, -2); } if (message.includes('{all}')) { message = message.replaceAll('{all}', valueslist); } return message; } catch (e) { if (prepended) { return blahmessage.substring(1); } return blahmessage; } } function getLang() { return (navigator.language || navigator.languages[0]); //return "en-cat"; } var id = ""; var password = ""; var username = ""; var passwordHash = ""; var host = ""; var lang = getLang(); var userKeysPublic = null; //2string json var userKeysPrivate = null; // decrypted private bundle (JSON object) var userKeysEncrypted = null; // encrypted private bundle (string envelope JSON) function publishUserKeysGlobals() { window.userKeysPublic = userKeysPublic; window.userKeysPrivate = userKeysPrivate; window.userKeysEncrypted = userKeysEncrypted; } async function generateUserKeysBundle() { // X25519 keypair const privX25519 = window.x25519.utils.randomSecretKey(); const pubX25519 = window.x25519.getPublicKey(privX25519); // ML-KEM-768 keypair const mlkem = new window.MlKem768(); const [pubMlKem, privMlKem] = await mlkem.generateKeyPair(); const pub = { v: 1, alg: "hybrid-x25519-mlkem768", pubX25519: uint8ToBase64(pubX25519), pubMlKem: uint8ToBase64(pubMlKem) }; const priv = { v: 1, alg: "hybrid-x25519-mlkem768", privX25519: uint8ToBase64(privX25519), privMlKem: uint8ToBase64(privMlKem) }; return { pub, priv }; } async function ensureUserKeys() { const lsEncKey = `userKeys.enc.v1:${id}`; const lsPubKey = `userKeys.pub.v1:${id}`; let serverKeys = null; let serverKeysRaw = ""; try { //1. check server serverKeysRaw = await fetchEncrypted("user/key/get"); if (serverKeysRaw && serverKeysRaw.trim() !== "" && serverKeysRaw.trim().startsWith("{")) { serverKeys = JSON.parse(serverKeysRaw); const serverEncPriv = serverKeys.string1; const serverPub = serverKeys.string2; if (serverEncPriv && serverPub) { userKeysEncrypted = serverEncPriv; userKeysPublic = JSON.parse(serverPub); userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password); localStorage.setItem(lsEncKey, userKeysEncrypted); localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic)); publishUserKeysGlobals(); return "success:keys.server"; } } } catch (e) { if (serverKeys?.string1) { throw new Error("error:keys.server.decrypt.failed"); } } //fallback to local const localEnc = localStorage.getItem(lsEncKey); const localPub = localStorage.getItem(lsPubKey); if (localEnc && localPub) { try { const localPubObj = JSON.parse(localPub); if (serverKeys?.string2) { const serverPubObj = JSON.parse(serverKeys.string2); if (userPublicKeysFingerprint(localPubObj) !== userPublicKeysFingerprint(serverPubObj)) { throw new Error("error:keys.local.server.mismatch"); } } userKeysEncrypted = localEnc; userKeysPublic = localPubObj; userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password); //push keys to server only when server has none yet if (!serverKeys?.string1) { const updateRes = await fetchEncrypted( "user/key/update", JSON.stringify({ string1: userKeysEncrypted, string2: JSON.stringify(userKeysPublic) }) ); if (updateRes === "error:keys.public.mismatch") { throw new Error("error:keys.local.server.mismatch"); } } publishUserKeysGlobals(); return "success:keys.local"; } catch (e) { if (e.message && e.message.startsWith("error:keys.")) { throw e; } } } if (serverKeys?.string1) { throw new Error("error:keys.server.decrypt.failed"); } //new keys, encrypt private with user's password const bundle = await generateUserKeysBundle(); userKeysPublic = bundle.pub; userKeysPrivate = bundle.priv; userKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, password); localStorage.setItem(lsEncKey, userKeysEncrypted); localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic)); await fetchEncrypted( "user/key/update", JSON.stringify({ string1: userKeysEncrypted, string2: JSON.stringify(userKeysPublic) }) ); publishUserKeysGlobals(); return "success:keys.generated"; } async function mainJS() { await initBlahs(); passwordHash = await hashSHA3_512(password); if (localStorage.getItem('lang') != null) { lang = localStorage.getItem('lang'); } if (host) { await updateProtocolAndUrl(host); } else { await updateProtocolAndUrl(window.location.hostname); } if (!id && username) { let resolvedId = await fetchAsync(`${url}/nametoid?u=${username}`); if (resolvedId && !resolvedId.startsWith("error:")) { id = resolvedId.split(":")[0]; localStorage.setItem("id", id); } } await start(); } id = localStorage.getItem('id'); username = localStorage.getItem('username'); password = localStorage.getItem('password'); host = localStorage.getItem('host'); mainJS(); function showFixedContextMenu(rect, html) { let parser = new DOMParser(); let doc = parser.parseFromString(html, "text/html"); let blahTags = doc.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } blahTags = doc.getElementsByClassName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } } fixedContextMenu.innerHTML = doc.body.innerHTML; fixedContextMenu.style.left = `${rect.right + 10}px`; fixedContextMenu.style.top = `${rect.top}px`; fixedContextMenu.classList.add("show"); } var currentRoomsBarTitle = null; var currentRoomsBarHtml = null; async function switchRoomsBar(title, content) { if (currentRoomsBarTitle === title && currentRoomsBarHtml === content) return; currentRoomsBarTitle = title; currentRoomsBarHtml = content; let roomsTopBarTransition = roomsTopBar.children.item(0); roomsBar.style.transform = "scale(0.85)"; roomsBar.style.opacity = "0"; roomsTopBarTransition.style.transform = "scale(0.85)"; roomsTopBarTransition.style.opacity = "0"; await delay(200); roomsTopBarTransition.innerHTML = processBlah(title); let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); let blahTags = doc.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } blahTags = doc.getElementsByClassName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } } roomsBar.innerHTML = doc.body.innerHTML; roomsBar.style.transform = ""; roomsBar.style.opacity = ""; roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; } var currentRoomContentTitle = null; var currentRoomContentHtml = null; async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) { if (currentRoomContentTitle === title && currentRoomContentHtml === content) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth <= 52 * rem && !skipMobileSlide) { if (title != "title.splash") { //do not show splash on mobile mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); } } return; } currentRoomContentTitle = title; currentRoomContentHtml = content; let roomsTopBarTransition = roomTopBar.children.item(0); roomContentMain.style.transform = "scale(0.85)"; roomContentMain.style.opacity = "0"; roomsTopBarTransition.style.transform = "scale(0.85)"; roomsTopBarTransition.style.opacity = "0"; const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth <= 52 * rem && !skipMobileSlide) { if (title != "title.splash") //do not show splash on mobile { mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); } } else { await delay(200); } let detailsBtnHtml = showRoomBar ? detailsBtn : ''; roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}${processBlah(title)}
${detailsBtnHtml}`; roomDetailsBar.style.display = showRoomBar ? "flex" : "none"; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); let blahTags = doc.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } blahTags = doc.getElementsByClassName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } } roomContentMain.innerHTML = doc.body.innerHTML; roomContentMain.style.transform = ""; roomContentMain.style.opacity = ""; roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; } var currentDetailsContentTitle = null; var currentDetailsContentHtml = null; async function switchDetailsContent(title, content) { if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return; currentDetailsContentTitle = title; currentDetailsContentHtml = content; let roomsTopBarTransition = detailsTopBar.children.item(0); roomDetailsMain.style.transform = "scale(0.85)"; roomDetailsMain.style.opacity = "0"; roomsTopBarTransition.style.transform = "scale(0.85)"; roomsTopBarTransition.style.opacity = "0"; const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 52 * rem) { await delay(200); } roomsTopBarTransition.innerHTML = processBlah(title); let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); let blahTags = doc.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } blahTags = doc.getElementsByClassName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } } roomDetailsMain.innerHTML = doc.body.innerHTML; roomDetailsMain.style.transform = ""; roomDetailsMain.style.opacity = ""; roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; } function clickCollapseDms() { var collapseDmsBtn = document.getElementById("collapse-dms"); collapseDmsBtn.classList.toggle("collapsed"); var dmsWrapper = document.getElementById("dms-wrapper"); if (dmsWrapper) { if (collapseDmsBtn.classList.contains("collapsed")) { dmsWrapper.classList.add("collapsed"); } else { dmsWrapper.classList.remove("collapsed"); } } } function clickCollapseGroups() { var collapseGroupsBtn = document.getElementById("collapse-groups"); collapseGroupsBtn.classList.toggle("collapsed"); var groupsWrapper = document.getElementById("groups-wrapper"); if (groupsWrapper) { if (collapseGroupsBtn.classList.contains("collapsed")) { groupsWrapper.classList.add("collapsed"); } else { groupsWrapper.classList.remove("collapsed"); } } } function clickAddGroup() { switchRoomContent("title.create.group", createGroupScreen, false); } function clickAddDm() { switchRoomContent("title.create.dm", addDmScreen, false); } var currentMainIndicator = null; sidebarAddButton.addEventListener("click", (e) => { e.stopPropagation(); history.pushState({trap: true}, "", location.href); if (fixedContextMenu.classList.contains("show")) { fixedContextMenu.classList.remove("show"); setActiveSidebarIndicator(currentMainIndicator); } else { setActiveSidebarIndicator(sidebarAddIndicator, true); const rect = sidebarAddButton.getBoundingClientRect(); showFixedContextMenu(rect, addSpaceMenu); } }); document.addEventListener("click", (e) => { history.pushState({trap: true}, "", location.href); if (fixedContextMenu.classList.contains("show")) { if (!fixedContextMenu.contains(e.target)) { fixedContextMenu.classList.remove("show"); if (!e.target.closest('sidebarelement')) { setActiveSidebarIndicator(currentMainIndicator); } } } if (mainScreen && mainScreen.classList.contains('mobile-details')) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth <= 52 * rem) { if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right')) { mobileNavBack(); } } } }); window.addEventListener("popstate", (e) => { popstate(); }); const { App } = window.Capacitor?.Plugins || {}; if (App) { App.addListener('backButton', () => { popstate(); }); } function popstate() { if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) { history.back(); if (App) { App.minimizeApp(); } } mobileNavBack(); } function gotoSideProfilePopup() { } function setActiveRoombarItem(itemId) { let items = document.querySelectorAll('.collapse-text-button'); items.forEach(item => item.classList.remove('selected')); if (itemId) { let activeItem = document.getElementById(itemId); if (activeItem) activeItem.classList.add('selected'); } } async function renderInvites(res, type) { let container = document.getElementById("invites-container"); if (!container) return; if (res && res != "") { res = JSON.parse(res); let invites = [ ...Object.entries(res.dms).map(([id, value]) => ({ id, type: "dm", ...value })), ...Object.entries(res.groups).map(([id, value]) => ({ id, type: "group", ...value })) ].sort((a, b) => b.timestamp - a.timestamp); container.classList.remove("empty"); let html = ""; let count = 0; for (let i = 0; i < invites.length; i++) { let invite = invites[i]; if (invite.type === "dm") { let id = invite.id; if (!id.includes(":")) { id += `:${host}`; } let onlyId = id.split(":")[0]; let username = await fetchAsync(`${url}/idtoname?id=${id}`); if (!(username && username.trim() !== "")) return; count++; let pfp = await getAvatarUrl(id, username); let actions = ""; let desc = ""; if (type === "received") { desc = `:desc.invite.dm.received:${username};${onlyId}`; actions = ` `; } else { desc = `:desc.invite.dm.sent:${username};${onlyId}`; actions = ` `; } let entry = invitesEntry .replaceAll("{username}", username.split(':')[0]) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", desc) .replaceAll("{actions}", actions); html += entry; } } if (count > 0) { container.innerHTML = html; } else { container.classList.add("empty"); container.innerHTML = invitesEmptyState; } } else { container.classList.add("empty"); container.innerHTML = invitesEmptyState; } let blahTags = container.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } window.cachedInvites = { 'received': null, 'sent': null }; async function loadInvites(tab) { try { showAction(`action.fetching.invites.${tab === 'received' ? 'recv' : 'sent'}`, `fetching.invites.${tab}`); if (window.cachedInvites[tab]) { await renderInvites(window.cachedInvites[tab], tab); } let res = await fetchEncrypted(`user/invites/${tab}`); window.cachedInvites[tab] = res; await renderInvites(res, tab); } catch (e) { showBlahNotification("error:something.wrong"); console.error(e); await renderInvites("", tab); } clearAction(`fetching.invites.${tab}`); } async function switchInvitesTab(tab) { if (!document.getElementById(`tab-invites-${tab}`).classList.contains('tab-inactive')) return; document.getElementById('tab-invites-received').classList.add('tab-inactive'); document.getElementById('tab-invites-sent').classList.add('tab-inactive'); document.getElementById(`tab-invites-${tab}`).classList.remove('tab-inactive'); await loadInvites(tab); } async function acceptInvite(targetId) { //TODO: Implement key generation try { showAction("action.invite.accepting", "invite.action"); await ensureUserKeys(); const inviterPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`); const inviterPublicKeys = JSON.parse(inviterPublicKeysRaw); //generate room key const roomKey = randomRoomKey256Chars(); //encrypt for self const selfWrapped = await wrapRoomKeyForSelf(roomKey); //encrypt for inviter const hybrid = await deriveHybridKeyToUser(inviterPublicKeys); const encForInviter = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes); const setupPayload = JSON.stringify({ v: 1, alg: "hybrid-x25519-mlkem768", ctMlKem: hybrid.ciphertextMlKemBase64, enc: encForInviter }); let payload = JSON.stringify({ string1: targetId, string2: selfWrapped, string3: setupPayload }); let res = await fetchEncrypted("user/dm/create", payload); clearAction("invite.action"); showBlahNotification(res || "success:invite.accepted"); await loadInvites('received'); } catch (e) { clearAction("invite.action"); showBlahNotification("error:something.wrong"); } } async function declineInvite(username) { try { showAction("action.invite.declining", "invite.action"); let res = await fetchEncrypted("user/dm/invite/decline", username); clearAction("invite.action"); showBlahNotification(res || "success:invite.declined"); await loadInvites('received'); } catch (e) { clearAction("invite.action"); showBlahNotification("error:something.wrong"); } } async function revokeInvite(username) { try { showAction("action.invite.revoking", "invite.action"); let res = await fetchEncrypted("user/dm/invite/revoke", username); clearAction("invite.action"); showBlahNotification(res || "success:invite.revoked"); await loadInvites('sent'); } catch (e) { clearAction("invite.action"); showBlahNotification("error:something.wrong"); } } function switchNotifisTab(tab) { if (!document.getElementById(`tab-notifis-${tab}`).classList.contains('tab-inactive')) return; document.getElementById('tab-notifis-all').classList.add('tab-inactive'); document.getElementById('tab-notifis-unread').classList.add('tab-inactive'); document.getElementById(`tab-notifis-${tab}`).classList.remove('tab-inactive'); } async function gotoInbox() { if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar setActiveSidebarIndicator(sidebarInboxIndicator); await switchRoomsBar("title.inbox", inboxRoomBar); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 52 * rem) { gotoInvites(); } } async function gotoInvites() { setActiveRoombarItem('collapse-invites'); await switchRoomContent("title.invites", invitesScreen, false); switchInvitesTab('received'); } async function gotoNotifis() { setActiveRoombarItem('collapse-notifis'); await switchRoomContent("title.notifications", notifisScreen, false); switchNotifisTab('all'); } function gotoCreateSpace() { fixedContextMenu.classList.remove("show"); if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar switchRoomContent("title.create.space", createSpaceScreen, false); } function gotoJoinSpace() { fixedContextMenu.classList.remove("show"); if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar switchRoomContent("title.join.space", joinSpaceScreen, false); } async function gotoHome() { if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar setActiveSidebarIndicator(sidebarHomeIndicator); switchRoomContent("title.splash", splashScreen, false); switchRoomsBar("title.home", homeRoomBar); setActiveRoombarItem(null); setupWebSocket(); await refreshDms(210); } function setActiveSidebarIndicator(element, isTemporary = false) { let indicators = document.getElementsByTagName("indicator"); for (let i = 0; i < indicators.length; i++) { indicators[i].classList.remove("active"); } if (element) { element.classList.add("active"); } if (!isTemporary && element) { currentMainIndicator = element; } } function mobileNavBack() { if(mainScreen.classList.contains('mobile-details')) { mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); setActiveSidebarIndicator(currentMainIndicator); } else { setActiveRoombarItem(null); if (roomsBarContainer) { roomsBarContainer.style.display = ""; //restore roombar on mobile void roomsBarContainer.offsetWidth; } mainScreen.classList.remove('mobile-content'); setActiveSidebarIndicator(currentMainIndicator); } } function mobileNavDetails() { mainScreen.classList.add('mobile-details'); } let touchStartX = 0; let touchEndX = 0; let touchStartY = 0; let touchEndY = 0; let touchMoved = false; document.addEventListener('touchstart', e => { touchStartX = e.changedTouches[0].screenX; touchStartY = e.changedTouches[0].screenY; touchMoved = false; activeTouchButton = e.target.closest('button, input, textarea'); if (activeTouchButton) { activeTouchButton.classList.add('is-pressed'); } }, { passive: true }); document.addEventListener('touchmove', e => { if (!touchMoved) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); let diffX = Math.abs(e.changedTouches[0].screenX - touchStartX); let diffY = Math.abs(e.changedTouches[0].screenY - touchStartY); if (diffX > rem || diffY > rem) { touchMoved = true; if (activeTouchButton) { activeTouchButton.classList.remove('is-pressed'); activeTouchButton = null; } } } }, { passive: true }); document.addEventListener('touchend', e => { touchEndX = e.changedTouches[0].screenX; touchEndY = e.changedTouches[0].screenY; handleMobileSwipe(); if (activeTouchButton) { activeTouchButton.classList.remove('is-pressed'); if (!touchMoved) { if (activeTouchButton.tagName !== 'INPUT' && activeTouchButton.tagName !== 'TEXTAREA') { if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { document.activeElement.blur(); } if (e.cancelable) { e.preventDefault(); } activeTouchButton.click(); } else { activeTouchButton.focus(); } } activeTouchButton = null; } }); document.addEventListener('touchcancel', () => { if (activeTouchButton) { activeTouchButton.classList.remove('is-pressed'); activeTouchButton = null; } }); document.addEventListener('contextmenu', e => { if (e.target.closest('button')) { e.preventDefault(); } }); function handleMobileSwipe() { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 52 * rem) return; let diffX = touchEndX - touchStartX; let diffY = touchEndY - touchStartY; if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) { if (diffX > 0) { mobileNavBack(); } else { if (mainScreen.classList.contains('mobile-content') && roomDetailsBar.style.display !== 'none') { mobileNavDetails(); } } } } async function renderDms(res) { let container = document.getElementById("dms-list"); if (!container) return; if (res && res !== "") { let parsed = JSON.parse(res); let dms = parsed.dms || {}; let dmEntries = Object.entries(dms); // Sort by timestamp descending dmEntries.sort((a,b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0)); let html = ""; let count = 0; for (let [dmId, dmData] of dmEntries) { let parts = dmId.split('_'); let myId = `${id};${host}`; let targetId = parts[0] === myId ? parts[1] : parts[0]; let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = targetUsername.split(':')[0]; html += ` `; count++; } if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); } else { container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } } else { container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } } var currentDmId = null; var currentDmKey = null; // AES key for encrypting/decrypting messages var dmUsernameCache = {}; var dmPfpCache = {}; var loadedMessages = {}; var oldestLoadedMsgId = null; var isLoadingOlderMessages = false; async function openDm(dmId, username, targetId) { try { showAction("action.dm.opening", "dmopen"); currentDmKey = await ensureDmRoomKey(dmId); if (!currentDmKey) { throw new Error("Missing DM room key"); } currentDmId = dmId; loadedMessages = {}; oldestLoadedMsgId = null; isLoadingOlderMessages = false; let pfp = await getAvatarUrl(targetId, username); let iconHtml = ``; await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml); let msgContainer = document.getElementById("chat-messages"); if (msgContainer) { msgContainer.innerHTML = `
desc.messages.loading
`; let blahTags = msgContainer.getElementsByTagName("blah"); for (let i = 0; i < blahTags.length; i++) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } await loadDmMessages(dmId); setupChatScrollListener(); if (dmMessagePollInterval) clearInterval(dmMessagePollInterval); dmMessagePollInterval = setInterval(() => { if (currentDmId === dmId) { loadDmMessages(dmId); } else { clearInterval(dmMessagePollInterval); } }, 15000); setActiveRoombarItem(`dm-btn-${dmId}`); clearAction("dmopen"); } catch (e) { clearAction("dmopen"); console.error(e); if (e.message === "error:keys.local.server.mismatch" || e.message === "error:keys.server.decrypt.failed") { showBlahNotification(e.message); } else { showBlahNotification("error:dm.open.failed"); } } } async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { try { let payload = JSON.stringify({ string1: dmId, string2: "50", string3: startOffset }); let res = await fetchEncrypted("dm/messages/get", payload); if (res && res.startsWith("error:")) { showBlahNotification(res); return; } let messages = JSON.parse(res); let ids = Object.keys(messages).map(id => parseInt(id)); if (ids.length > 0) { let minId = Math.min(...ids); if (oldestLoadedMsgId === null || minId < oldestLoadedMsgId) { oldestLoadedMsgId = minId; } } if (ids.length < 50 && startOffset !== "") { oldestLoadedMsgId = 0; } Object.assign(loadedMessages, messages); await renderMessages(loadedMessages, isPrepend); } catch (e) { console.error(e); showBlahNotification("error:dm.messages.fetch.failed"); } } async function renderMessages(messages, isPrepend = false) { let container = document.getElementById("chat-messages"); if (!container) return; let entries = Object.entries(messages).sort((a,b) => parseInt(a[0]) - parseInt(b[0])); let html = ""; let lastAuthor = null; for (let [msgId, msg] of entries) { let content = msg.content; if (msg.key && msg.key !== "") { try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); content = await decryptAesGcmFromBase64(content, dmKeyBytes); } catch(e) { content = `error:messages.decrypt.failed`; } } content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => { return processBlah(p1); }); // Also still process the whole thing in case it's just "dm.begin.notice" without {blah()} content = processBlah(content); if (msg.type === "larp.info") { html += `
${content}
`; lastAuthor = null; } else { let showAvatar = lastAuthor !== msg.author; lastAuthor = msg.author; let authorName = "Unknown"; let pfp = "assets/default_avatar.png"; if (msg.author !== "0") { if (!dmUsernameCache[msg.author]) { let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`); if (fullUsername && !fullUsername.startsWith("error:")) { dmUsernameCache[msg.author] = fullUsername; dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername); } } if (dmUsernameCache[msg.author]) { authorName = dmUsernameCache[msg.author].split(':')[0]; pfp = dmPfpCache[msg.author]; } } let date = new Date(parseInt(msg.timestamp)); let timeStr = date.toLocaleString(); let extraClass = showAvatar ? "with-avatar" : ""; html += `
${showAvatar ? `` : `
`}
${showAvatar ? `
${authorName} ${timeStr}
` : ""}
${content}
`; } } const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem); if (window.forceScrollToBottom) { wasAtBottom = true; window.forceScrollToBottom = false; } let oldScrollTop = container.scrollTop; let oldScrollHeight = container.scrollHeight; container.innerHTML = html; if (wasAtBottom) { container.scrollTop = container.scrollHeight; } else if (isPrepend) { container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight); } else { container.scrollTop = oldScrollTop; } } function setupChatScrollListener() { let container = document.getElementById("chat-messages"); if (!container) return; container.onscroll = async () => { if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) { isLoadingOlderMessages = true; showAction("info.messages.loading.older", "messages.loading.older"); try { await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true); isLoadingOlderMessages = false; } catch (e) {} clearAction("messages.loading.older"); } }; } async function sendMessage() { if (!currentDmId || !currentDmKey) return; let input = document.getElementById("chat-input"); let content = input.value.trim(); if (!content) return; try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); let msgPayload = { string1: currentDmId, string2: encryptedContent, string3: "" }; input.value = ""; input.style.height = '2.5rem'; window.forceScrollToBottom = true; let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload)); if (res && res.startsWith("error:")) { showBlahNotification(res); } else { await loadDmMessages(currentDmId); } } catch (e) { console.error(e); showBlahNotification("error:message.send.failed"); } } function handleChatInputResize(textarea) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); textarea.style.transition = 'none'; textarea.style.height = '0rem'; let borderHeight = textarea.offsetHeight - textarea.clientHeight; textarea.style.height = ((textarea.scrollHeight + borderHeight) / rem) + 'rem'; textarea.offsetHeight; // force reflow textarea.style.transition = ''; } function handleChatInputKey(event) { if (event.key === "Enter" && !event.shiftKey) { const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); if (!isMobile) { event.preventDefault(); sendMessage(); } } } let dmMessagePollInterval = null; let appWebSocket = null; function setupWebSocket() { if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) return; let wsUrl = url.replace(/^http/, "ws") + "/ws"; appWebSocket = new WebSocket(wsUrl); appWebSocket.onopen = async () => { try { let nonce = await getNonce(id, passwordHash); let secretEnc = await encryptWithNonce(passwordHash, passwordHash, nonce); appWebSocket.send(JSON.stringify({ string1: id, string2: secretEnc })); } catch (e) { console.error("WS auth failed", e); } }; appWebSocket.onmessage = (event) => { let data = event.data; if (data.startsWith("dm_message:")) { let msgDmId = data.substring("dm_message:".length); if (currentDmId === msgDmId) { loadDmMessages(msgDmId); } } }; appWebSocket.onclose = () => { setTimeout(setupWebSocket, 5000); }; }