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) { let binary = ''; const chunkSize = 8192; for (let i = 0; i < uint8.length; i += chunkSize) { binary += String.fromCharCode.apply(null, uint8.subarray(i, i + chunkSize)); } return fixBase64Padding(btoa(binary)); } 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 uint8ToBase64(combined); } async function encryptBytesWithNonce(dataUint8, key, nonce) { const encoder = new TextEncoder(); const passphrase = nonce + key; const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const importedKey = 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}, importedKey, dataUint8 ); const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv); combined.set(new Uint8Array(encrypted), iv.length); return 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(); if (data.startsWith("error:")) return data; return await decryptString(data, passwordHash); } catch (e) { console.error("fetchEncrypted error:", e); return "error:network.error"; } finally { release(); } } async function fetchEncryptedUpload(target, fileBytesUint8) { 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}/upload?id=${id}`, { method: "POST", body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), headers: { "secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "target": await encryptWithNonce(target, passwordHash, nonce) } }); let data = await response.text(); if (data.startsWith("error:")) return data; return await decryptString(data, passwordHash); } catch (e) { console.error("fetchEncryptedUpload error:", e); return "error:network.error"; } 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 (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; 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`); blah = JSON.parse(res); } 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 (blahmessage.startsWith("error:")) { return blah["error.unknown.error"] || "Unknown error"; } 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() { if (localStorage.getItem('lang') != null) { lang = localStorage.getItem('lang'); } await initBlahs(); await loadEmotes(); passwordHash = await hashSHA3_512(password); 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'); window.addEventListener("load", () => { 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.transition = 'none'; fixedContextMenu.style.opacity = '0'; fixedContextMenu.style.left = '0px'; fixedContextMenu.style.top = '0px'; fixedContextMenu.classList.add("show"); let menuRect = fixedContextMenu.getBoundingClientRect(); let newLeft = rect.right + 10; let newTop = rect.top; if (newLeft + menuRect.width > window.innerWidth) { newLeft = window.innerWidth - menuRect.width - 10; } if (newTop + menuRect.height > window.innerHeight) { newTop = window.innerHeight - menuRect.height - 10; } fixedContextMenu.style.left = `${newLeft}px`; fixedContextMenu.style.top = `${newTop}px`; fixedContextMenu.offsetHeight; fixedContextMenu.style.transition = ''; fixedContextMenu.style.opacity = ''; } 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 <= 55 * 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 <= 55 * 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 : ''; let pinCallBtns = showRoomBar ? `${pinBtnHtml}${callBtnHtml}` : ''; roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}${processBlah(title)}
${pinCallBtns}${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 > 55 * rem) { await delay(200); } let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`; roomsTopBarTransition.innerHTML = `${processBlah(title)}
${pinCallBtns}`; 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); } }); function clearContextMenuStyles() { let elems = document.querySelectorAll(".chat-message.context-menu-open"); for (let i = 0; i < elems.length; i++) { elems[i].classList.remove("context-menu-open"); } } 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 (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); if (!e.target.closest('sidebarelement')) { setActiveSidebarIndicator(currentMainIndicator); } } } if (mainScreen && mainScreen.classList.contains('mobile-details')) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth <= 55 * rem) { if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right') && !e.target.closest('.profile-popup-container')) { mobileNavBack(); } } } }); window.addEventListener("popstate", (e) => { popstate(); }); const {App} = window.Capacitor?.Plugins || {}; if (App) { App.addListener('backButton', () => { popstate(); }); } function popstate() { if (isProfileOpen) { closeProfile(); return; } if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) { history.back(); if (App) { App.minimizeApp(); } } mobileNavBack(); } sidebarProfileButton.addEventListener("click", (e) => { e.stopPropagation(); history.pushState({trap: true}, "", location.href); if (fixedContextMenu.classList.contains("show")) { fixedContextMenu.classList.remove("show"); if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); setActiveSidebarIndicator(currentMainIndicator); } else { setActiveSidebarIndicator(sidebarProfileIndicator, true); const rect = sidebarProfileButton.getBoundingClientRect(); showFixedContextMenu({top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}, profileContextMenu); } }); function logout() { localStorage.clear(); window.location.href = "login/index.html"; } let isProfileOpen = false; async function openProfile(accountId) { if (fixedContextMenu.classList.contains("show")) { fixedContextMenu.classList.remove("show"); if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); setActiveSidebarIndicator(currentMainIndicator); } let popupContainer = document.getElementById("profile-popup-container"); let popupInner = document.getElementById("profile-popup-inner"); showAction('action.profile.loading', 'profile-loading'); try { if (!accountId) accountId = window.id; //var fallback if (accountId.endsWith(';' + host)) { accountId = accountId.substring(0, accountId.length - host.length - 1); } else if (accountId.endsWith(':' + host)) { accountId = accountId.substring(0, accountId.length - host.length - 1); } let nameWithHost = await fetchAsync(`${url}/idtoname?id=${accountId}`); if (nameWithHost.startsWith("error:")) nameWithHost = accountId; //api fallback let nameParts = nameWithHost.split(":"); let profileName = nameParts[0] || accountId; let profileHost = nameParts.slice(1).join(":") || host; let pfpUrl = await getAvatarUrl(accountId, nameWithHost); let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`; if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`; let bannerHtml = ``; let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); if (bio.startsWith("error:")) bio = ""; let bioHtml = bio && bio.trim() !== "" ? `
${bio.replace(//g, ">")}
` : ""; let isOwnProfile = accountId === id; let actionBtnHtml = ""; if (isOwnProfile) { actionBtnHtml = ``; } else { let hasDm = false; let dmRoomId = ""; if (window.cachedDms) { try { let parsedDms = JSON.parse(window.cachedDms); if (parsedDms.dms) { for (let dmId in parsedDms.dms) { let parts = dmId.split('_'); if (parts.length >= 2) { let accStandard = accountId.replace(':', ';'); if (!accStandard.includes(';')) accStandard += ';' + host; let p0Standard = parts[0].replace(':', ';'); if (!p0Standard.includes(';')) p0Standard += ';' + host; let p1Standard = parts[1].replace(':', ';'); if (!p1Standard.includes(';')) p1Standard += ';' + host; if (p0Standard === accStandard || p1Standard === accStandard) { hasDm = true; dmRoomId = dmId; break; } } } } } catch(e){} } if (hasDm) { actionBtnHtml = ``; } else { let sentInvite = false; let receivedInvite = false; if (window.cachedInvites) { let checkInvite = (type) => { if (window.cachedInvites[type]) { try { let invites = JSON.parse(window.cachedInvites[type]); if (invites.dms) { for(let key in invites.dms) { let accStandard = accountId.replace(':', ';'); if (!accStandard.includes(';')) accStandard += ';' + host; let keyStandard = key.replace(':', ';'); if (!keyStandard.includes(';')) keyStandard += ';' + host; if (keyStandard === accStandard) return true; } } } catch(e){} } return false; }; sentInvite = checkInvite('sent'); receivedInvite = checkInvite('received'); } if (receivedInvite) { actionBtnHtml = ``; } else if (sentInvite) { actionBtnHtml = ``; } else { actionBtnHtml = ``; } } } popupInner.innerHTML = ` ${bannerHtml}
${profileName}
${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})
${bioHtml} ${actionBtnHtml}
`; clearAction('profile-loading'); popupContainer.style.display = "flex"; popupContainer.offsetHeight; popupContainer.classList.add("show"); isProfileOpen = true; } catch (e) { clearAction('profile-loading'); showNotification(processBlah('error.profile.load.failed'), 'error'); console.error(e); } } function closeProfile() { let popupContainer = document.getElementById("profile-popup-container"); popupContainer.classList.remove("show"); isProfileOpen = false; setTimeout(() => { popupContainer.style.display = "none"; }, 300); } 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) .replaceAll("{id}", id); 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 sendInviteTo(targetId) { try { showAction("action.invite.sending", "invite.action"); let res = await fetchEncrypted("user/dm/invite", targetId); clearAction("invite.action"); showBlahNotification(res || "success:invite.sent"); if (typeof loadInvites === 'function') await loadInvites('sent'); } catch (e) { clearAction("invite.action"); showBlahNotification("error:something.wrong"); } } 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 > 55 * 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(splash = true) { if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar setActiveSidebarIndicator(sidebarHomeIndicator); if (splash) { 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; let pressDate; 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'); pressDate = Date.now(); } }, {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 (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { document.activeElement.blur(); } if (activeTouchButton) { activeTouchButton.classList.remove('is-pressed'); activeTouchButton = null; } } } }, {passive: true}); document.addEventListener('touchend', async (e) => { touchEndX = e.changedTouches[0].screenX; touchEndY = e.changedTouches[0].screenY; isAdjusting = false; //resize scroll fix handleMobileSwipe(); if (activeTouchButton) { 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(); } } let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie if (elapsed < 100) { const remaining = 100 - elapsed; await delay(remaining); } activeTouchButton.classList.remove('is-pressed'); 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 > 55 * 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]; let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; 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; function escapeHtml(unsafe) { if (!unsafe) return ""; return unsafe .replaceAll(/&/g, "&") .replaceAll(//g, ">") .replaceAll(/"/g, """) .replaceAll(/'/g, "'"); } async function generateDmMembersHtml(targetId, targetUsername) { let myIdStr = id + ";" + host; let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); let myPfp = await getAvatarUrl(myIdStr, myUsername); let targetPfp = await getAvatarUrl(targetId, targetUsername); let targetDisplayName = targetUsername.split(':')[0]; let myDisplayName = myUsername.split(':')[0]; return `
`; } async function openDm(dmId, username, targetId) { try { showAction("action.dm.opening", "dmopen"); markDmAsRead(dmId); 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 = ``; if (currentRoomsBarTitle !== "title.home") { gotoHome(false); } await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml); let membersHtml = await generateDmMembersHtml(targetId, username); await switchDetailsContent("title.details", membersHtml); 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; let reactions = msg.reactions || ""; let decrypted = false; if (msg.key && msg.key !== "") { try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); content = await decryptAesGcmFromBase64(content, dmKeyBytes); if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes); decrypted = true; } catch (e) { content = `error:messages.decrypt.failed`; decrypted = false; } } else { decrypted = true; } if (decrypted) { content = escapeHtml(content); } 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] && !dmPfpCache[msg.author]) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); } 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" : ""; let repliedHtml = ""; if (msg.responded && msg.responded !== "") { let respondedId = msg.responded; if (typeof respondedId === 'number') respondedId = respondedId.toString(); let respondedAuthorId = null; if (typeof respondedId === 'string' && respondedId.includes(':')) { let parts = respondedId.split(':'); respondedId = parts[0]; respondedAuthorId = parts[1]; } let replyAuthor = processBlah('unknown.author'); let needsAsyncFetch = false; if (loadedMessages[respondedId] && loadedMessages[respondedId].author !== "0") { let rAuthId = loadedMessages[respondedId].author; if (dmUsernameCache[rAuthId]) { replyAuthor = dmUsernameCache[rAuthId].split(':')[0]; } else { needsAsyncFetch = true; respondedAuthorId = rAuthId; } } else if (respondedAuthorId && dmUsernameCache[respondedAuthorId]) { replyAuthor = dmUsernameCache[respondedAuthorId].split(':')[0]; } else { needsAsyncFetch = true; } if (window.replyAuthorCache && window.replyAuthorCache[respondedId]) { replyAuthor = window.replyAuthorCache[respondedId]; needsAsyncFetch = false; } let randId = ""; if (needsAsyncFetch) { if (!window.replyAuthorCache) window.replyAuthorCache = {}; window.replyIdCounter = (window.replyIdCounter || 0) + 1; if (window.replyIdCounter > 1000000) window.replyIdCounter = 0; randId = "reply-author-" + window.replyIdCounter; replyAuthor = processBlah('unknown.author'); setTimeout(async () => { try { let aId = respondedAuthorId; if (!aId) { let payload = JSON.stringify({ string1: currentDmId, string2: "1", string3: respondedId }); let res = await fetchEncrypted("dm/messages/get", payload); if (res && !res.startsWith("error:")) { let msgs = JSON.parse(res); if (msgs[respondedId] && msgs[respondedId].author !== "0") { aId = msgs[respondedId].author; } } } if (aId) { let fname = dmUsernameCache[aId]; if (!fname) { fname = await fetchAsync(`${url}/idtoname?id=${aId}`); if (fname && !fname.startsWith("error:")) dmUsernameCache[aId] = fname; } if (fname && !fname.startsWith("error:")) { let sName = fname.split(':')[0]; window.replyAuthorCache[respondedId] = sName; let el = document.getElementById(randId); if (el) { el.innerHTML = ` ${processBlah('title.replied.to')} ${sName}`; } } } } catch (e) { } }, 10); } let divIdStr = randId !== "" ? `id="${randId}" ` : ""; repliedHtml = `
${processBlah('title.replied.to')} ${replyAuthor}
`; } let reactionsHtml = ""; if (reactions) { try { let rxArr = JSON.parse(reactions); if (rxArr.length > 0) { let rxCounts = {}; for (let rx of rxArr) { let parts = rx.split(':'); if (parts.length < 2) continue; let rxVal = parts.slice(1).join(':'); if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false}; rxCounts[rxVal].count++; if (parts[0] === id) rxCounts[rxVal].me = true; } let rxKeys = Object.keys(rxCounts); if (rxKeys.length > 0) { reactionsHtml = `
`; for (let rxVal of rxKeys) { let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);"; let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); let escapedRxVal = escapeHtml(rxVal); reactionsHtml += `${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}`; } reactionsHtml += `
`; } } } catch (e) { } } let isRedacted = msg.type === "larp.redacted"; let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``; let redactedIcon = isRedacted ? `` : ``; html += `
${showAvatar ? `` : `
`}
${showAvatar ? `
${authorName} ${timeStr}
` : ""} ${repliedHtml}
${redactedIcon}${content}
${reactionsHtml}
`; } } 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; let targetScrollTop = 0; if (wasAtBottom) { targetScrollTop = container.scrollHeight; } else if (isPrepend) { targetScrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight); } else { targetScrollTop = oldScrollTop; } ignoreScroll = true; container.scrollTop = targetScrollTop; lastChatScroll = container.scrollHeight - targetScrollTop - container.clientHeight; if (lastChatScroll < 0) lastChatScroll = 0; setTimeout(() => { ignoreScroll = false; }, 50); } var lastChatScroll = 0; var isAdjusting = false; var ignoreScroll = false; window.visualViewport?.addEventListener("resize", async () => { let container = document.getElementById("chat-messages"); console.log(container.scrollHeight - container.clientHeight - lastChatScroll) isAdjusting = true; while (isAdjusting) { //chat scroll fix ignoreScroll = true; container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll; //context menu fix let menuRect = fixedContextMenu.getBoundingClientRect(); if (menuRect.bottom > window.innerHeight) { fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`; } requestAnimationFrame(() => { ignoreScroll = false; }); await delay(1); } }); function setupChatScrollListener() { let container = document.getElementById("chat-messages"); if (!container) return; container.onscroll = async () => { if (!ignoreScroll) { isAdjusting = false; lastChatScroll = container.scrollHeight - container.scrollTop - container.clientHeight; if (lastChatScroll <= 10) { handleDmInteraction(); } } 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"); } }; } let replyingToMsgId = null; async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) { if (!currentDmId || !currentDmKey) return; let input = document.getElementById("chat-input"); let content = input.value.trim(); if (!content) return; let pendingMsgId = "pending_" + Date.now(); let container = document.getElementById("chat-messages"); if (container) { let html = `
${content} (${processBlah("info.sending.message")})
`; container.insertAdjacentHTML('beforeend', html); container.scrollTop = container.scrollHeight; } try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); let msgPayload = { string1: currentDmId, string2: encryptedContent, string3: "", string4: replyingToMsgId || "" }; input.value = ""; input.style.height = '2.5rem'; sendbutton.style.height = '2.5rem'; window.forceScrollToBottom = true; replyingToMsgId = null; let bar = document.getElementById("replying-bar"); if (bar) bar.style.display = "none"; showAction("action.message.sending", "msgsending"); let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload)); clearAction("msgsending"); if (res && res.startsWith("error:")) { showBlahNotification(res); let pElem = document.getElementById(pendingMsgId); if (pElem) pElem.remove(); } else if (res && res.startsWith("success:")) { let parts = res.split(":"); if (parts.length > 1 && parts[1] !== "message.sent") { let newMsgId = parts[1]; loadedMessages[newMsgId] = { author: id, timestamp: Date.now().toString(), type: "larp.text", content: encryptedContent, attachment: "", key: "0", pervious: "", responded: replyingToMsgId || "", reactions: "" }; renderMessages(loadedMessages); } } } catch (e) { clearAction("msgsending"); console.error(e); showBlahNotification("error:message.send.failed"); let pElem = document.getElementById(pendingMsgId); if (pElem) pElem.remove(); } } function handleChatInputResize(textarea) { const sendbutton = textarea.parentElement.children.item(1); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); textarea.style.transition = 'none'; textarea.style.height = '0rem'; sendbutton.style.height = '0rem'; //test let borderHeight = textarea.offsetHeight - textarea.clientHeight; textarea.style.height = ((textarea.scrollHeight + borderHeight) / rem) + 'rem'; sendbutton.style.height = ((textarea.scrollHeight + borderHeight) / rem) + 'rem'; //test 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; let dmMessageLoadTimeout = null; function setupWebSocket() { if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return; let wsUrl = url.replace(/^http/, "ws") + "/ws"; appWebSocket = new WebSocket(wsUrl); appWebSocket.onopen = async () => { 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 secretEnc = await encryptWithNonce(passwordHash, passwordHash, nonce); appWebSocket.send(JSON.stringify({string1: id, string2: secretEnc})); } catch (e) { console.error("WS auth failed", e); } finally { release(); } }; appWebSocket.onmessage = (event) => { let data = event.data; if (data.startsWith("dm_message:")) { let parts = data.split(":"); let msgDmId = parts[1]; let msgType = parts[2] || "unread"; if (msgType === "unread") { updateDmListUI(msgDmId, false, Date.now().toString()); } else if (msgType === "read") { updateDmListUI(msgDmId, true, Date.now().toString()); } else if (msgType === "update") { // do nothing to UI sorting/read state } if (currentDmId === msgDmId) { if (dmMessageLoadTimeout) clearTimeout(dmMessageLoadTimeout); dmMessageLoadTimeout = setTimeout(() => { loadDmMessages(msgDmId); }, 300); } } }; appWebSocket.onclose = () => { setTimeout(setupWebSocket, 5000); }; } let currentContextMenuMsgId = null; let touchContextMenuFired = false; function handleMessageContextMenu(e, msgId) { e.preventDefault(); if (touchContextMenuFired) { touchContextMenuFired = false; return; } if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); currentContextMenuMsgId = msgId; let elem = document.getElementById(`msg-${msgId}`); if (elem) elem.classList.add("context-menu-open"); showFixedContextMenu({top: e.clientY, right: e.clientX, bottom: e.clientY, left: e.clientX}, messageContextMenu); let delBtn = fixedContextMenu.querySelector("#context-delete-btn"); let reactBtn = fixedContextMenu.querySelector("#context-react-btn"); let msg = loadedMessages[msgId]; if (msg && msg.type === "larp.redacted") { if (delBtn) delBtn.style.display = "none"; if (reactBtn) reactBtn.style.display = "none"; } else { if (delBtn) { if (msg && msg.author !== id) { delBtn.style.display = "none"; } else { delBtn.style.display = ""; } } if (reactBtn) reactBtn.style.display = ""; } } async function deleteMessage(msgId) { if (fixedContextMenu) fixedContextMenu.classList.remove("show"); if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); showAction("action.message.deleting", "msgdel"); try { let msgPayload = { string1: currentDmId, string2: msgId }; let res = await fetchEncrypted("dm/message/delete", JSON.stringify(msgPayload)); if (res && res.startsWith("error:")) { showBlahNotification(res); } } catch (e) { showBlahNotification("error:message.delete.failed"); } clearAction("msgdel"); } function cancelReply() { replyingToMsgId = null; let bar = document.getElementById("replying-bar"); if (bar) bar.style.display = "none"; let input = document.getElementById("chat-input"); if (input) input.focus(); } async function scrollToMessage(msgId) { let elem = document.getElementById(`msg-${msgId}`); if (elem) { elem.scrollIntoView({behavior: "smooth", block: "center"}); elem.style.transition = "background-color 0.5s"; let oldBg = elem.style.backgroundColor; elem.style.backgroundColor = "rgba(255, 255, 255, 0.1)"; setTimeout(() => { elem.style.backgroundColor = oldBg; }, 1500); } else { if (oldestLoadedMsgId !== null && parseInt(msgId) < oldestLoadedMsgId) { showAction("info.messages.loading.older", "msgscroll"); let tries = 0; while (!document.getElementById(`msg-${msgId}`) && oldestLoadedMsgId > 0 && tries < 15) { await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true); tries++; } clearAction("msgscroll"); let elem2 = document.getElementById(`msg-${msgId}`); if (elem2) { elem2.scrollIntoView({behavior: "smooth", block: "center"}); elem2.style.transition = "background-color 0.5s"; let oldBg = elem2.style.backgroundColor; elem2.style.backgroundColor = "rgba(255, 255, 255, 0.1)"; setTimeout(() => { elem2.style.backgroundColor = oldBg; }, 1500); } else { showBlahNotification("error:message.not.found"); } } } } async function replyMessage(msgId) { if (fixedContextMenu) fixedContextMenu.classList.remove("show"); if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); let msg = loadedMessages[msgId]; if (msg && msg.author) { replyingToMsgId = msgId + ":" + msg.author; } else { replyingToMsgId = msgId; } let bar = document.getElementById("replying-bar"); if (bar) { let msg = loadedMessages[msgId]; let authorName = processBlah('unknown.author'); if (msg && msg.author !== "0" && dmUsernameCache[msg.author]) { authorName = dmUsernameCache[msg.author].split(':')[0]; } document.getElementById("replying-to-name").textContent = authorName; let content = msg ? msg.content : ""; if (msg && msg.key && msg.key !== "") { try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); content = await decryptAesGcmFromBase64(content, dmKeyBytes); } catch (e) { } } content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => processBlah(p1)); content = processBlah(content); document.getElementById("replying-to-text").textContent = content; bar.style.display = "flex"; } let input = document.getElementById("chat-input"); input.focus(); } var reactionPickerMenuHtml = ""; var loadedEmotes = []; async function loadEmotes() { try { let res = await fetch("emotes.json"); let emotesObj = await res.json(); let skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}']; loadedEmotes = Object.keys(emotesObj).flatMap(char => { let baseEmote = { char: char, name: emotesObj[char].name, slug: emotesObj[char].slug }; if (emotesObj[char].skin_tone_support) { let variants = skinTones.map(tone => ({ char: char + tone, name: emotesObj[char].name + " skin tone", slug: emotesObj[char].slug })); return [baseEmote, ...variants]; } return [baseEmote]; }); reactionPickerMenuHtml = `
${loadedEmotes.map(emoji => ``).join('')}
`; } catch (e) { console.error("Failed to load emotes", e); } } function filterReactions(query) { let grid = document.getElementById("reaction-picker-grid"); if (!grid) return; let buttons = grid.querySelectorAll(".reaction-emoji-btn"); let lowerQuery = query.toLowerCase(); buttons.forEach(btn => { let name = btn.getAttribute("data-name"); if (name && name.toLowerCase().indexOf(lowerQuery) > -1) { btn.style.display = "flex"; } else { btn.style.display = "none"; } }); } function openReactionPicker(e) { if (e) { e.stopPropagation(); e.preventDefault(); } if (!reactionPickerMenuHtml) return; let rect = fixedContextMenu.getBoundingClientRect(); showFixedContextMenu({top: rect.top, right: rect.left, bottom: rect.top, left: rect.left}, reactionPickerMenuHtml); let searchInput = document.getElementById('reaction-picker-search'); } async function reactMessagePrompt(msgId, quickReaction = null) { if (fixedContextMenu) fixedContextMenu.classList.remove("show"); if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); let reaction = quickReaction; if (!reaction) { return; } else { reaction = decodeURIComponent(reaction); } showAction("action.message.reacting", "msgreact"); try { let currentMsg = loadedMessages[msgId]; if (currentMsg && currentMsg.type === "larp.redacted") { clearAction("msgreact"); showNotification(processBlah("error.message.already.deleted"), "error"); return; } let existingReactions = []; if (currentMsg && currentMsg.reactions) { let rxStr = currentMsg.reactions; if (currentMsg.key && currentMsg.key !== "") { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); try { rxStr = await decryptAesGcmFromBase64(rxStr, dmKeyBytes); } catch (e) { } } try { existingReactions = JSON.parse(rxStr); } catch (e) { } } let targetEntry = id + ":" + reaction; let index = existingReactions.indexOf(targetEntry); if (index > -1) { existingReactions.splice(index, 1); } else { existingReactions.push(targetEntry); } let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let encryptedReactions = await encryptAesGcmToBase64(JSON.stringify(existingReactions), dmKeyBytes); let originalReactionsEncrypted = ""; if (currentMsg) { originalReactionsEncrypted = currentMsg.reactions || ""; currentMsg.reactions = encryptedReactions; renderMessages(loadedMessages); } let msgPayload = { string1: currentDmId, string2: msgId, string3: encryptedReactions }; let res = await fetchEncrypted("dm/message/react", JSON.stringify(msgPayload)); if (res && res.startsWith("error:")) { showBlahNotification(res); if (currentMsg) { currentMsg.reactions = originalReactionsEncrypted; renderMessages(loadedMessages); } } } catch (e) { showBlahNotification("error:message.react.failed"); } clearAction("msgreact"); } // --- Settings Logic --- async function gotoSettings(tab = 'account') { fixedContextMenu.classList.remove("show"); if (roomsBarContainer) roomsBarContainer.style.display = ""; await switchRoomsBar("title.settings", settingsBar); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 55 * rem) { switchSettingsTab(tab); } } async function switchSettingsTab(tabName) { setActiveRoombarItem('tab-settings-' + tabName); if (tabName === 'account') { await switchRoomContent("title.account", accountSettings, false); let userEl = document.getElementById("settings-username"); if (userEl && window.username) userEl.value = window.username; } else if (tabName === 'profile') { showAction('title.loading', 'settings-loading'); try { let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host); let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`; if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`; let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`); clearAction('settings-loading'); await switchRoomContent("title.profile", profileSettings, false); let pfpEl = document.getElementById("settings-avatar-preview"); if (pfpEl) pfpEl.src = pfpUrl; let bannerEl = document.getElementById("settings-banner-preview"); if (bannerEl) bannerEl.src = bannerUrl; let bioEl = document.getElementById("settings-bio"); if (bioEl && !bio.startsWith("error:")) bioEl.value = bio; } catch (e) { clearAction('settings-loading'); await switchRoomContent("title.profile", profileSettings, false); } } else if (tabName === 'appearance') { await switchRoomContent("title.appearance", appearanceSettings, false); populateLanguageDropdown(); } } async function populateLanguageDropdown() { let sel = document.getElementById("settings-language"); if (!sel) return; try { let path = window.location.pathname.replace("/login/index.html", "/").replace("index.html", ""); let res = await fetchAsync(`${path}blah/index.json`); let json = JSON.parse(res); sel.innerHTML = ""; for (let code in json) { let opt = document.createElement("option"); opt.value = code; opt.textContent = json[code].native + " (" + json[code].english + ")"; if (code === lang) opt.selected = true; sel.appendChild(opt); } } catch (e) { console.error("Failed to load languages", e); } } function changeLanguage(newLang) { if (!newLang) return; localStorage.setItem('lang', newLang); window.location.reload(); } async function changeUsername() { let input = document.getElementById("settings-username"); let name = input.value; if (!name) return; showAction("action.account.saving", "accountsave"); let res = await fetchPostEnc(`${url}/chname?id=${window.id}`, name); clearAction("accountsave"); let text = res; if (text.startsWith("success:")) { showBlahNotification(text); loginUsername = name; localStorage.setItem("username", name); } else { showBlahNotification(text); } } async function changePassword() { let input = document.getElementById("settings-password"); let newPass = input.value; if (!newPass) return; showAction("action.account.saving", "accountsave"); let newPassHash = await hashSHA3_512(newPass); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let payload = JSON.stringify({ string1: newPassHash, string2: JSON.stringify({ string1: newUserKeysEncrypted, string2: JSON.stringify(userKeysPublic) }) }); let res = await fetchPostEnc(`${url}/chpass?id=${window.id}`, payload); clearAction("accountsave"); let text = res; if (text.startsWith("success:")) { password = newPass; localStorage.setItem("password", newPass); passwordHash = newPassHash; userKeysEncrypted = newUserKeysEncrypted; localStorage.setItem(`userKeys.enc.v1:${window.id}`, userKeysEncrypted); showBlahNotification(text); } else { showBlahNotification(text); } } async function deleteAccount() { if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return; showAction("action.account.deleting", "accountdel"); let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, ""); clearAction("accountdel"); let text = res; if (text.startsWith("success:")) { alert(processBlah(text)); logout(); } else { showBlahNotification(text); } } async function saveProfile() { let bioInput = document.getElementById("settings-bio"); if (bioInput) { let payload = { string1: "larp.profile.bio", string2: bioInput.value || "" }; showAction("action.bio.saving", "savebio"); let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload)); clearAction("savebio"); if (res && res.startsWith("error:")) { showBlahNotification(res); return; } } showBlahNotification("success:profile.updated"); } // --- Cropper Logic --- let cropperType = 'avatar'; // 'avatar' or 'banner' let cropperImageObj = null; let cropperPosX = 0; let cropperPosY = 0; let cropperBaseZoom = 1; let cropperOriginalType = ''; let cropperOriginalB64 = ''; let isDraggingCropper = false; let startDragX = 0; let startDragY = 0; let startPosX = 0; let startPosY = 0; function openCropper(input, type) { if (!input.files || input.files.length === 0) return; let file = input.files[0]; cropperType = type; let reader = new FileReader(); reader.onload = (e) => { let img = new Image(); img.onload = () => { cropperImageObj = img; initCropper(); }; cropperOriginalType = file.type; cropperOriginalB64 = e.target.result; img.src = e.target.result; document.getElementById('cropper-image').src = e.target.result; }; reader.readAsDataURL(file); input.value = ""; // reset input } function initCropper() { let container = document.getElementById('cropper-viewport-container'); let imgEl = document.getElementById('cropper-image'); let zoomSlider = document.getElementById('cropper-zoom'); // Set aspect ratio let targetWidth = cropperType === 'avatar' ? 512 : 494; let targetHeight = cropperType === 'avatar' ? targetWidth : 200; let aspectRatio = targetWidth / targetHeight; container.style.height = 'auto'; container.style.maxHeight = 'calc(85vh - 16rem)'; container.style.aspectRatio = `${targetWidth} / ${targetHeight}`; container.style.maxWidth = `min(30rem, calc((85vh - 16rem) * ${aspectRatio}))`; // Force layout recalculation void container.offsetHeight; let containerWidth = container.clientWidth || 300; let imgWidth = cropperImageObj.width; let imgHeight = cropperImageObj.height; let scaleX = containerWidth / imgWidth; let scaleY = (containerWidth * (targetHeight / targetWidth)) / imgHeight; cropperBaseZoom = Math.max(scaleX, scaleY); zoomSlider.min = cropperBaseZoom; zoomSlider.max = cropperBaseZoom * 5; zoomSlider.value = cropperBaseZoom; cropperPosX = 0; cropperPosY = 0; updateCropperTransform(); document.getElementById('cropper-popup-container').classList.add('show'); } function updateCropperZoom() { updateCropperTransform(); } function updateCropperTransform() { let zoom = parseFloat(document.getElementById('cropper-zoom').value); let imgEl = document.getElementById('cropper-image'); let container = document.getElementById('cropper-viewport-container'); if (cropperImageObj) { let scaledW = cropperImageObj.width * zoom; let scaledH = cropperImageObj.height * zoom; let minX = container.clientWidth - scaledW; let maxX = 0; let minY = container.clientHeight - scaledH; let maxY = 0; if (minX > maxX) minX = maxX; if (minY > maxY) minY = maxY; if (cropperPosX < minX) cropperPosX = minX; if (cropperPosX > maxX) cropperPosX = maxX; if (cropperPosY < minY) cropperPosY = minY; if (cropperPosY > maxY) cropperPosY = maxY; } imgEl.style.transform = `translate(${cropperPosX}px, ${cropperPosY}px) scale(${zoom})`; } function closeCropper() { document.getElementById('cropper-popup-container').classList.remove('show'); } async function confirmCropper() { let zoom = parseFloat(document.getElementById('cropper-zoom').value); let container = document.getElementById('cropper-viewport-container'); let targetWidth = cropperType === 'avatar' ? 512 : 988; let targetHeight = cropperType === 'avatar' ? 512 : 400; let scaleRatio = targetWidth / container.clientWidth; let drawX = cropperPosX * scaleRatio; let drawY = cropperPosY * scaleRatio; let drawW = cropperImageObj.width * zoom * scaleRatio; let drawH = cropperImageObj.height * zoom * scaleRatio; let b64; let newSrc; let isAnimated = false; let gifFrames = null; let webpDecoded = null; let parsedGif = null; if (cropperOriginalType === 'image/gif') { isAnimated = true; } else if (cropperOriginalType === 'image/webp') { showAction("action.processing", "uploadmedia"); try { let res = await fetch(cropperOriginalB64); let buffer = await res.arrayBuffer(); let webpWorker = new Worker('webp-worker.js', { type: 'module' }); webpDecoded = await new Promise((resolve, reject) => { webpWorker.onmessage = (e) => { if (e.data.success) resolve(e.data.data); else reject(new Error(e.data.error)); webpWorker.terminate(); }; webpWorker.onerror = (err) => { reject(err); webpWorker.terminate(); }; webpWorker.postMessage({ action: 'decode', data: new Uint8Array(buffer) }); }); if (webpDecoded && webpDecoded.length > 1) { isAnimated = true; } } catch (e) { console.error("WebP decode check failed", e); } } if (isAnimated) { showAction("action.processing", "uploadmedia"); try { let webpFrames = []; let tempCanvas = document.createElement('canvas'); let tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); let gifCanvas = document.createElement('canvas'); let gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true }); gifCanvas.width = targetWidth; gifCanvas.height = targetHeight; if (cropperOriginalType === 'image/gif') { let res = await fetch(cropperOriginalB64); let buffer = await res.arrayBuffer(); parsedGif = gifuct.parseGIF(buffer); gifFrames = gifuct.decompressFrames(parsedGif, true); tempCanvas.width = parsedGif.lsd.width; tempCanvas.height = parsedGif.lsd.height; let frameImageData = null; let previousImageData = null; for (let i = 0; i < gifFrames.length; i++) { let frame = gifFrames[i]; if (frame.disposalType === 3) { previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); } if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) { frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height); } frameImageData.data.set(frame.patch); let patchCanvas = document.createElement('canvas'); patchCanvas.width = frame.dims.width; patchCanvas.height = frame.dims.height; patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0); tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top); gifCtx.fillStyle = '#000'; gifCtx.fillRect(0, 0, targetWidth, targetHeight); gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH); let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight); webpFrames.push({ data: frameImgData.data, duration: Math.max(20, frame.delay || 0), config: { lossless: 0, quality: 90 } }); if (frame.disposalType === 2) { tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height); } else if (frame.disposalType === 3 && previousImageData) { tempCtx.putImageData(previousImageData, 0, 0); } } } else if (cropperOriginalType === 'image/webp' && webpDecoded) { tempCanvas.width = webpDecoded[0].width; tempCanvas.height = webpDecoded[0].height; for (let i = 0; i < webpDecoded.length; i++) { let frame = webpDecoded[i]; let frameImageData = new ImageData( new Uint8ClampedArray(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength), frame.width, frame.height ); tempCtx.putImageData(frameImageData, 0, 0); gifCtx.fillStyle = '#000'; gifCtx.fillRect(0, 0, targetWidth, targetHeight); gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH); let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight); webpFrames.push({ data: frameImgData.data, duration: Math.max(20, frame.duration || 0), config: { lossless: 0, quality: 90 } }); } } let webpUint8 = await new Promise((resolve, reject) => { let worker = new Worker('webp-worker.js', { type: 'module' }); worker.onmessage = (e) => { if (e.data.success) { resolve(e.data.data); } else { reject(new Error(e.data.error)); } worker.terminate(); }; worker.onerror = (err) => { reject(err); worker.terminate(); }; worker.postMessage({ action: 'encode', targetWidth, targetHeight, webpFrames }); }); let webpBlob = new Blob([webpUint8], {type: 'image/webp'}); b64 = await new Promise((resolve) => { let reader = new FileReader(); reader.onload = function() { resolve(reader.result.split(',')[1]); }; reader.readAsDataURL(webpBlob); }); newSrc = 'data:image/webp;base64,' + b64; } catch (e) { console.error("Animated crop failed", e); b64 = cropperOriginalB64.split(',')[1]; newSrc = cropperOriginalB64; } } else { let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); canvas.width = targetWidth; canvas.height = targetHeight; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, targetWidth, targetHeight); ctx.drawImage(cropperImageObj, drawX, drawY, drawW, drawH); b64 = canvas.toDataURL('image/webp', 0.9).split(',')[1]; newSrc = 'data:image/webp;base64,' + b64; } let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner'; let fileBytes = base64ToUint8(b64); showAction("action.uploading", "uploadmedia"); let res = await fetchEncryptedUpload(payloadKey, fileBytes); clearAction("uploadmedia"); if (res && res.startsWith("error:")) { showBlahNotification(res); } else { showBlahNotification(`success:${cropperType}.updated`); window.profileUpdateTimestamp = Date.now(); // Update all matching images on the page instantly document.querySelectorAll('img').forEach(img => { if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) { img.src = newSrc; } }); } closeCropper(); } let container = document.getElementById('cropper-viewport-container'); if (container) { const startDrag = (e) => { isDraggingCropper = true; startDragX = e.clientX || (e.touches && e.touches[0].clientX); startDragY = e.clientY || (e.touches && e.touches[0].clientY); startPosX = cropperPosX; startPosY = cropperPosY; if (e.type !== "touchstart") e.preventDefault(); }; const onDrag = (e) => { if (!isDraggingCropper) return; let clientX = e.clientX || (e.touches && e.touches[0].clientX); let clientY = e.clientY || (e.touches && e.touches[0].clientY); cropperPosX = startPosX + (clientX - startDragX); cropperPosY = startPosY + (clientY - startDragY); updateCropperTransform(); }; const endDrag = () => { isDraggingCropper = false; }; container.addEventListener('mousedown', startDrag); container.addEventListener('touchstart', startDrag, {passive: true}); document.addEventListener('mousemove', onDrag); document.addEventListener('touchmove', onDrag, {passive: true}); document.addEventListener('mouseup', endDrag); document.addEventListener('touchend', endDrag); } function updateDmListUI(dmId, isRead, timestamp = null) { try { if (!window.cachedDms) return; let parsedDms = JSON.parse(window.cachedDms); if (!parsedDms.dms || !parsedDms.dms[dmId]) return; let changed = false; if (parsedDms.dms[dmId].string3 !== (isRead ? "true" : "false")) { parsedDms.dms[dmId].string3 = isRead ? "true" : "false"; changed = true; } if (timestamp && parsedDms.dms[dmId].string2 !== timestamp) { parsedDms.dms[dmId].string2 = timestamp; changed = true; } if (changed) { window.cachedDms = JSON.stringify(parsedDms); } let btn = document.getElementById(`dm-btn-${dmId}`); let container = document.getElementById("dms-list"); if (btn && container) { let nameSpan = btn.querySelector('.room-name'); if (nameSpan) { nameSpan.style.opacity = isRead ? "0.8" : "1"; } if (timestamp) { container.prepend(btn); } } else { if (typeof refreshDms === 'function') refreshDms(0, false); } } catch (e) { console.error(e); if (typeof refreshDms === 'function') refreshDms(0, false); } } function markDmAsRead(dmId) { fetchEncrypted("dm/read", JSON.stringify({string1: dmId})).catch(()=>{}); updateDmListUI(dmId, true); } function handleDmInteraction() { if (!currentDmId) return; let container = document.getElementById("chat-messages"); if (!container) return; if (lastChatScroll <= 10) { try { if (window.cachedDms) { let parsedDms = JSON.parse(window.cachedDms); if (parsedDms.dms && parsedDms.dms[currentDmId] && parsedDms.dms[currentDmId].string3 !== "true") { markDmAsRead(currentDmId); } } } catch (e) {} } } document.addEventListener('click', handleDmInteraction); document.addEventListener('keydown', handleDmInteraction); document.addEventListener('touchstart', handleDmInteraction);