LarpixClient/webroot/main.js
2026-08-26 00:11:00 +02:00

5824 lines
229 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}`, {
headers: {"X-Crypto": "g1"}
})).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);
//0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji)
return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt]
}
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);
}
async function encryptAesGcmToBytes(plainBytes, keyBytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await importAesGcmKey(keyBytes);
const ctBuf = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plainBytes);
const combined = new Uint8Array(iv.length + ctBuf.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ctBuf), iv.length);
return combined;
}
async function decryptAesGcmFromBytes(cipherBytes, keyBytes) {
const iv = cipherBytes.slice(0, 12);
const ct = cipherBytes.slice(12);
const key = await importAesGcmKey(keyBytes);
const ptBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct);
return new Uint8Array(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;
window.groupKeysCache = {};
async function getGroupKey(groupId, keyIndex) {
if (!window.groupKeysCache[groupId]) {
window.groupKeysCache[groupId] = {};
}
if (window.groupKeysCache[groupId][keyIndex]) {
return window.groupKeysCache[groupId][keyIndex];
}
let payload = JSON.stringify({string1: groupId, string2: keyIndex.toString()});
let res = await fetchEncrypted("group/key/get", payload);
if (!res || res.startsWith("error:")) {
return null;
}
try {
let decryptedKey;
if (res.startsWith("SETUP:")) {
const rest = res.substring("SETUP:".length);
const parts = rest.split(";");
if (parts.length < 2) throw new Error("error:group.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);
decryptedKey = await decryptAesGcmFromBase64(setupPayload.enc, aesKeyBytes);
} else {
decryptedKey = await unwrapRoomKeyForSelf(res);
}
window.groupKeysCache[groupId][keyIndex] = decryptedKey;
return decryptedKey;
} catch(e) {
return null;
}
}
let pendingRotationMessagePayload = null;
let genericPopupConfirmCallback = null;
let genericPopupCancelCallback = null;
function closeGenericPopup() {
let popupContainer = document.getElementById("generic-popup-container");
if (popupContainer) {
popupContainer.classList.remove("show");
setTimeout(() => {
popupContainer.style.display = "none";
}, 200);
}
if (genericPopupCancelCallback) {
genericPopupCancelCallback();
genericPopupCancelCallback = null;
}
genericPopupConfirmCallback = null;
}
function showConfirmPopup(titleKey, descHtml, confirmActionKey, cancelActionKey, onConfirm, onCancel = null, isDestructive = false) {
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
genericPopupConfirmCallback = () => {
let cb = onConfirm;
genericPopupCancelCallback = null; // Prevent cancel from firing on confirm
closeGenericPopup();
if (cb) cb();
};
let html = `
<div style="font-size: 1.2rem; font-weight: 500; text-align: center;"><blah>${titleKey}</blah></div>
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 1rem; margin-bottom: 1rem;">
${descHtml}
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
`;
popupInner.innerHTML = html;
let blahTags = popupInner.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
popupContainer.style.display = "flex";
popupContainer.offsetHeight; // trigger reflow
popupContainer.classList.add("show");
}
function showPromptPopup(titleKey, descHtml, placeholderKey, confirmActionKey, cancelActionKey, onConfirm, onCancel = null, isDestructive = false) {
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
genericPopupConfirmCallback = () => {
let val = document.getElementById("generic-popup-input").value;
let cb = onConfirm;
genericPopupCancelCallback = null; // Prevent cancel from firing on confirm
closeGenericPopup();
if (cb) cb(val);
};
let html = `
<div style="font-size: 1.2rem; font-weight: 500; text-align: center;"><blah>${titleKey}</blah></div>
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 0.5rem; margin-bottom: 1rem;">
${descHtml}
</div>
<div class="input-group" style="margin: 0 auto 1.5rem auto; width: 100%;">
<label for="generic-popup-input" style="margin-left: calc((var(--button-margin) * 2) - 0.6rem);"><blah>${placeholderKey}</blah></label>
<input type="text" id="generic-popup-input" class="forminput" placeholder="${processBlah(placeholderKey)}" style="margin-left: 0; margin-right: 0; width: 100%; box-sizing: border-box;" onkeydown="if(arguments[0].key === 'Enter') { if(genericPopupConfirmCallback) genericPopupConfirmCallback(); }">
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
`;
popupInner.innerHTML = html;
let input = document.getElementById("generic-popup-input");
if (input && input.placeholder.includes("{blah(")) {
let key = input.placeholder.replace("{blah(", "").replace(")}", "");
input.placeholder = processBlah(key);
}
let blahTags = popupInner.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
popupContainer.style.display = "flex";
popupContainer.offsetHeight; // trigger reflow
popupContainer.classList.add("show");
setTimeout(() => {
let el = document.getElementById("generic-popup-input");
if (el) el.focus();
}, 100);
}
function closeRotationPopup() {
closeGenericPopup();
}
async function showRotationPopup(groupId, originalMessagePayload) {
pendingRotationMessagePayload = originalMessagePayload;
showAction("action.key.rotating", "key_rotation");
try {
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let descHtml = `<blah>desc.key.rotation.you</blah><br><br><b>${members.length} <blah>label.keys.to.encrypt</blah></b>`;
showConfirmPopup(
"title.key.rotation",
descHtml,
"action.rotate.keys",
"action.cancel",
() => executeKeyRotation(),
() => clearAction("key_rotation")
);
} catch(e) {
clearAction("key_rotation");
showBlahNotification("error:something.wrong");
}
}
async function executeKeyRotation() {
closeRotationPopup();
clearAction("key_rotation");
showAction("action.key.rotating", "key_rotation_exec");
try {
let groupId = currentDmId;
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let roomKey = randomRoomKey256Chars();
let keysDict = {};
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
for(let memberId of members) {
let pureId = memberId.split(";")[0];
if(pureId === window.id) {
keysDict[memberId] = await wrapRoomKeyForSelf(roomKey);
} else {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${pureId}`);
if(publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[memberId] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
} else {
throw new Error("error:missing.keys");
}
}
}
let payload = JSON.stringify({
groupId: groupId,
keys: keysDict
});
let res = await fetchEncrypted("group/key/rotate", payload);
if(res && res.startsWith("error:")) {
throw new Error(res);
}
// Success!
let newIndex = res.replace("success:", "");
window.currentGroupKeyIndex = newIndex;
if(!window.groupKeysCache[groupId]) window.groupKeysCache[groupId] = {};
window.groupKeysCache[groupId][newIndex] = roomKey;
clearAction("key_rotation_exec");
showBlahNotification("success:key.rotated");
pendingRotationMessagePayload = null;
} catch(e) {
clearAction("key_rotation_exec");
showBlahNotification(e.message || e);
pendingRotationMessagePayload = null;
}
}
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 pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv},
key,
encoder.encode(plainText)
));
return "G1:" + uint8ToBase64(concatUint8(iv, encrypted));
}
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-GCM'}, false, ['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv},
importedKey,
dataUint8
));
const prefix = encoder.encode("G1:");
const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength);
combined.set(prefix, 0);
combined.set(iv, prefix.length);
combined.set(encrypted, prefix.length + iv.length);
return combined;
}
async function decryptString(base64Text, passphrase) {
//tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane
if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) {
throw new Error("error:invalid.cipher.format");
}
const encoder = new TextEncoder();
const combined = base64ToUint8(base64Text.substring(3));
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt']
);
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{name: 'AES-GCM', 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: {
"X-Crypto": "g1",
"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: {
"X-Crypto": "g1",
"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: {
"X-Crypto": "g1",
"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];
//sól KDF jest wymagana - konta bez soli nie mogą się zalogować
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`);
if (!saltRes || !saltRes.startsWith("P:")) {
return "error:invalid.username.or.password";
}
let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim());
let response = await fetch(`${url}/auth?id=${actualId}`, {
method: "GET",
headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash))
}
});
let data = await response.text();
if (data.startsWith("success:")) {
data += "|" + actualId;
//utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash
passwordHash = credHash;
if (!id) {
id = actualId;
try { localStorage.setItem("id", actualId); } catch (e) {}
}
}
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: {
"X-Crypto": "g1",
"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: {
"X-Crypto": "g1",
"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);
});
}
//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex)
async function deriveCredentialKdf(passwordPlain, saltB64) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(passwordPlain),
{name: "PBKDF2"},
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000},
keyMaterial,
512
);
return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
}
//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji
//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje)
async function resolveCredentialHash(accountId) {
if (!accountId) return null;
try {
let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`);
if (res && res.startsWith("P:") && password) {
return await deriveCredentialKdf(password, res.substring(2).trim());
}
} catch (e) {}
return null;
}
//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 = `
${ICONS.avatar(initials, color, size)}
`.trim();
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
async function getAvatarUrl(id, username) {
//S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
//generowało 1-2 żądania NA wiersz (główny składnik N+1).
//Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
//network itp.) nie mogą przyklejać fallback-SVG na minutę
if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
const cached = window.pfpExistenceCache[id];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
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();
}
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
return pfpUrl;
} catch (e) {
return createAvatarSvg(username);
}
}
async function getGroupAvatarUrl(groupId, groupName) {
if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
const cached = window.groupPfpExistenceCache[groupId];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
return pfpUrl;
} catch (e) {
return createAvatarSvg(groupName);
}
}
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();
//emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML
if (!window.location.pathname.includes("/login")) {
await loadEmotes();
}
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);
}
}
//hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu -
//start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login
if (id && password) {
try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; }
}
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 = '0rem';
fixedContextMenu.style.top = '0rem';
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 = escapeHtmlText(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);
let slideIn = false;
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
if (title != "title.splash") //do not show splash on mobile
{
if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) {
slideIn = true;
}
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
}
}
if (!slideIn) {
await delay(200);
}
let pinCallBtns = showRoomBar ? `${pinBtnHtml}${callBtnHtml}` : '';
let clickableContent = showRoomBar ?
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</button>` :
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</inherit>`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
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;
var savedDetailsHtml = null;
var savedDetailsTitle = null;
async function switchDetailsContent(title, content, showBack = false, backAction = "", saveCurrent = false) {
if (saveCurrent && currentDetailsContentTitle !== title) {
savedDetailsHtml = currentDetailsContentHtml;
savedDetailsTitle = currentDetailsContentTitle;
}
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 || mainScreen.classList.contains('mobile-details')) {
await delay(200);
}
let pinCallBtns = showBack ? `${callBtnHtml}` : `${pinBtnHtml}${callBtnHtml}`;
if (window.currentRoomType === 'groups' && !showBack) {
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
}
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${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);
}
function clickAddAttachment(btn, e) {
if (e) {
e.stopPropagation();
}
history.pushState({trap: true}, "", location.href);
if (fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
} else {
const rect = btn.getBoundingClientRect();
showFixedContextMenu(rect, attachmentMenu);
// Let's reposition it so it appears properly anchored above the input if it's too close to the bottom.
// showFixedContextMenu automatically handles bounds, but it's set to top-right alignment basically.
// It's perfectly fine to rely on the default bounds checking of showFixedContextMenu.
}
}
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) => {
if (touchContextMenuFired) return;
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('#room-topbar-clickable') && !e.target.closest('.profile-popup-container') && !e.target.closest('.room-action-pin')) {
if (currentRoomContentTitle === "title.group.settings") {
openDm(window.currentSettingsGroupId, document.getElementById('group-settings-name').value, window.currentSettingsGroupId, 'groups');
} else {
mobileNavBack();
}
}
}
}
});
window.addEventListener("popstate", (e) => {
popstate();
});
const {App} = window.Capacitor?.Plugins || {};
if (App) {
App.addListener('backButton', () => {
popstate();
});
}
function popstate() {
if (fixedContextMenu && fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
return;
}
let cropperPopup = document.getElementById("cropper-popup-container");
if (cropperPopup && cropperPopup.classList.contains("show")) {
cropperPopup.classList.remove("show");
return;
}
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 (touchContextMenuFired) return;
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
accountId = sanitizeId(accountId);
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 = `<img src="${bannerUrl}" class="profile-banner" onerror="this.outerHTML='<div class=\\'profile-banner\\'></div>'">`;
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() !== "" ? `<div class="profile-bio">${escapeHtml(bio)}</div>` : "";
let isOwnProfile = accountId === id;
let actionBtnHtml = "";
if (isOwnProfile) {
actionBtnHtml = `<button class="profile-action-btn" onclick="gotoSettings('profile');closeProfile();">${processBlah('action.edit.profile')}</button>`;
} 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) {
let safeName = encodeURIComponent(nameWithHost).replace(/'/g, "%27");
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', decodeURIComponent('${safeName}'), '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
} 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 = `<button class="profile-action-btn" onclick="acceptInvite('${accountId}'); closeProfile();">${processBlah('action.invite.accept')}</button>`;
} else if (sentInvite) {
actionBtnHtml = `<button class="profile-action-btn" onclick="revokeInvite('${accountId}'); closeProfile();">${processBlah('action.invite.cancel')}</button>`;
} else {
actionBtnHtml = `<button class="profile-action-btn" onclick="sendInviteTo('${accountId}'); closeProfile();">${processBlah('action.invite.send')}</button>`;
}
}
}
let roleHtml = "";
if (window.currentDmId && window.currentRoomType && (window.currentRoomType === "groups" || window.currentRoomType === "dms" || window.currentRoomType === "spaces")) {
let payload = JSON.stringify({
string1: window.currentRoomType,
string2: window.currentDmId,
string3: accountId
});
let roleRes = await fetchEncrypted("room/role", payload);
if (roleRes && !roleRes.startsWith("error:")) {
//roleRes trafia do innerHTML - whitelist znaków zamiast surowej
//podstawienia (audyt W6: serwer-controlled string w szablonie)
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
}
}
popupInner.innerHTML = `
${bannerHtml}
<div class="profile-avatar-container">
<img src="${pfpUrl}" class="profile-avatar">
</div>
<div class="profile-info">
<div>
<div class="profile-name">${escapeHtml(profileName)}</div>
<div class="profile-id-host">${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})</div>
${roleHtml}
</div>
${bioHtml}
${actionBtnHtml}
</div>
`;
clearAction('profile-loading');
popupContainer.style.display = "flex";
popupInner.scrollTop = 0;
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 acceptGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.accepting", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/join", payload);
clearAction("invite.action");
showBlahNotification(res || "success:joined");
// Refresh invites and groups
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function declineGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.declining", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/invite/decline", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.declined");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function revokeGroupInvite(groupId, targetId) {
try {
showAction("action.invite.revoking", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: targetId
});
let res = await fetchEncrypted("group/invite/revoke", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.revoked");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
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 = sanitizeId(invite.id);
if (!id.includes(":")) {
id += `:${host}`;
}
let onlyId = id.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${id}`);
if (!(username && username.trim() !== "")) continue;
count++;
let pfp = await getAvatarUrl(id, username);
let actions = "";
let desc = "";
let displayName = escapeHtml(username || onlyId);
if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptInvite('${id}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
<button class="icon-button" style="background-color: var(--big-red);" onclick="declineInvite('${id}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
} else {
desc = `:desc.invite.dm.sent:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeInvite('${id}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
}
let entry = invitesEntry
.replaceAll("{username}", displayName)
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", `onclick="openProfile('${id}')" style="cursor: pointer;"`)
.replaceAll("{id}", id);
html += entry;
} else if (invite.type === "group") {
let groupId = sanitizeId(invite.groupId || invite.id);
let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver);
if (!targetId.includes(":")) {
targetId += `:${host}`;
}
let onlyId = targetId.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!(username && username.trim() !== "")) continue;
// Fetch group name
let groupInfoStr = await fetchEncrypted("group/info", groupId);
let groupName = processBlah("title.unknown.group");
if (groupInfoStr && !groupInfoStr.startsWith("error")) {
let groupInfo = JSON.parse(groupInfoStr);
groupName = groupInfo.string2;
}
count++;
let pfp = await getGroupAvatarUrl(groupId, groupName);
let actions = "";
let desc = "";
let displayName = escapeHtml(username || onlyId);
let val1 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${displayName}</b></span>`;
let val2 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${escapeHtml(onlyId)}</b></span>`;
if (type === "received") {
desc = `:desc.invite.group.received:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptGroupInvite('${groupId}', '${targetId}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
<button class="icon-button" style="background-color: var(--big-red);" onclick="declineGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
} else {
desc = `:desc.invite.group.sent:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
}
let entry = invitesEntry
.replaceAll("{username}", escapeHtmlText(groupName))
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", "")
.replaceAll("{id}", groupId);
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 (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
switchRoomContent("title.splash", splashScreen, false);
}
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
await Promise.all([
refreshDms(210),
(typeof refreshGroups === 'function' ? refreshGroups(210) : Promise.resolve())
]);
}
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 (currentRoomContentTitle === "title.group.settings") {
openDm(window.currentSettingsGroupId, document.getElementById('group-settings-name').value, window.currentSettingsGroupId, 'groups');
return;
}
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 touchStartTarget = null;
let pressDate;
document.addEventListener('touchstart', e => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
touchMoved = false;
touchStartTarget = e.target;
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; //najgorszy fix swiata
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();
}
});
// Swipes that begin inside floating layers (popups, dialogs, dropdowns -
// anything rendered with position: fixed) belong to those layers and must
// not trigger app navigation gestures.
function touchStartedInFloatingLayer() {
let el = touchStartTarget;
while (el && el !== document.body && el !== document.documentElement) {
if (getComputedStyle(el).position === 'fixed') return true;
el = el.parentElement;
}
return false;
}
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 (touchStartedInFloatingLayer()) return;
if (diffX > 0) {
mobileNavBack();
} else {
if (mainScreen.classList.contains('mobile-content') && roomDetailsBar.style.display !== 'none') {
mobileNavDetails();
}
}
}
}
//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
let dmListRenderGen = 0;
let groupListRenderGen = 0;
async function renderDms(res) {
const gen = ++dmListRenderGen;
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 [dmIdRaw, dmData] of dmEntries) {
let dmId = sanitizeId(dmIdRaw);
let parts = dmId.split('_');
let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
//S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
if (!targetUsername) {
targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
}
let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]);
let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(dmIdRaw, dmData.call === true);
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${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" style="${nameStyle}">${displayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span>
</div>
${unreadBadge}
${getCallIndicatorHtml()}
</button>
`;
count++;
}
if (gen !== dmListRenderGen) return; //nowszy render przejÄ…Å‚ listÄ™
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
updateVoicePresenceUI();
} else {
if (gen !== dmListRenderGen) return;
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);
}
}
}
//S22: TTL-cache nazw grup (60 s) - nazwy rzadko siÄ™ zmieniajÄ…,
//a group/info było najdroższym elementem renderu listy grup
function getGroupNameFromCache(groupId) {
if (!window.groupNameCache) return null;
let entry = window.groupNameCache[sanitizeId(groupId)];
if (!entry || Date.now() - entry.ts > 60000) return null;
return entry.name;
}
async function renderGroups(res) {
const gen = ++groupListRenderGen;
let container = document.getElementById("groups-list");
if (!container) return;
if (res && res !== "") {
let parsed = JSON.parse(res);
let groups = parsed.groups || {};
let groupEntries = Object.entries(groups);
//Sort by timestamp descending
groupEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0));
let html = "";
let count = 0;
for (let [groupId, groupData] of groupEntries) {
let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(groupId, groupData.call === true);
//S22: TTL-cache nazw grup - group/info było wołane per grupa per render
let groupName = getGroupNameFromCache(groupId);
if (groupName === null) {
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
groupName = JSON.parse(infoRes).string2;
if (!window.groupNameCache) window.groupNameCache = {};
window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
} else {
if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
}
continue;
}
}
{
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
let safeGroupId = sanitizeId(groupId);
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="group-btn-${safeGroupId}" onclick="openDm('${safeGroupId}', decodeURIComponent('${safeGroupName}'), '${safeGroupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${safeGroupId}')">
<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" style="${nameStyle}">${escapeHtmlText(groupName)}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span>
</div>
${unreadBadge}
${getCallIndicatorHtml()}
</button>
`;
count++;
}
}
if (gen !== groupListRenderGen) return; //nowszy render przejÄ…Å‚ listÄ™
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
updateVoicePresenceUI();
} else {
if (gen !== groupListRenderGen) return;
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</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.groups</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
var dmUsernameCache = {};
var dmPfpCache = {};
var loadedMessages = {};
var oldestLoadedMsgId = null;
var isLoadingOlderMessages = false;
function escapeHtml(unsafe) {
if (!unsafe) return "";
return unsafe
.replaceAll(/&/g, "&amp;")
.replaceAll(/</g, "&lt;")
.replaceAll(/>/g, "&gt;")
.replaceAll(/"/g, "&quot;")
.replaceAll(/'/g, "&#039;");
}
//ID-y/identyfikatory wstawiane do atrybutów HTML i inline JS (onclick).
//Usuwa wszystko poza bezpiecznym zestawem znakow - nie da się wyjść z stringa JS ani atrybutu.
//Dozwolone: litery, cyfry oraz _ . ; : @ -
function sanitizeId(value) {
return String(value ?? "").replace(/[^A-Za-z0-9_.;:@-]/g, "");
}
//nazwy (grup itp.) wstawiane do treści HTML
function escapeHtmlText(value) {
return escapeHtml(String(value ?? ""));
}
// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) ---
function getCallIndicatorHtml() {
return `<div class="call-indicator">${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}</div>`;
}
function roomHasActiveCall(roomId) {
if (!roomId || !window.activeCallsByRoom) return false;
//Semantyka mapy: klucz ISTNIEJE = rozmowa trwa. Pusta tablica oznacza
//"aktywna, ale skład jeszcze nieznany" (seed z listy DM/grup).
return Array.isArray(window.activeCallsByRoom[sanitizeId(roomId)]);
}
//Zapisuje flagÄ™ "trwa rozmowa" z listy DM/grup (nie nadpisuje znanej listy
//uczestników z call_update - ta jest dokładniejsza).
function ensureActiveCallsMap() {
//Obiekt bez prototypu - klucze pochodzÄ… z danych sieciowych (roomId), a
//zapis np. pod "__proto__" na zwykłym obiekcie zanieczyściłby prototyp
if (!window.activeCallsByRoom) window.activeCallsByRoom = Object.create(null);
return window.activeCallsByRoom;
}
function seedCallStateFromList(roomId, hasCall) {
if (!hasCall) return;
let map = ensureActiveCallsMap();
let key = sanitizeId(roomId);
if (!Array.isArray(map[key])) {
map[key] = [];
}
}
function toggleCallIndicator(rootEl, show) {
if (!rootEl) return;
let ind = rootEl.querySelector(".call-indicator");
if (ind) ind.classList.toggle("show", !!show);
}
//Odświeża wskaźniki rozmów: listy DM/grup, przyciski call w top-barach
//oraz wiersze członków w panelu details.
//roomIds = null -> pełna synchronizacja (po renderze list),
//roomIds = [id] -> tylko wskazane pokoje (ścieżka gorąca: call_update),
// żeby przy dużych listach nie skanować całego DOM.
function updateVoicePresenceUI(roomIds = null) {
let targets = Array.isArray(roomIds) ? roomIds.map(r => sanitizeId(r)) : null;
[["dms-list", "dm-btn-"], ["groups-list", "group-btn-"]].forEach(([listId, prefix]) => {
let container = document.getElementById(listId);
if (!container) return;
container.querySelectorAll(".room-entry").forEach(btn => {
if (!btn.id.startsWith(prefix)) return;
let roomId = btn.id.substring(prefix.length);
if (targets && !targets.includes(roomId)) return;
toggleCallIndicator(btn, roomHasActiveCall(roomId));
});
});
let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
let currentAffected = targets === null || (currentKey !== null && targets.includes(currentKey));
if (!currentAffected) return;
let inCurrent = roomHasActiveCall(window.currentDmId);
document.querySelectorAll(".room-action-call svg").forEach(svg => {
svg.style.fill = inCurrent ? "var(--big-green)" : "";
});
if (currentKey) {
let users = (window.activeCallsByRoom && window.activeCallsByRoom[currentKey]) || [];
let detailsBar = document.getElementById("roomdetailsbar");
if (detailsBar) {
detailsBar.querySelectorAll(".room-entry[data-member-id]").forEach(btn => {
toggleCallIndicator(btn, users.includes(btn.getAttribute("data-member-id")));
});
}
}
}
//Pobiera aktualny skład rozmowy dla otwartego pokoju (snapshot po otwarciu -
//call_update przychodzi dopiero przy join/leave).
async function refreshRoomCallState() {
try {
if (!window.currentDmId || !window.currentRoomType) return;
let res = await fetchEncrypted("room/voice/state", JSON.stringify({
string1: window.currentRoomType,
string2: window.currentDmId
}));
if (!res || !res.startsWith("[") || !window.currentDmId) return;
let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
let map = ensureActiveCallsMap();
let key = sanitizeId(window.currentDmId);
if (participants.length > 0) map[key] = participants;
else delete map[key];
updateVoicePresenceUI([key]);
} catch (e) {}
}
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];
let safeTargetId = sanitizeId(targetId);
let safeMyId = sanitizeId(myIdStr);
targetDisplayName = escapeHtmlText(targetDisplayName);
let myDisplaySafe = escapeHtmlText(myDisplayName);
return `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div>
<button class="room-entry" data-member-id="${sanitizeId(targetId.split(';')[0])}" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" 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">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span>
</div>
${getCallIndicatorHtml()}
</button>
<button class="room-entry" data-member-id="${sanitizeId(String(id))}" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;">
<img src="${myPfp}" 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">${myDisplaySafe}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span>
</div>
${getCallIndicatorHtml()}
</button>
</div>
`;
}
async function inviteToGroupPrompt(groupId) {
showPromptPopup(
"action.invite",
"",
"title.username",
"action.invite",
"action.cancel",
async (targetUsername) => {
if (targetUsername) {
targetUsername = targetUsername.trim();
if (targetUsername.length > 0) {
if (!targetUsername.includes(":")) {
targetUsername += `:${host}`;
}
let idRes = await fetchAsync(`${url}/nametoid?u=${targetUsername}`);
if (idRes.startsWith("error") || idRes.trim() === "0" || idRes.trim() === "") {
showBlahNotification("error:user.not.found");
return;
}
let targetId = idRes.trim();
try {
showAction("action.invite.sending", "invite.action");
let keysPayload = "";
try {
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && !infoRes.startsWith("error:")) {
let info = JSON.parse(infoRes);
if (info.string6 === "true") { // historyVisible
let lastKeyIndex = parseInt(info.string4);
if (!isNaN(lastKeyIndex)) {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
if (publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
let keysDict = {};
let allKeysSuccess = true;
for (let i = 0; i <= lastKeyIndex; i++) {
let roomKey = await getGroupKey(groupId, i.toString());
if (!roomKey) {
allKeysSuccess = false;
break;
}
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[i.toString()] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
}
if (!allKeysSuccess) {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
keysPayload = JSON.stringify(keysDict);
} else {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
}
}
}
} catch (e) {
console.error("Error generating keys payload for invite", e);
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
let payload = JSON.stringify({
string1: groupId,
string2: targetId,
string3: keysPayload
});
let res = await fetchEncrypted("group/invite", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
}
},
null,
false
);
}
async function generateGroupMembersHtml(groupId, groupName) {
let membersJson = await fetchEncrypted("group/members", groupId);
if (!membersJson || membersJson.startsWith("error")) {
return `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.failed.load.members</blah></div>`;
}
let members = JSON.parse(membersJson);
let html = `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
<div class="flex-spacer"></div>
<button class="topbar-action-btn" onclick="inviteToGroupPrompt('${sanitizeId(groupId)}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;">
${ICONS.plus({ width: "1rem", height: "1rem" })}
</button>
</div>`;
for (let memberId of members) {
let memberUsername = await fetchAsync(`${url}/idtoname?id=${memberId}`);
if (!memberUsername || memberUsername.startsWith("error")) continue;
let memberPfp = await getAvatarUrl(memberId, memberUsername);
let memberDisplayName = escapeHtml(memberUsername.split(':')[0]);
let safeMemberId = sanitizeId(memberId);
let safeGroupId = sanitizeId(groupId);
let memberNumericId = sanitizeId(String(memberId).split(';')[0]);
html += `
<button class="room-entry" data-member-id="${memberNumericId}" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" 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">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span>
</div>
${getCallIndicatorHtml()}
</button>`;
}
html += `</div>`;
return html;
}
async function openDm(dmId, username, targetId, type = 'dms') {
if (touchContextMenuFired) {
return;
}
try {
showAction("action.dm.opening", "dmopen");
if (type === 'groups') {
markDmAsRead(dmId, 'groups');
} else {
markDmAsRead(dmId, 'dms');
}
if (type === 'dms') {
let keyCheck = await fetchEncrypted("dm/key/get", dmId);
if (keyCheck === "error:forbidden") {
//wpis w liscie do ktorego nie mamy dostepu - usun go i nie otwieraj rozmowy
showBlahNotification(keyCheck);
removeRoomFromCachedList(dmId, 'dms');
removeRoomButton(sanitizeId(dmId), 'dms');
refreshDms(0, false);
clearAction("dmopen");
return;
}
currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) {
throw new Error("Missing DM room key");
}
} else {
let infoPayload = await fetchEncrypted("group/info", dmId);
if (infoPayload && !infoPayload.startsWith("error:")) {
let info = JSON.parse(infoPayload);
window.currentGroupKeyIndex = info.string4 || "0";
window.currentGroupOwner = info.string7;
} else {
showBlahNotification(infoPayload && infoPayload.startsWith("error:") ? infoPayload : "error:group.not.found");
//usuń wpis z listy tylko przy deterministycznym błędzie
//(network.error itp. nie może czyścić poprawnych wpisów)
if (infoPayload === "error:group.not.found") {
removeRoomFromCachedList(dmId, 'groups');
removeRoomButton(sanitizeId(dmId), 'groups');
refreshGroups(0, false);
}
clearAction("dmopen");
return;
}
}
if (currentDmId !== dmId) {
currentRoomContentTitle = "";
}
currentDmId = dmId;
window.currentRoomType = type; // Save the type globally
loadedMessages = {};
oldestLoadedMsgId = null;
isLoadingOlderMessages = false;
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
} else {
let pfp = await getGroupAvatarUrl(dmId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
}
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let membersHtml = "";
if (type === 'dms') {
membersHtml = await generateDmMembersHtml(targetId, username);
} else {
membersHtml = await generateGroupMembersHtml(dmId, username);
}
await switchDetailsContent("title.details", membersHtml);
updateVoicePresenceUI();
refreshRoomCallState();
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);
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 endpoint = window.currentRoomType === 'groups' ? "group/messages/get" : "dm/messages/get";
let res = await fetchEncrypted(endpoint, payload);
if (res && res.startsWith("error:")) {
if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") {
//brak dostępu do pokoju - serwer usunął też nieaktualny wpis z listy,
//my dodatkowo czyścimy cache/UI i wychodzimy z ekranu rozmowy
await handleLostRoomAccess(dmId, null, res);
return;
}
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");
}
}
function formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function truncateFilename(name, maxLength = 20) {
if (name.length <= maxLength) return name;
let extIndex = name.lastIndexOf(".");
if (extIndex === -1 || extIndex === 0) {
return name.substring(0, Math.floor(maxLength / 2) - 1) + "..." + name.substring(name.length - Math.floor(maxLength / 2) + 2);
}
let ext = name.substring(extIndex);
let baseName = name.substring(0, extIndex);
let allowedBaseLength = maxLength - ext.length - 3; // 3 for "..."
if (allowedBaseLength <= 1) {
return name.substring(0, maxLength - 3) + "...";
}
let half = Math.floor(allowedBaseLength / 2);
return baseName.substring(0, half) + "..." + baseName.substring(baseName.length - (allowedBaseLength - half)) + ext;
}
async function generateMessagesHtml(entries, isPinnedContext = false) {
let html = "";
let lastAuthor = null;
let consecutiveMessages = 0;
for (let [msgId, msg] of entries) {
let content = msg.content;
let reactions = msg.reactions || "";
let decrypted = false;
let keyToUse = currentDmKey;
if (msg.key && msg.key !== "") {
try {
if (window.currentRoomType === 'groups') {
keyToUse = await getGroupKey(currentDmId, msg.key);
if (keyToUse) {
let mK = parseInt(msg.key);
let cK = parseInt(window.currentGroupKeyIndex || "0");
if (!isNaN(mK) && !isNaN(cK) && mK > cK) {
window.currentGroupKeyIndex = msg.key;
}
}
}
if (!keyToUse) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
} else {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
content = await decryptAesGcmFromBase64(content, keyBytes);
decrypted = true;
}
} catch (e) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
}
} else {
decrypted = true;
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `${ICONS.delete({ fill: "currentColor", width: "1.5rem", height: "1rem", style: "vertical-align: -0.165em; margin-right: 0.2rem;" })}` : ``;
let attachmentsHtml = "";
if (msg.attachment && msg.attachment !== "") {
if (msg.attachment.startsWith("[")) {
try {
let atts = JSON.parse(msg.attachment);
if (atts.length > 0) {
attachmentsHtml += `<div class="chat-message-attachments" data-raw="${escapeHtml(msg.attachment)}" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
for (let att of atts) {
if (att.enc && att.enc.startsWith("ENC:")) {
try {
if (keyToUse) {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let decStr = await decryptAesGcmFromBase64(att.enc.substring(4), keyBytes);
let metadata = JSON.parse(decStr);
att.name = metadata.name || "unknown";
att.type = metadata.type || "application/octet-stream";
att.size = metadata.size || 0;
} else {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
} catch(e) {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
}
let safeAttId = sanitizeId(att.id);
let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`;
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
? `${ICONS.image({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`
: `${ICONS.download({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 7.5rem; max-width: 18.75rem; min-height: 6.25rem; max-height: 18.75rem; background: rgba(0,0,0,0.2); border: 0.0625rem solid var(--border-color); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${sanitizeId(String(msgId))}', '${safeAttId}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${sanitizeId(msg.key) || "0"}')">
<div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
${sizeStr ? `<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.2rem;">${escapeHtml(sizeStr)}</div>` : ""}
</div>
</div>`;
}
attachmentsHtml += `</div>`;
}
} catch (e) {
console.error("Failed to parse attachments", e);
}
} else {
attachmentsHtml += `<div class="chat-message-attachments" data-raw="${escapeHtml(msg.attachment)}" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<div style="padding: 0.5rem; background: rgba(0,0,0,0.2); border: var(--border-width) solid var(--border-color); border-radius: 0.5rem; font-size: 0.8rem; opacity: 0.7;">[${processBlah("attachment.legacy")}: ${escapeHtml(msg.attachment)}]</div>
</div>`;
}
}
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 && msg.type.startsWith("larp.info")) {
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin" || msg.type === "larp.info.join" || msg.type === "larp.info.leave" || msg.type === "larp.info.create" || msg.type === "larp.info.history" || msg.type === "larp.info.kick" || msg.type === "larp.info.transfer_owner" || msg.type === "larp.info.name" || msg.type === "larp.info.pfp" || msg.type === "larp.info.pfp_removed") {
let authorName = processBlah("title.unknown");
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
if (fullUsername && !fullUsername.startsWith("error:")) {
dmUsernameCache[msg.author] = fullUsername;
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
}
}
if (dmUsernameCache[msg.author]) {
authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]);
} else {
authorName = escapeHtml(msg.author.split(';')[0]);
}
}
let actionStr = "";
if (msg.type === "larp.info.pin") actionStr = "message.pinned.by";
else if (msg.type === "larp.info.unpin") actionStr = "message.unpinned.by";
else if (msg.type === "larp.info.join") actionStr = "group.user.joined.by";
else if (msg.type === "larp.info.leave") actionStr = "group.user.left.by";
else if (msg.type === "larp.info.create") actionStr = "group.created.by";
else if (msg.type === "larp.info.history") actionStr = "group.history.changed.by";
else if (msg.type === "larp.info.kick") actionStr = "group.user.kicked.by";
else if (msg.type === "larp.info.transfer_owner") actionStr = "group.owner.transferred.by";
else if (msg.type === "larp.info.name") actionStr = "group.name.changed.by";
else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by";
else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by";
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr);
if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) {
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${sanitizeId(msg.pervious)}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
} else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") {
let escapedContent = escapeHtml(content);
content = textStr.replace("{0}", clickableAuthor).replace("{1}", escapedContent);
} else if (msg.type === "larp.info.kick" || msg.type === "larp.info.transfer_owner") {
let targetUsername = "";
let targetId = "";
let parts = content.split(':');
if (parts.length >= 2 && parts[0].includes(';')) {
targetId = sanitizeId(parts[0]);
targetUsername = escapeHtml(parts.slice(1).join(':'));
} else {
targetUsername = escapeHtml(parts[0]);
}
let clickableTarget = targetId ? `<span onclick="event.stopPropagation(); openProfile('${targetId}')" style="font-weight: bold; cursor: pointer;">${targetUsername}</span>` : `<span style="font-weight: bold;">${targetUsername}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableTarget);
} else {
content = textStr.replace("{0}", clickableAuthor);
}
}
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem; word-break: break-word; overflow-wrap: anywhere;">${content}</div>`;
lastAuthor = null;
consecutiveMessages = 0;
} else {
let showAvatar = false;
if (isPinnedContext) {
showAvatar = true;
} else {
if (lastAuthor !== msg.author) {
showAvatar = true;
consecutiveMessages = 1;
} else {
consecutiveMessages++;
if (consecutiveMessages >= 6) {
showAvatar = true;
consecutiveMessages = 1;
}
}
}
lastAuthor = msg.author;
let authorName = processBlah("title.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 = escapeHtml(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 unencryptedIcon = (!decrypted && !isRedacted) ? `<span title="${processBlah('warning.unencrypted')}" style="color: var(--big-red); vertical-align: -0.165em; margin-right: 0.2rem; cursor: help;">${ICONS.lockOpen({ width: "1rem", height: "1rem", stroke: "currentColor" })}</span>` : ``;
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 = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(sName)}`;
}
}
}
} catch (e) {
}
}, 10);
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${sanitizeId(respondedId)}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(replyAuthor)}</div>`;
}
let reactionsHtml = "";
if (reactions) {
try {
let rxArr = JSON.parse(reactions);
if (rxArr.length > 0) {
let rxCounts = {};
for (let rx of rxArr) {
let rxVal = rx.r;
if (msg.key && msg.key !== "") {
try {
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if (k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false, myReactionId: null};
rxCounts[rxVal].count++;
if (rx.u === id) {
rxCounts[rxVal].me = true;
rxCounts[rxVal].myReactionId = rx.id;
}
}
let rxKeys = Object.keys(rxCounts);
if (rxKeys.length > 0) {
reactionsHtml = `<div class="chat-message-reactions" style="display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.3rem;">`;
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);
let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`;
reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${sanitizeId(String(msgId))}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
}
reactionsHtml += `</div>`;
}
}
} catch (e) {
}
}
html += `
<div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${sanitizeId(String(msgId))}" id="${isPinnedContext ? `pinned-` : ``}msg-${sanitizeId(String(msgId))}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${sanitizeId(String(msgId))}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${sanitizeId(String(msgId))}')">
${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
<div class="chat-message-text" style="${redactedStyle}">${unencryptedIcon}${redactedIcon}${content}</div>
${attachmentsHtml}
${reactionsHtml}
</div>
</div>
`;
}
}
return html;
}
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 = await generateMessagesHtml(entries, false);
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;
let tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
let newChildren = Array.from(tempDiv.children);
let currentIndex = 0;
for (let newChild of newChildren) {
let existingChild = document.getElementById(newChild.id);
if (existingChild && existingChild.parentElement === container) {
if (container.children[currentIndex] !== existingChild) {
container.insertBefore(existingChild, container.children[currentIndex]);
}
let oldText = existingChild.querySelector('.chat-message-text');
let newText = newChild.querySelector('.chat-message-text');
if (oldText && newText) {
if (oldText.innerHTML !== newText.innerHTML) {
oldText.innerHTML = newText.innerHTML;
}
if (oldText.getAttribute('style') !== newText.getAttribute('style')) {
let newStyle = newText.getAttribute('style');
if (newStyle) {
oldText.setAttribute('style', newStyle);
} else {
oldText.removeAttribute('style');
}
}
}
let oldReactions = existingChild.querySelector('.chat-message-reactions');
let newReactions = newChild.querySelector('.chat-message-reactions');
if (newReactions) {
if (oldReactions) {
if (oldReactions.innerHTML !== newReactions.innerHTML) oldReactions.innerHTML = newReactions.innerHTML;
} else {
existingChild.querySelector('.chat-message-content').appendChild(newReactions);
}
} else if (oldReactions) {
oldReactions.remove();
}
let oldAttachments = existingChild.querySelector('.chat-message-attachments');
let newAttachments = newChild.querySelector('.chat-message-attachments');
if (newAttachments) {
if (oldAttachments) {
if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) {
//keep already downloaded/rendered attachment boxes alive across the update,
//and always sync data-raw - otherwise every poll would see a stale attribute
//and re-render (unloading) the attachments forever
let loadedBoxes = {};
oldAttachments.querySelectorAll('.attachment-box[data-loaded="true"]').forEach(box => {
loadedBoxes[box.id] = box;
});
oldAttachments.innerHTML = newAttachments.innerHTML;
oldAttachments.setAttribute('data-raw', newAttachments.getAttribute('data-raw'));
for (let boxId in loadedBoxes) {
let freshBox = oldAttachments.querySelector(`#${boxId}`);
if (freshBox) freshBox.replaceWith(loadedBoxes[boxId]);
}
}
} else {
let contentNode = existingChild.querySelector('.chat-message-content');
if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions);
else contentNode.appendChild(newAttachments);
}
} else if (oldAttachments) {
oldAttachments.remove();
}
let oldPfp = existingChild.querySelector('.chat-message-pfp');
let newPfp = newChild.querySelector('.chat-message-pfp');
if (oldPfp && newPfp && oldPfp.src !== newPfp.src) {
oldPfp.src = newPfp.src;
}
let oldHeader = existingChild.querySelector('.chat-message-header');
let newHeader = newChild.querySelector('.chat-message-header');
if (oldHeader && newHeader && oldHeader.innerHTML !== newHeader.innerHTML) {
oldHeader.innerHTML = newHeader.innerHTML;
}
let oldReplied = existingChild.querySelector('.chat-message-replied');
let newReplied = newChild.querySelector('.chat-message-replied');
if (newReplied) {
if (oldReplied) {
if (oldReplied.innerHTML !== newReplied.innerHTML) oldReplied.innerHTML = newReplied.innerHTML;
} else {
let contentNode = existingChild.querySelector('.chat-message-content');
if (oldText) contentNode.insertBefore(newReplied, oldText);
else contentNode.insertBefore(newReplied, contentNode.firstChild);
}
} else if (oldReplied) {
oldReplied.remove();
}
} else {
if (container.children[currentIndex]) {
container.insertBefore(newChild, container.children[currentIndex]);
} else {
container.appendChild(newChild);
}
}
currentIndex++;
}
while (container.children.length > newChildren.length) {
container.removeChild(container.lastChild);
}
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");
}
};
}
async function downloadAndRenderAttachment(container, msgId, attId, type, name, keyIndex = "0") {
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
if (!container) return;
if (container.dataset.loaded === "true") {
if (!type.startsWith("image/") && !type.startsWith("video/")) {
let a = document.createElement("a");
a.href = container.dataset.url;
a.download = name;
a.click();
}
return;
}
if (container.dataset.loading === "true") return;
container.dataset.loading = "true";
let originalHtml = container.innerHTML;
container.innerHTML = `<div style="padding: 1rem; color: var(--text-color);">${ICONS.spinner()}</div>`;
try {
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, keyIndex);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let nonce = await getNonce(id, passwordHash);
let endpointType = window.currentRoomType === 'groups' ? "group" : "dm";
let res = await fetch(`${url}/${endpointType}/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}
});
if (!res.ok) throw new Error("Failed to fetch attachment");
let encryptedBytes = new Uint8Array(await res.arrayBuffer());
let decryptedBytes = await decryptAesGcmFromBytes(encryptedBytes, dmKeyBytes);
let blob = new Blob([decryptedBytes], {type: type});
let blobUrl = URL.createObjectURL(blob);
container.dataset.loaded = "true";
container.dataset.url = blobUrl;
if (type.startsWith("image/")) {
container.innerHTML = `<img src="${blobUrl}" style="max-width: 100%; max-height: 18.75rem; display: block;" onclick="window.open('${blobUrl}', '_blank')">`;
} else if (type.startsWith("video/")) {
container.innerHTML = `<video src="${blobUrl}" controls style="max-width: 100%; max-height: 18.75rem; display: block;"></video>`;
} else {
container.innerHTML = originalHtml;
container.style.border = "0.0625rem solid var(--accent-color)";
let a = document.createElement("a");
a.href = blobUrl;
a.download = name;
a.click();
}
} catch (e) {
console.error(e);
if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
delete window.groupKeysCache[currentDmId][keyIndex];
}
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading - click to retry</div>`;
container.dataset.loading = "false";
}
}
let currentDraftAttachments = [];
let replyingToMsgId = null;
function handleAttachmentSelection(event) {
if (!event.target.files) return;
for (let file of event.target.files) {
currentDraftAttachments.push(file);
}
event.target.value = '';
renderAttachmentsPreview();
}
function removeDraftAttachment(index) {
currentDraftAttachments.splice(index, 1);
renderAttachmentsPreview();
}
function renderAttachmentsPreview() {
let previewDiv = document.getElementById("attachments-preview");
if (!previewDiv) return;
if (currentDraftAttachments.length === 0) {
previewDiv.style.display = "none";
previewDiv.innerHTML = "";
return;
}
previewDiv.style.display = "flex";
let html = "";
currentDraftAttachments.forEach((file, index) => {
let isImage = file.type.startsWith("image/");
let isVideo = file.type.startsWith("video/");
let iconHtml = "";
if (isImage) {
iconHtml = `${ICONS.image({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
} else if (isVideo) {
iconHtml = `${ICONS.video({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
} else {
iconHtml = `${ICONS.file({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
}
html += `
<div style="display: flex; align-items: center; gap: 0.8rem; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(0.625rem); -webkit-backdrop-filter: blur(0.625rem); border: var(--border-width) solid rgba(255, 255, 255, 0.1); padding: 0.5rem 0.5rem 0.5rem 1rem; border-radius: 0.75rem; flex-shrink: 0; max-width: 15.625rem; box-shadow: 0 0.25rem 0.375rem rgba(0,0,0,0.1); transition: transform 0.2s ease;">
<div style="flex-shrink: 0; color: var(--accent-color); display: flex; align-items: center;">${iconHtml}</div>
<div style="font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-color); font-weight: 500; flex-grow: 1;">${escapeHtml(file.name)}</div>
<button onclick="removeDraftAttachment(${index})" style="background: transparent; color: var(--text-color); border: none; width: 1.5rem; height: 1.5rem; flex-shrink: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.125rem; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.6'">
${ICONS.close({ width: "100%", height: "100%", fill: "currentColor", style: "width:100%; height:100%;" })}
</button>
</div>`;
});
previewDiv.innerHTML = html;
}
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
if (!content && currentDraftAttachments.length === 0) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
if (container) {
let html = `
<div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;">
<div style="width: 2.5rem; flex-shrink: 0;"></div>
<div class="chat-message-content">
<div class="chat-message-text">${escapeHtml(content)} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
container.scrollTop = container.scrollHeight;
}
try {
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, window.currentGroupKeyIndex || "0");
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
let attachmentsPayload = [];
if (currentDraftAttachments.length > 0) {
showAction(processBlah("action.attachment.uploading"), "uploading_atts");
for (let i = 0; i < currentDraftAttachments.length; i++) {
let file = currentDraftAttachments[i];
let arrayBuffer = await file.arrayBuffer();
let encryptedFileBytes = await encryptAesGcmToBytes(new Uint8Array(arrayBuffer), dmKeyBytes);
let nonce = await getNonce(id, passwordHash);
let res;
try {
res = await fetch(`${url}/dm/attachment/staging?id=${id}`, {
method: "POST",
headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
},
body: encryptedFileBytes
});
} catch (e) {
throw new Error("error.network.error");
}
if (res.status === 413) {
throw new Error("error:body.too.large");
}
let text = await res.text();
if (text.startsWith("error:")) {
throw new Error(text);
}
let decText;
try {
decText = await decryptString(text, passwordHash);
} catch (e) {
throw new Error("error:message.send.failed"); // trigger proper blah notification
}
if (decText && decText.startsWith("success:")) {
let metadataObj = {
type: file.type,
name: file.name,
size: file.size
};
let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
attachmentsPayload.push({
id: i,
staging_id: decText.split(":")[1],
enc: encMetadata
});
} else {
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
}
}
clearAction("uploading_atts");
}
let msgPayload = {
string1: currentDmId,
string2: encryptedContent,
string3: attachmentsPayload.length > 0 ? JSON.stringify(attachmentsPayload) : "",
string4: replyingToMsgId || ""
};
if (window.currentRoomType === 'groups') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
}
let savedContentForRestore = content;
let savedAttachmentsForRestore = [...currentDraftAttachments];
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
currentDraftAttachments = [];
renderAttachmentsPreview();
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(window.currentRoomType === 'groups' ? "group/message/send" : "dm/message/send", JSON.stringify(msgPayload));
clearAction("msgsending");
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
// Restore input and attachments for the user to retry manually
input.value = savedContentForRestore;
currentDraftAttachments = savedAttachmentsForRestore;
renderAttachmentsPreview();
showRotationPopup(currentDmId, null);
} else 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];
let finalAttPayload = "";
if (attachmentsPayload.length > 0) {
finalAttPayload = JSON.stringify(attachmentsPayload.map(a => {
let clone = Object.assign({}, a);
delete clone.staging_id;
return clone;
}));
}
loadedMessages[newMsgId] = {
author: id,
timestamp: Date.now().toString(),
type: "larp.text",
content: encryptedContent,
attachment: finalAttPayload,
key: window.currentRoomType === 'groups' ? (window.currentGroupKeyIndex || "0") : "0",
pervious: "",
responded: replyingToMsgId || "",
reactions: ""
};
renderMessages(loadedMessages);
}
}
} catch (e) {
clearAction("uploading_atts");
clearAction("msgsending");
console.error(e);
if (e.message && e.message.startsWith("error:")) {
showBlahNotification(e.message);
} else {
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:") || data.startsWith("group_message:")) {
let parts = data.split(":");
let msgTypeRoom = data.startsWith("dm_message:") ? "dms" : "groups";
let msgDmId = parts[1];
let msgType = parts[2] || "unread";
if (msgType === "unread") {
updateDmListUI(msgDmId, false, msgTypeRoom, Date.now().toString());
} else if (msgType === "read") {
updateDmListUI(msgDmId, true, msgTypeRoom, 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);
if (currentDetailsContentTitle === "title.pinned.messages") {
showPinnedMessages(true, false);
}
}, 300);
}
}
else if (data.startsWith("group_removed:")) {
let removedGroupId = data.substring("group_removed:".length);
handleGroupRemoved(removedGroupId);
}
else if (data.startsWith("call_update:")) {
if (typeof handleCallUpdate === "function") {
handleCallUpdate(data);
}
}
else if (data.startsWith("signal:")) {
if (typeof handleRoomSignal === "function") {
handleRoomSignal(data);
}
}
};
appWebSocket.onclose = () => {
setTimeout(setupWebSocket, 5000);
};
}
let currentContextMenuMsgId = null;
let touchContextMenuFired = false;
function handleMessageContextMenu(e, msgId) {
e.preventDefault();
if (touchContextMenuFired) {
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 = "";
let pinContainer = fixedContextMenu.querySelector("#context-pin-container");
if (pinContainer) {
let isPinned = msg && msg.pinned === "true";
pinContainer.innerHTML = `
<button onclick="togglePinMessage('${sanitizeId(String(msgId))}')">
${ICONS.pin()}
<span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span>
</button>`;
}
}
let menuRect = fixedContextMenu.getBoundingClientRect();
let newLeft = e.clientX + 10;
let newTop = e.clientY;
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`;
}
async function togglePinMessage(msgId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
try {
let payload = JSON.stringify({
string1: currentDmId,
string2: msgId
});
let endpoint = window.currentRoomType === 'groups' ? "group/message/pin/toggle" : "dm/message/pin/toggle";
await fetchEncrypted(endpoint, payload);
} catch (e) {
console.error(e);
showBlahNotification("error.network.error");
}
}
async function restoreDmDetails() {
if (savedDetailsHtml && savedDetailsTitle) {
await switchDetailsContent(savedDetailsTitle, savedDetailsHtml);
savedDetailsHtml = null;
savedDetailsTitle = null;
}
}
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 endpoint = window.currentRoomType === 'groups' ? "group/message/delete" : "dm/message/delete";
let res = await fetchEncrypted(endpoint, 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 keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
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 = `
<div style="display: flex; flex-direction: column; gap: 0.4rem; padding: 0.2rem; min-width: 14rem;">
<input type="text" id="reaction-picker-search" placeholder="{blah(placeholder.type.text.emoji)}" oninput="filterReactions(this.value)" onkeydown="if(arguments[0].key === 'Enter') { if(this.value.trim() !== '') reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(this.value)); }" style="width: auto; align-self: stretch; margin: 0; box-sizing: border-box; padding: 0.5rem 0.6rem; border-radius: 0.65rem; border: var(--border-width) solid var(--light-border-color); background: transparent; color: var(--text-color); font-family: inherit;">
<div id="reaction-picker-grid" style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.2rem; max-height: 12rem; overflow-y: auto; overflow-x: hidden;">
${loadedEmotes.map(emoji => {
let safeChar = encodeURIComponent(emoji.char).replace(/'/g, "%27");
return `<button onclick="reactMessagePrompt(currentContextMenuMsgId, '${safeChar}')" data-name="${emoji.name}" style="min-width: unset; width: 100%; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; padding: 0; font-size: 1.2rem; border-radius: 0.65rem;" class="reaction-emoji-btn">${emoji.char}</button>`;
}).join('')}
</div>
<button class="reaction-text-btn" onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(document.getElementById('reaction-picker-search').value))" style="margin: 0; padding: 0.5rem; justify-content: center; border-radius: 0.65rem; width: 100%;">
<span class="blah">title.text.react</span>
</button>
</div>
`;
} 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 myReactionId = null;
if (currentMsg && currentMsg.reactions) {
try {
let rxArr = JSON.parse(currentMsg.reactions);
for (let rx of rxArr) {
let rxVal = rx.r;
if (currentMsg.key && currentMsg.key !== "") {
try {
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (rx.u === id && rxVal === reaction) {
myReactionId = rx.id;
break;
}
}
} catch (e) {
}
}
let isAdding = (myReactionId === null);
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedReaction = await encryptAesGcmToBase64(reaction, dmKeyBytes);
let endpoint = window.currentRoomType === 'groups'
? (isAdding ? "group/message/react/add" : "group/message/react/remove")
: (isAdding ? "dm/message/react/add" : "dm/message/react/remove");
let msgPayload = {
string1: currentDmId,
string2: msgId,
string3: isAdding ? encryptedReaction : myReactionId
};
let res = await fetchEncrypted(endpoint, JSON.stringify(msgPayload));
if (res && res.startsWith("error:")) {
showBlahNotification(res);
}
} 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 container = document.getElementById("settings-language");
let optionsContainer = document.getElementById("settings-language-options");
let display = document.getElementById("settings-language-display");
if (!container || !optionsContainer || !display) 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);
optionsContainer.innerHTML = "";
window._langDropdownListener = window._langDropdownListener || ((e) => {
let cont = document.getElementById("settings-language");
if (cont && !cont.contains(e.target)) {
cont.classList.remove('open');
}
});
document.removeEventListener('click', window._langDropdownListener);
document.addEventListener('click', window._langDropdownListener);
for (let code in json) {
let opt = document.createElement("div");
opt.className = "custom-select-option";
let text = json[code].native + " (" + json[code].english + ")";
opt.textContent = text;
if (code === lang) {
opt.classList.add("selected");
display.textContent = text;
display.classList.remove("blah");
}
opt.onclick = () => {
display.textContent = text;
container.classList.remove("open");
changeLanguage(code);
};
optionsContainer.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");
//sól KDF konta (wymagana; nie zmienia się przy zmianie hasła)
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${window.id}`);
if (!saltRes || !saltRes.startsWith("P:")) {
clearAction("accountsave");
showBlahNotification("error:kdf.salt.missing");
return;
}
let newPassHash = await deriveCredentialKdf(newPass, saltRes.substring(2).trim());
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() {
showConfirmPopup(
"title.delete.account",
"<blah>desc.delete.account</blah><br><br><blah>prompt.are.you.sure</blah>",
"action.delete.account",
"action.cancel",
async () => {
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);
}
},
null,
true
);
}
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 cropperZoom = 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() {
document.getElementById('cropper-popup-container').classList.add('show');
let container = document.getElementById('cropper-viewport-container');
let imgEl = document.getElementById('cropper-image');
// Set aspect ratio
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-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);
cropperZoom = cropperBaseZoom;
cropperPosX = 0;
cropperPosY = 0;
updateCropperTransform();
}
function updateCropperZoom() {
updateCropperTransform();
}
function updateCropperTransform() {
let imgEl = document.getElementById('cropper-image');
let container = document.getElementById('cropper-viewport-container');
if (cropperImageObj) {
let containerWidth = container.clientWidth || 300;
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
let containerHeight = container.clientHeight || (containerWidth * (targetHeight / targetWidth));
let scaleX = containerWidth / cropperImageObj.width;
let scaleY = containerHeight / cropperImageObj.height;
cropperBaseZoom = Math.max(scaleX, scaleY);
if (cropperZoom < cropperBaseZoom) cropperZoom = cropperBaseZoom;
if (cropperZoom > cropperBaseZoom * 5) cropperZoom = cropperBaseZoom * 5;
let zoom = cropperZoom;
let scaledW = cropperImageObj.width * zoom;
let scaledH = cropperImageObj.height * zoom;
containerWidth = container.clientWidth || 300;
containerHeight = container.clientHeight || (containerWidth * (targetHeight / targetWidth));
let minX = containerWidth - scaledW;
let minY = containerHeight - scaledH;
let maxX = 0;
let maxY = 0;
if (scaledW < containerWidth) {
minX = maxX = (containerWidth - scaledW) / 2;
}
if (scaledH < containerHeight) {
minY = maxY = (containerHeight - scaledH) / 2;
}
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(${cropperZoom})`;
}
function closeCropper() {
document.getElementById('cropper-popup-container').classList.remove('show');
}
async function confirmCropper() {
let container = document.getElementById('cropper-viewport-container');
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
let scaleRatio = targetWidth / (container.clientWidth || 300);
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
let drawW = cropperImageObj.width * cropperZoom * scaleRatio;
let drawH = cropperImageObj.height * cropperZoom * 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' || cropperType === 'group-avatar') ? 'larp.profile.pfp' : 'larp.profile.banner';
showAction("action.uploading", "uploadmedia");
let res;
if (cropperType === 'group-avatar') {
let payload = JSON.stringify({
string1: window.currentSettingsGroupId,
string2: payloadKey,
string3: b64
});
res = await fetchEncrypted("group/storage/public/update", payload);
} else {
let fileBytes = base64ToUint8(b64);
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 (cropperType === 'group-avatar') {
if (img.src.includes(`id=${window.currentSettingsGroupId}&e=${payloadKey}`) || img.id === 'group-settings-avatar-preview') {
img.src = newSrc;
}
} else {
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 cropperContainer = document.getElementById('cropper-viewport-container');
if (cropperContainer) {
let initialPinchDistance = null;
let initialPinchZoom = null;
const startDrag = (e) => {
if (e.touches && e.touches.length === 2) {
isDraggingCropper = false;
let dx = e.touches[0].clientX - e.touches[1].clientX;
let dy = e.touches[0].clientY - e.touches[1].clientY;
initialPinchDistance = Math.sqrt(dx*dx + dy*dy);
initialPinchZoom = cropperZoom;
return;
}
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 (e.touches && e.touches.length === 2 && initialPinchDistance !== null) {
let dx = e.touches[0].clientX - e.touches[1].clientX;
let dy = e.touches[0].clientY - e.touches[1].clientY;
let distance = Math.sqrt(dx*dx + dy*dy);
let zoomFactor = distance / initialPinchDistance;
let oldZoom = cropperZoom;
cropperZoom = initialPinchZoom * zoomFactor;
if (cropperZoom < cropperBaseZoom) cropperZoom = cropperBaseZoom;
if (cropperZoom > cropperBaseZoom * 5) cropperZoom = cropperBaseZoom * 5;
let zoomRatio = cropperZoom / oldZoom;
let cw = cropperContainer.clientWidth || 300;
let ch = cropperContainer.clientHeight || 300;
cropperPosX = (cropperPosX - cw/2) * zoomRatio + cw/2;
cropperPosY = (cropperPosY - ch/2) * zoomRatio + ch/2;
updateCropperTransform();
return;
}
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 = (e) => {
if (e.touches && e.touches.length < 2) {
initialPinchDistance = null;
initialPinchZoom = null;
}
if (e.touches && e.touches.length === 1) {
isDraggingCropper = true;
startDragX = e.touches[0].clientX;
startDragY = e.touches[0].clientY;
startPosX = cropperPosX;
startPosY = cropperPosY;
} else {
isDraggingCropper = false;
}
};
const onWheel = (e) => {
if (document.getElementById('cropper-popup-container').classList.contains('show')) {
e.preventDefault();
let oldZoom = cropperZoom;
let zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
cropperZoom *= zoomFactor;
if (cropperZoom < cropperBaseZoom) cropperZoom = cropperBaseZoom;
if (cropperZoom > cropperBaseZoom * 5) cropperZoom = cropperBaseZoom * 5;
let zoomRatio = cropperZoom / oldZoom;
let rect = cropperContainer.getBoundingClientRect();
let mouseX = e.clientX - rect.left;
let mouseY = e.clientY - rect.top;
cropperPosX = (cropperPosX - mouseX) * zoomRatio + mouseX;
cropperPosY = (cropperPosY - mouseY) * zoomRatio + mouseY;
updateCropperTransform();
}
};
cropperContainer.addEventListener('mousedown', startDrag);
cropperContainer.addEventListener('touchstart', startDrag, {passive: true});
document.addEventListener('mousemove', onDrag);
document.addEventListener('touchmove', onDrag, {passive: false});
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchend', endDrag);
cropperContainer.addEventListener('wheel', onWheel, {passive: false});
new ResizeObserver(() => {
if (document.getElementById('cropper-popup-container').classList.contains('show')) {
updateCropperTransform();
}
}).observe(cropperContainer);
}
//Usuwa pokój z cache listy (dms/groups). Zwraca true jeśli coś usunięto.
function removeRoomFromCachedList(roomId, type) {
try {
roomId = String(roomId);
if (type === 'groups') {
if (!window.cachedGroups) return false;
let parsed = JSON.parse(window.cachedGroups);
let removed = false;
if (parsed.groups && parsed.groups[roomId]) {
delete parsed.groups[roomId];
removed = true;
}
//fallback: wpis może być zapisany z domena albo bez
if (!removed && parsed.groups && !roomId.includes(';')) {
for (let key in parsed.groups) {
if (key.split(';')[0] === roomId) {
delete parsed.groups[key];
removed = true;
break;
}
}
}
if (removed) window.cachedGroups = JSON.stringify(parsed);
return removed;
} else {
if (!window.cachedDms) return false;
let parsed = JSON.parse(window.cachedDms);
if (parsed.dms && parsed.dms[roomId]) {
delete parsed.dms[roomId];
window.cachedDms = JSON.stringify(parsed);
return true;
}
}
} catch (e) {
console.error(e);
}
return false;
}
function removeRoomButton(roomId, type) {
let btn = document.getElementById(`${type === 'groups' ? 'group' : 'dm'}-btn-${sanitizeId(roomId)}`);
if (btn) btn.remove();
}
//Serwer informuje, że użytkownik został usunięty z grupy (kick) -
//usuń grupę z listy, a jeśli jest otwarta, wyjdź z ekranu rozmowy.
async function handleGroupRemoved(groupId) {
try {
removeRoomFromCachedList(groupId, 'groups');
removeRoomButton(sanitizeId(groupId), 'groups');
if (currentDmId === groupId || (currentDmId && currentDmId.split(';')[0] === groupId.split(';')[0] && window.currentRoomType === 'groups')) {
await handleLostRoomAccess(groupId, 'groups', "info:removed.from.group");
}
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
console.error(e);
}
}
//Pokój do którego straciliśmy dostęp: usuń go z listy i wyjdź z ekranu rozmowy.
async function handleLostRoomAccess(roomId, type = null, notifyKey = null) {
if (!type) type = window.currentRoomType === 'groups' ? 'groups' : 'dms';
let wasOpen = currentDmId === roomId;
removeRoomFromCachedList(roomId, type);
removeRoomButton(sanitizeId(roomId), type);
if (typeof refreshDms === 'function') refreshDms(0, false);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
if (wasOpen) {
showBlahNotification(notifyKey || (type === 'groups' ? "error:not.a.member" : "error:forbidden"));
if (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
}
gotoHome();
}
}
function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
try {
if (type === 'groups') {
if (!window.cachedGroups) return;
let parsedGroups = JSON.parse(window.cachedGroups);
if (!parsedGroups.groups || !parsedGroups.groups[roomId]) return;
let changed = false;
if (parsedGroups.groups[roomId].string3 !== (isRead ? "true" : "false")) {
parsedGroups.groups[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedGroups.groups[roomId].string2 !== timestamp) {
parsedGroups.groups[roomId].string2 = timestamp;
changed = true;
}
if (changed) {
window.cachedGroups = JSON.stringify(parsedGroups);
}
let btn = document.getElementById(`group-btn-${roomId}`);
let container = document.getElementById("groups-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
if (timestamp) {
container.prepend(btn);
}
} else {
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
} else {
if (!window.cachedDms) return;
let parsedDms = JSON.parse(window.cachedDms);
if (!parsedDms.dms || !parsedDms.dms[roomId]) return;
let changed = false;
if (parsedDms.dms[roomId].string3 !== (isRead ? "true" : "false")) {
parsedDms.dms[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedDms.dms[roomId].string2 !== timestamp) {
parsedDms.dms[roomId].string2 = timestamp;
changed = true;
}
if (changed) {
window.cachedDms = JSON.stringify(parsedDms);
}
let btn = document.getElementById(`dm-btn-${roomId}`);
let container = document.getElementById("dms-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
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);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
}
function markDmAsRead(roomId, type = 'dms') {
if (type === 'groups') {
fetchEncrypted("group/read", JSON.stringify({string1: roomId})).catch(()=>{});
} else {
fetchEncrypted("dm/read", JSON.stringify({string1: roomId})).catch(()=>{});
}
updateDmListUI(roomId, true, type);
}
function handleDmInteraction() {
if (!currentDmId) return;
let container = document.getElementById("chat-messages");
if (!container) return;
if (lastChatScroll <= 10) {
try {
if (window.currentRoomType === 'groups') {
if (window.cachedGroups) {
let parsedGroups = JSON.parse(window.cachedGroups);
if (parsedGroups.groups && parsedGroups.groups[currentDmId] && parsedGroups.groups[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId, window.currentRoomType);
}
}
} else {
if (window.cachedDms) {
let parsedDms = JSON.parse(window.cachedDms);
if (parsedDms.dms && parsedDms.dms[currentDmId] && parsedDms.dms[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId, window.currentRoomType);
}
}
}
} catch (e) {}
}
}
document.addEventListener('click', handleDmInteraction);
document.addEventListener('keydown', handleDmInteraction);
document.addEventListener('touchstart', handleDmInteraction);
async function showPinnedMessages(forceReload = false, showPanel = true) {
if (!forceReload && currentDetailsContentTitle === "title.pinned.messages" && savedDetailsHtml) {
if (showPanel) mobileNavDetails();
return;
}
if (showPanel) mobileNavDetails();
// Do not show loading html on refresh if we are already in pinned messages, to avoid jarring flashing
if (!forceReload) {
let loadingHtml = `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>desc.messages.loading</blah></div>`;
switchDetailsContent("title.pinned.messages", loadingHtml, true, "restoreDmDetails()", true);
}
try {
let endpoint = window.currentRoomType === 'groups' ? "group/messages/pinned" : "dm/messages/pinned";
let res = await fetchEncrypted(endpoint, currentDmId);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
let entries = Object.entries(msgs).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
if (entries.length === 0) {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.pinned.messages.empty</blah></div>`, true, "restoreDmDetails()");
return;
}
let innerHtml = await generateMessagesHtml(entries, true);
let html = `<div id="pinned-messages" style="display: flex; flex-direction: column; padding: 1rem; gap: 0.5rem; overflow-y: auto; height: 100%;">
${innerHtml}
</div>`;
switchDetailsContent("title.pinned.messages", html, true, "restoreDmDetails()");
} else {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error:message.not.found</blah></div>`, true, "restoreDmDetails()");
}
} catch (e) {
console.error(e);
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.network.error</blah></div>`, true, "restoreDmDetails()");
}
}
function scrollToPinnedMessage(e, msgId) {
if (e && e.target) {
if (e.target.closest('a') ||
e.target.closest('button') ||
e.target.closest('.attachment-box') ||
e.target.closest('.chat-message-replied') ||
e.target.closest('.reaction-pill') ||
e.target.closest('.chat-message-author') ||
e.target.closest('.chat-message-pfp')) {
return;
}
}
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
}
scrollToMessage(msgId);
}
function checkTopbarSpace() {
if (typeof roomTopBar === 'undefined' || !roomTopBar) return;
let transitionEl = roomTopBar.children.item(0);
if (!transitionEl) return;
let children = Array.from(transitionEl.children);
let usedWidth = 0;
let isPinHidden = document.body.classList.contains('hide-topbar-pin');
let isCallHidden = document.body.classList.contains('hide-topbar-call');
for (let child of children) {
if (child.classList.contains('flex-spacer')) continue;
if (child.classList.contains('room-action-pin') && isPinHidden) continue;
if (child.classList.contains('room-action-call') && isCallHidden) continue;
let style = window.getComputedStyle(child);
let margins = (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0);
if (child.id === 'room-topbar-clickable' || child.tagName.toLowerCase() === 'inherit') {
let titleEl = child.querySelector('inherit');
if (titleEl) {
usedWidth += (child.offsetWidth - titleEl.offsetWidth) + titleEl.scrollWidth + margins;
} else {
usedWidth += child.offsetWidth + margins;
}
} else {
usedWidth += child.offsetWidth + margins;
}
}
// Safety buffer for padding/rendering quirks
let diff = transitionEl.clientWidth - usedWidth - 5;
let iconWidth = 50;
if (diff < 0) {
if (!isPinHidden) {
document.body.classList.add('hide-topbar-pin');
} else if (!isCallHidden) {
document.body.classList.add('hide-topbar-call');
}
} else {
if (isCallHidden && diff > iconWidth) {
document.body.classList.remove('hide-topbar-call');
} else if (isPinHidden && !isCallHidden && diff > iconWidth) {
document.body.classList.remove('hide-topbar-pin');
}
}
}
if (typeof roomTopBar !== 'undefined' && roomTopBar) {
let topbarResizeObserver = new ResizeObserver(() => {
checkTopbarSpace();
});
topbarResizeObserver.observe(roomTopBar);
if (roomTopBar.children.length > 0) {
let topbarMutationObserver = new MutationObserver(() => {
setTimeout(checkTopbarSpace, 10);
});
topbarMutationObserver.observe(roomTopBar.children.item(0), { childList: true, subtree: true });
}
}
var currentContextMenuGroupId = null;
function handleMemberContextMenu(e, groupId, memberId) {
if (window.currentRoomType !== 'groups') return;
e.preventDefault();
e.stopPropagation();
groupId = sanitizeId(groupId);
memberId = sanitizeId(memberId);
if (e && e.type === "contextmenu") {
touchContextMenuFired = true;
setTimeout(() => { touchContextMenuFired = false; }, 500);
}
let html = '';
let myIdStr = window.id + ";" + window.host;
if (window.currentGroupOwner === myIdStr && memberId !== myIdStr) {
html += `
<button onclick="kickFromGroup('${groupId}', '${memberId}')">
${ICONS.delete({ fill: "var(--big-red)" })}
<span class="blah" style="color: var(--big-red)">action.kick</span>
</button>
<button onclick="transferGroupOwnership('${groupId}', '${memberId}')">
${ICONS.profile()}
<span class="blah">action.transfer_owner</span>
</button>
`;
}
if (html !== '') {
let clientX = e.clientX !== undefined ? e.clientX : (e.touches && e.touches.length > 0 ? e.touches[0].clientX : 0);
let clientY = e.clientY !== undefined ? e.clientY : (e.touches && e.touches.length > 0 ? e.touches[0].clientY : 0);
showFixedContextMenu({top: clientY, right: clientX, bottom: clientY, left: clientX}, html);
}
}
async function kickFromGroup(groupId, memberId) {
if (fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
}
showAction("action.processing", "kick.action");
let payload = JSON.stringify({
string1: groupId,
string2: memberId
});
let res = await fetchEncrypted("group/kick", payload);
clearAction("kick.action");
if (res && res.startsWith("success")) {
showBlahNotification("success:kicked");
showGroupMembers(groupId).then(html => {
let container = document.getElementById("right-sidebar-container");
if (container) {
container.innerHTML = html;
translate(container);
}
});
} else {
showBlahNotification(res || "error:something.wrong");
}
}
async function transferGroupOwnership(groupId, memberId) {
if (fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
}
showAction("action.processing", "transfer.action");
let payload = JSON.stringify({
string1: groupId,
string2: memberId
});
let res = await fetchEncrypted("group/transferowner", payload);
clearAction("transfer.action");
if (res && res.startsWith("success")) {
window.currentGroupOwner = memberId;
showBlahNotification("success:owner.transferred");
showGroupMembers(groupId).then(html => {
let container = document.getElementById("right-sidebar-container");
if (container) {
container.innerHTML = html;
translate(container);
}
});
} else {
showBlahNotification(res || "error:something.wrong");
}
}
function handleGroupContextMenu(e, groupId) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
currentContextMenuGroupId = groupId;
if (e && e.type === "contextmenu") {
touchContextMenuFired = true;
setTimeout(() => { touchContextMenuFired = false; }, 500);
}
let btn = e ? (e.currentTarget || e.target) : null;
let rect;
if (e && e.type === "click") {
rect = btn.getBoundingClientRect();
showFixedContextMenu({top: rect.top, right: rect.left, bottom: rect.bottom, left: rect.left}, groupContextMenu);
} else if (e) {
let clientX = e.clientX !== undefined ? e.clientX : (e.touches && e.touches.length > 0 ? e.touches[0].clientX : 0);
let clientY = e.clientY !== undefined ? e.clientY : (e.touches && e.touches.length > 0 ? e.touches[0].clientY : 0);
showFixedContextMenu({top: clientY, right: clientX, bottom: clientY, left: clientX}, groupContextMenu);
} else {
return;
}
let menuRect = fixedContextMenu.getBoundingClientRect();
let newLeft = e ? (e.clientX !== undefined ? e.clientX : (e.touches && e.touches.length > 0 ? e.touches[0].clientX : 0)) + 10 : 0;
let newTop = e ? (e.clientY !== undefined ? e.clientY : (e.touches && e.touches.length > 0 ? e.touches[0].clientY : 0)) : 0;
if (e && e.type === "click") {
newLeft = rect.left + 10;
newTop = rect.bottom;
}
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`;
}
async function gotoGroupSettings(groupId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
window.currentSettingsGroupId = groupId;
let infoStr = await fetchEncrypted("group/info", groupId);
if (!infoStr.startsWith("error:")) {
let info = JSON.parse(infoStr);
window.currentGroupOwner = info.string7;
await switchRoomContent("title.group.settings", groupSettingsScreen, false);
document.getElementById('group-settings-name').value = info.string2;
let pfpUrl = await getGroupAvatarUrl(groupId, info.string2);
document.getElementById('group-settings-avatar-preview').src = pfpUrl;
document.getElementById('group-settings-history').checked = (info.string6 === "true");
let myIdStr = window.id + ";" + window.host;
let historyCheckbox = document.getElementById('group-settings-history');
let historyContainer = historyCheckbox.closest('.setting-row');
if (historyContainer) {
if (window.currentGroupOwner !== myIdStr) {
historyContainer.style.display = 'none';
} else {
historyContainer.style.display = 'flex';
}
} else {
historyCheckbox.disabled = (window.currentGroupOwner !== myIdStr);
}
} else {
showBlahNotification(infoStr);
}
}
async function saveGroupSettings() {
let groupId = window.currentSettingsGroupId;
let name = document.getElementById('group-settings-name').value;
let historyVisible = document.getElementById('group-settings-history').checked.toString();
let payload = JSON.stringify({
string1: groupId,
string2: name,
string3: historyVisible
});
showAction("action.saving", "savegroup");
let res = await fetchEncrypted("group/settings/update", payload);
clearAction("savegroup");
if (res === "success") {
showBlahNotification("success:group.settings.saved");
refreshGroups(0, false);
} else {
showBlahNotification(res);
}
}
function leaveGroupPrompt(groupId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
showConfirmPopup(
"title.leave.group",
`<blah>desc.leave.group.confirm</blah>`,
"title.leave.group",
"action.cancel",
() => confirmLeaveGroup(groupId),
null,
true
);
}
async function confirmLeaveGroup(groupId) {
closeProfile();
showAction("action.leaving.group", "leavegroup");
try {
let payload = JSON.stringify({ string1: groupId });
let res = await fetchEncrypted("group/leave", payload);
if (res === "success" || res === "error:not.a.member" || res === "error:group.not.found") {
if (currentDmId === groupId) {
currentDmId = null;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
mobileNavBack();
} else {
gotoHome();
}
}
refreshGroups(0, false);
if (res !== "success") {
showBlahNotification(res);
}
} else if (res.startsWith("error:")) {
showBlahNotification(res);
}
} catch (e) {
console.error(e);
showBlahNotification("error:something.wrong");
}
clearAction("leavegroup");
}