LarpixClient/webroot/main.js
olcxja b24b36adec
All checks were successful
Android Build / publish (push) Successful in 52s
Linux Build / publish (push) Successful in 49s
Fix race conditions
2026-06-02 11:58:18 +02:00

1960 lines
No EOL
63 KiB
JavaScript

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