3896 lines
No EOL
143 KiB
JavaScript
3896 lines
No EOL
143 KiB
JavaScript
var prot = window.location.protocol;
|
|
|
|
async function updateProtocolAndUrl(host) {
|
|
prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol;
|
|
if (prot == "http:") {
|
|
try {
|
|
JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`));
|
|
} catch (error) {
|
|
try {
|
|
JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`));
|
|
prot = "https:";
|
|
} catch (error) {
|
|
}
|
|
}
|
|
}
|
|
url = `${prot}//${host}/_larpix`;
|
|
}
|
|
|
|
console.log(prot);
|
|
|
|
var url = `${prot}//${window.location.hostname}/_larpix`;
|
|
var params = new URLSearchParams(window.location.search);
|
|
|
|
try {
|
|
var loadingScreen = document.querySelector("loading");
|
|
var mainScreen = document.querySelector("main");
|
|
|
|
var loadingStatus = document.getElementById("loadingstatus");
|
|
|
|
var addDmBtn = document.getElementById("add-dm-btn");
|
|
var addGroupBtn = document.getElementById("add-group-btn");
|
|
|
|
var sidebarHome = document.getElementById("sidebar-home");
|
|
var sidebarHomeButton = sidebarHome.children.item(1);
|
|
var sidebarHomeIndicator = sidebarHome.children.item(0);
|
|
|
|
var sidebarAdd = document.getElementById("sidebar-add");
|
|
var sidebarAddButton = sidebarAdd.children.item(1);
|
|
var sidebarAddIndicator = sidebarAdd.children.item(0);
|
|
var roomsBarContainer = document.getElementById("roomsbar");
|
|
|
|
var roomDetailsBar = document.getElementById("roomdetailsbar");
|
|
var roomContent = document.getElementsByTagName("roomcontent")[0];
|
|
var roomsBar = document.getElementsByTagName("roomcontent2")[0];
|
|
var sideBar = document.getElementsByTagName("sidebar")[0];
|
|
|
|
var roomContentMain = document.getElementsByTagName("roomcontent2")[1];
|
|
|
|
var roomDetailsMain = document.getElementsByTagName("roomcontent2")[2];
|
|
|
|
var roomContentBar = roomContent.children[0];
|
|
|
|
var roomsTopBar = document.getElementsByTagName("roomtopbar")[0];
|
|
var roomTopBar = document.getElementsByTagName("roomtopbar")[1];
|
|
var detailsTopBar = document.getElementsByTagName("roomtopbar")[2];
|
|
|
|
var sidebarProfile = document.getElementById("sidebar-profile");
|
|
var sidebarProfileButton = sidebarProfile.children.item(1);
|
|
var sidebarProfileIndicator = sidebarProfile.children.item(0);
|
|
|
|
var sidebarPfp = sidebarProfileButton.children.item(0);
|
|
|
|
|
|
var sidebarInbox = document.getElementById("sidebar-inbox");
|
|
var sidebarInboxButton = sidebarInbox.children.item(1);
|
|
var sidebarInboxIndicator = sidebarInbox.children.item(0);
|
|
|
|
|
|
var fixedContextMenu = document.getElementById("fixed-context-menu");
|
|
|
|
} catch (e) {
|
|
}
|
|
|
|
|
|
function delay(time) {
|
|
return new Promise(resolve => setTimeout(resolve, time));
|
|
}
|
|
|
|
async function packetEncPass(pass, key, accountId) {
|
|
return await encryptWithNonce(pass, key, getNonce(accountId, key));
|
|
}
|
|
|
|
async function getNonce(accountId, key) {
|
|
|
|
let nonce;
|
|
let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text();
|
|
|
|
try {
|
|
nonce = await decryptString(fetchRes, key);
|
|
} catch (err) {
|
|
nonce = await decryptString(fetchRes, "");
|
|
}
|
|
return nonce;
|
|
}
|
|
|
|
async function encryptWithNonce(value, key, nonce) {
|
|
return await encryptString(value, nonce + key);
|
|
}
|
|
|
|
async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBase64) {
|
|
const pubX25519Server = base64ToUint8(pubX25519ServerBase64);
|
|
const pubMlKemServer = base64ToUint8(pubMlKemServerBase64);
|
|
|
|
// X25519
|
|
const privX25519Client = window.x25519.utils.randomSecretKey();
|
|
const pubX25519Client = window.x25519.getPublicKey(privX25519Client);
|
|
const secretX25519 = window.x25519.getSharedSecret(privX25519Client, pubX25519Server);
|
|
|
|
// ML-KEM-768
|
|
const mlkem = new window.MlKem768();
|
|
const [ciphertextMlKem, secretMlKem] = await mlkem.encap(pubMlKemServer);
|
|
|
|
// Combine and Hash: SHA256(X25519_Secret || MLKEM_Secret)
|
|
const combined = new Uint8Array(secretX25519.length + secretMlKem.length);
|
|
combined.set(secretX25519);
|
|
combined.set(secretMlKem, secretX25519.length);
|
|
|
|
const hashBuffer = await window.crypto.subtle.digest('SHA-256', combined);
|
|
const aesKey = new Uint8Array(hashBuffer);
|
|
|
|
return [uint8ToBase64(pubX25519Client), uint8ToBase64(ciphertextMlKem), aesKey];
|
|
}
|
|
|
|
function keyDataFromServerJson(jsonFromServer) {
|
|
const data = JSON.parse(jsonFromServer);
|
|
return [data.pubX25519, data.pubMlKem, data.idKey]
|
|
}
|
|
|
|
function base64ToUint8(base64) {
|
|
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
|
}
|
|
|
|
function uint8ToBase64(uint8) {
|
|
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;
|
|
|
|
async function encrypt(plainText, keyBytes) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(plainText);
|
|
|
|
const cryptoKey = await window.crypto.subtle.importKey(
|
|
"raw", keyBytes, "AES-CBC", false, ["encrypt"]
|
|
);
|
|
|
|
const encryptedContent = await window.crypto.subtle.encrypt(
|
|
{name: "AES-CBC", iv: iv},
|
|
cryptoKey,
|
|
data
|
|
);
|
|
const result = new Uint8Array(iv.length + encryptedContent.byteLength);
|
|
result.set(iv);
|
|
result.set(new Uint8Array(encryptedContent), iv.length);
|
|
|
|
return uint8ToBase64(result);
|
|
}
|
|
|
|
async function decrypt(cipherTextBase64, keyBytes) {
|
|
const fullData = base64ToUint8(cipherTextBase64);
|
|
const iv = fullData.slice(0, 16);
|
|
const cipherData = fullData.slice(16);
|
|
|
|
const cryptoKey = await window.crypto.subtle.importKey(
|
|
"raw", keyBytes, "AES-CBC", false, ["decrypt"]
|
|
);
|
|
|
|
const decrypted = await window.crypto.subtle.decrypt(
|
|
{name: "AES-CBC", iv: iv},
|
|
cryptoKey,
|
|
cipherData
|
|
);
|
|
|
|
return new TextDecoder().decode(decrypted);
|
|
}
|
|
|
|
|
|
async function getCryptoKey(passphrase) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(passphrase);
|
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
return await crypto.subtle.importKey('raw', hash, {name: 'AES-CBC'}, false, ['encrypt', 'decrypt']);
|
|
}
|
|
|
|
async function encryptBytes(plainBytes, passphrase) {
|
|
const key = await getCryptoKey(passphrase);
|
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
|
|
const encryptedContent = await crypto.subtle.encrypt(
|
|
{name: 'AES-CBC', iv: iv},
|
|
key,
|
|
plainBytes
|
|
);
|
|
|
|
const result = new Uint8Array(iv.length + encryptedContent.byteLength);
|
|
result.set(iv);
|
|
result.set(new Uint8Array(encryptedContent), iv.length);
|
|
return result;
|
|
}
|
|
|
|
async function decryptBytes(combinedBytes, passphrase) {
|
|
const key = await getCryptoKey(passphrase);
|
|
const iv = combinedBytes.slice(0, 16);
|
|
const data = combinedBytes.slice(16);
|
|
|
|
const decryptedContent = await crypto.subtle.decrypt(
|
|
{name: 'AES-CBC', iv: iv},
|
|
key,
|
|
data
|
|
);
|
|
|
|
return new Uint8Array(decryptedContent);
|
|
}
|
|
|
|
async function encryptString(plainText, passphrase) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(plainText);
|
|
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
|
|
);
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{name: 'AES-CBC', iv: iv},
|
|
key,
|
|
data
|
|
);
|
|
|
|
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
combined.set(iv);
|
|
combined.set(new Uint8Array(encrypted), iv.length);
|
|
|
|
return uint8ToBase64(combined);
|
|
}
|
|
|
|
async function encryptBytesWithNonce(dataUint8, key, nonce) {
|
|
const encoder = new TextEncoder();
|
|
const passphrase = nonce + key;
|
|
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
|
|
|
|
const importedKey = await crypto.subtle.importKey(
|
|
'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
|
|
);
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{name: 'AES-CBC', iv: iv},
|
|
importedKey,
|
|
dataUint8
|
|
);
|
|
|
|
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
combined.set(iv);
|
|
combined.set(new Uint8Array(encrypted), iv.length);
|
|
|
|
return combined;
|
|
}
|
|
|
|
async function decryptString(base64Text, passphrase) {
|
|
const encoder = new TextEncoder();
|
|
const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0)));
|
|
|
|
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
|
|
const key = await crypto.subtle.importKey(
|
|
'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt']
|
|
);
|
|
|
|
const iv = combined.slice(0, 16);
|
|
const data = combined.slice(16);
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{name: 'AES-CBC', iv: iv},
|
|
key,
|
|
data
|
|
);
|
|
|
|
return new TextDecoder().decode(decrypted);
|
|
}
|
|
|
|
|
|
let requestMutex = Promise.resolve();
|
|
|
|
async function fetchPost(url, value) {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let response = await fetch(url, {
|
|
method: "POST",
|
|
body: value,
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
|
|
}
|
|
});
|
|
let data = await response.text();
|
|
return data;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function fetchPostEnc(url, value) {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let nonce = await getNonce(id, passwordHash);
|
|
let response = await fetch(url, {
|
|
method: "POST",
|
|
body: await encryptWithNonce(value, passwordHash, nonce),
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
|
|
}
|
|
});
|
|
let data = await response.text();
|
|
return data;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function fetchAsync(url) {
|
|
let response = await fetch(url, {
|
|
method: "GET",
|
|
});
|
|
|
|
let data = await response.text();
|
|
return data;
|
|
}
|
|
|
|
async function fetchAsyncWAuth(url) {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
|
|
}
|
|
});
|
|
|
|
let data = await response.text();
|
|
return data;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function getServerInfo(host) {
|
|
console.log(`${prot}//${host}/_larpix/serverinfo`)
|
|
return JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`));
|
|
}
|
|
|
|
async function Auth(loginUsername, loginPassword) {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let resolvedId = await fetchAsync(`${url}/nametoid?u=${loginUsername}`);
|
|
if (!resolvedId || resolvedId.trim() === "" || resolvedId.startsWith("error:")) {
|
|
return "error:invalid.username.or.password";
|
|
}
|
|
let actualId = resolvedId.split(":")[0];
|
|
|
|
let passwordHash = await hashSHA3_512(loginPassword);
|
|
let response = await fetch(`${url}/auth?id=${actualId}`, {
|
|
method: "GET",
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash))
|
|
}
|
|
});
|
|
|
|
let data = await response.text();
|
|
if (data.startsWith("success:")) {
|
|
data += "|" + actualId;
|
|
}
|
|
return data;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
|
|
async function fetchEncrypted(request, body = "") {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let nonce = await getNonce(id, passwordHash);
|
|
|
|
let response = await fetch(`${url}/encryptedrequest?id=${id}`, {
|
|
method: "POST",
|
|
body: await encryptWithNonce(
|
|
JSON.stringify({
|
|
string1: request,
|
|
string2: body
|
|
})
|
|
|
|
, passwordHash, nonce),
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
|
|
}
|
|
});
|
|
let data = await response.text();
|
|
if (data.startsWith("error:")) return data;
|
|
return await decryptString(data, passwordHash);
|
|
} catch (e) {
|
|
console.error("fetchEncrypted error:", e);
|
|
return "error:network.error";
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function fetchEncryptedUpload(target, fileBytesUint8) {
|
|
let release;
|
|
const lock = new Promise(resolve => release = resolve);
|
|
const prevMutex = requestMutex;
|
|
requestMutex = prevMutex.then(() => lock);
|
|
await prevMutex;
|
|
try {
|
|
let nonce = await getNonce(id, passwordHash);
|
|
|
|
let response = await fetch(`${url}/upload?id=${id}`, {
|
|
method: "POST",
|
|
body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce),
|
|
headers: {
|
|
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce),
|
|
"target": await encryptWithNonce(target, passwordHash, nonce)
|
|
}
|
|
});
|
|
let data = await response.text();
|
|
if (data.startsWith("error:")) return data;
|
|
return await decryptString(data, passwordHash);
|
|
} catch (e) {
|
|
console.error("fetchEncryptedUpload error:", e);
|
|
return "error:network.error";
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
|
|
function power(base, exponent, mod) {
|
|
let res = 1n;
|
|
base = base % mod;
|
|
while (exponent > 0n) {
|
|
if (exponent % 2n === 1n) res = (res * base) % mod;
|
|
base = (base * base) % mod;
|
|
exponent = exponent / 2n;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function fixBase64Padding(base64String) {
|
|
let str = base64String.replace(/-/g, '+').replace(/_/g, '/');
|
|
const pad = str.length % 4;
|
|
|
|
if (pad) {
|
|
if (pad === 1) {
|
|
throw new Error("");
|
|
}
|
|
str += '='.repeat(4 - pad);
|
|
}
|
|
|
|
return str;
|
|
}
|
|
|
|
function showBlahNotification(blahmessage, duration = 3500) {
|
|
try {
|
|
let split = blahmessage.split(":");
|
|
showNotification(processBlah(blahmessage), split[0], duration);
|
|
} catch (e) {
|
|
showNotification(blahmessage, "info", duration);
|
|
}
|
|
}
|
|
|
|
function showNotification(message, type = 'info', duration = 3500) {
|
|
let container = document.getElementById('notification-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'notification-container';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const notif = document.createElement('div');
|
|
notif.className = `notification ${type}`;
|
|
notif.textContent = message;
|
|
|
|
container.appendChild(notif);
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
notif.classList.add('show');
|
|
});
|
|
});
|
|
|
|
setTimeout(() => {
|
|
notif.classList.remove('show');
|
|
|
|
notif.addEventListener('transitionend', () => {
|
|
notif.remove();
|
|
});
|
|
setTimeout(() => notif.remove(), 300);
|
|
}, duration);
|
|
}
|
|
|
|
|
|
function showAction(message, actionid) {
|
|
let container = document.getElementById('notification-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'notification-container';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
const notif = document.createElement('div');
|
|
notif.className = `notification`;
|
|
notif.textContent = processBlah(message);
|
|
notif.id = `notification-${actionid}`;
|
|
|
|
container.appendChild(notif);
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
notif.classList.add('show');
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearAction(actionid) {
|
|
const notifications = document.querySelectorAll(`[id="notification-${actionid}"]`);
|
|
|
|
notifications.forEach(notif => {
|
|
notif.classList.remove('show');
|
|
|
|
notif.addEventListener('transitionend', async () => {
|
|
notif.remove();
|
|
}, {once: true});
|
|
setTimeout(() => notif.remove(), 300);
|
|
});
|
|
}
|
|
|
|
|
|
async function hashSHA3_512(input) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(input);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
return hashHex;
|
|
}
|
|
|
|
//placeholder pfp
|
|
function getInitials(name) {
|
|
const cleanName = (name || "").trim();
|
|
|
|
//name empty
|
|
if (!cleanName) return "";
|
|
|
|
const parts = cleanName.split(/\s+/);
|
|
|
|
//at least 2 words
|
|
if (parts.length >= 2) {
|
|
const firstInitial = parts[0][0];
|
|
const secondInitial = parts[parts.length - 1][0]; //from last word
|
|
return (firstInitial + secondInitial).toUpperCase();
|
|
}
|
|
|
|
//only 1 word with 1 letter
|
|
const word = parts[0];
|
|
if (word.length === 1) {
|
|
return word.toUpperCase();
|
|
}
|
|
|
|
//1 word at least 2 letters
|
|
return word.substring(0, 2).toUpperCase();
|
|
}
|
|
|
|
function stringToColor(str) {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const h = Math.abs(hash) % 360;
|
|
const s = 50;
|
|
const l = 65;
|
|
|
|
return `hsl(${h}, ${s}%, ${l}%)`;
|
|
}
|
|
|
|
function createAvatarSvg(name, size = 512) {
|
|
const initials = getInitials(name);
|
|
const color = stringToColor(name);
|
|
|
|
const svg = `
|
|
${ICONS.avatar(initials, color, size)}
|
|
`.trim();
|
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
}
|
|
|
|
async function getAvatarUrl(id, username) {
|
|
try {
|
|
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
|
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
|
if ((await fetchAsync(pfpUrl)) == "") {
|
|
throw Error();
|
|
}
|
|
return pfpUrl;
|
|
} catch (e) {
|
|
return createAvatarSvg(username);
|
|
}
|
|
}
|
|
|
|
function updateLoadingStatus(message) {
|
|
loadingStatus.innerHTML = processBlah(message);
|
|
}
|
|
|
|
async function loadingFadeOut() {
|
|
|
|
mainScreen.style.transform = "scale(0.85)";
|
|
mainScreen.style.opacity = "0";
|
|
|
|
loadingScreen.style.transform = "scale(0.85)";
|
|
loadingScreen.style.opacity = "0";
|
|
await delay(200);
|
|
loadingScreen.style.display = "none";
|
|
mainScreen.style.display = "";
|
|
await delay(200);
|
|
mainScreen.style.transform = "";
|
|
mainScreen.style.opacity = "";
|
|
}
|
|
|
|
async function loadingFadeIn() {
|
|
|
|
loadingScreen.style.transform = "scale(0.85)";
|
|
loadingScreen.style.opacity = "0";
|
|
|
|
mainScreen.style.transform = "scale(0.85)";
|
|
mainScreen.style.opacity = "0";
|
|
await delay(200);
|
|
mainScreen.style.display = "none";
|
|
loadingScreen.style.display = "";
|
|
await delay(200);
|
|
loadingScreen.style.transform = "";
|
|
loadingScreen.style.opacity = "";
|
|
}
|
|
|
|
|
|
var blah;
|
|
|
|
async function initBlahs() {
|
|
lang = lang.toLowerCase();
|
|
let res;
|
|
|
|
let path = window.location.pathname.replace("/login/index.html", "/");
|
|
|
|
try { //try user lang first
|
|
res = await fetchAsync(`${path}blah/${lang}.json`);
|
|
blah = JSON.parse(res);
|
|
} catch (e) { //fallback to en-us
|
|
res = await fetchAsync(`${path}blah/en-us.json`);
|
|
blah = JSON.parse(res);
|
|
}
|
|
|
|
let blahTags = document.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
blahTags = document.getElementsByClassName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
|
|
let placeholders = document.querySelectorAll("[placeholder]");
|
|
for (let i = 0; i < placeholders.length; i++) {
|
|
if (placeholders[i].placeholder.startsWith("{blah(")) {
|
|
let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
|
|
placeholders[i].placeholder = processBlah(value);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
function splitLimit(str, separator, limit) {
|
|
const parts = str.split(separator);
|
|
|
|
if (parts.length <= limit) {
|
|
return parts;
|
|
}
|
|
|
|
return [
|
|
...parts.slice(0, limit - 1),
|
|
parts.slice(limit - 1).join(separator)
|
|
];
|
|
}
|
|
|
|
function processBlah(blahmessage) {
|
|
let prepended = false;
|
|
try {
|
|
if (!blahmessage.includes(":")) {
|
|
blahmessage = `:${blahmessage}`;
|
|
prepended = true;
|
|
}
|
|
|
|
let split = splitLimit(blahmessage, ":", 3);
|
|
|
|
let values = [];
|
|
try {
|
|
if (split[2]) {
|
|
values = split[2].split(";");
|
|
}
|
|
} catch (e) {
|
|
}
|
|
|
|
let message = blah[split[1]];
|
|
if (message === undefined) throw new Error();
|
|
|
|
let valueslist = "";
|
|
for (let i = 0; i < values.length; i++) {
|
|
let value = processBlah(values[i]);
|
|
valueslist += `${value}, `;
|
|
message = message.replaceAll(`{${i}}`, value);
|
|
}
|
|
if (values.length > 0) {
|
|
valueslist = valueslist.slice(0, -2);
|
|
}
|
|
if (message.includes('{all}')) {
|
|
message = message.replaceAll('{all}', valueslist);
|
|
}
|
|
return message;
|
|
} catch (e) {
|
|
if (blahmessage.startsWith("error:")) {
|
|
return blah["error.unknown.error"] || "Unknown error";
|
|
}
|
|
if (prepended) {
|
|
return blahmessage.substring(1);
|
|
}
|
|
return blahmessage;
|
|
}
|
|
}
|
|
|
|
function getLang() {
|
|
return (navigator.language || navigator.languages[0]);
|
|
//return "en-cat";
|
|
}
|
|
|
|
var id = "";
|
|
var password = "";
|
|
var username = "";
|
|
var passwordHash = "";
|
|
var host = "";
|
|
var lang = getLang();
|
|
|
|
|
|
var userKeysPublic = null; //2string json
|
|
var userKeysPrivate = null; // decrypted private bundle (JSON object)
|
|
var userKeysEncrypted = null; // encrypted private bundle (string envelope JSON)
|
|
|
|
function publishUserKeysGlobals() {
|
|
window.userKeysPublic = userKeysPublic;
|
|
window.userKeysPrivate = userKeysPrivate;
|
|
window.userKeysEncrypted = userKeysEncrypted;
|
|
}
|
|
|
|
async function generateUserKeysBundle() {
|
|
// X25519 keypair
|
|
const privX25519 = window.x25519.utils.randomSecretKey();
|
|
const pubX25519 = window.x25519.getPublicKey(privX25519);
|
|
|
|
// ML-KEM-768 keypair
|
|
const mlkem = new window.MlKem768();
|
|
const [pubMlKem, privMlKem] = await mlkem.generateKeyPair();
|
|
|
|
const pub = {
|
|
v: 1,
|
|
alg: "hybrid-x25519-mlkem768",
|
|
pubX25519: uint8ToBase64(pubX25519),
|
|
pubMlKem: uint8ToBase64(pubMlKem)
|
|
};
|
|
const priv = {
|
|
v: 1,
|
|
alg: "hybrid-x25519-mlkem768",
|
|
privX25519: uint8ToBase64(privX25519),
|
|
privMlKem: uint8ToBase64(privMlKem)
|
|
};
|
|
return {pub, priv};
|
|
}
|
|
|
|
async function ensureUserKeys() {
|
|
const lsEncKey = `userKeys.enc.v1:${id}`;
|
|
const lsPubKey = `userKeys.pub.v1:${id}`;
|
|
|
|
let serverKeys = null;
|
|
let serverKeysRaw = "";
|
|
try { //1. check server
|
|
serverKeysRaw = await fetchEncrypted("user/key/get");
|
|
if (serverKeysRaw && serverKeysRaw.trim() !== "" && serverKeysRaw.trim().startsWith("{")) {
|
|
serverKeys = JSON.parse(serverKeysRaw);
|
|
const serverEncPriv = serverKeys.string1;
|
|
const serverPub = serverKeys.string2;
|
|
if (serverEncPriv && serverPub) {
|
|
userKeysEncrypted = serverEncPriv;
|
|
userKeysPublic = JSON.parse(serverPub);
|
|
userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password);
|
|
localStorage.setItem(lsEncKey, userKeysEncrypted);
|
|
localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic));
|
|
publishUserKeysGlobals();
|
|
return "success:keys.server";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (serverKeys?.string1) {
|
|
throw new Error("error:keys.server.decrypt.failed");
|
|
}
|
|
}
|
|
|
|
//fallback to local
|
|
const localEnc = localStorage.getItem(lsEncKey);
|
|
const localPub = localStorage.getItem(lsPubKey);
|
|
if (localEnc && localPub) {
|
|
try {
|
|
const localPubObj = JSON.parse(localPub);
|
|
if (serverKeys?.string2) {
|
|
const serverPubObj = JSON.parse(serverKeys.string2);
|
|
if (userPublicKeysFingerprint(localPubObj) !== userPublicKeysFingerprint(serverPubObj)) {
|
|
throw new Error("error:keys.local.server.mismatch");
|
|
}
|
|
}
|
|
|
|
userKeysEncrypted = localEnc;
|
|
userKeysPublic = localPubObj;
|
|
userKeysPrivate = await decryptJsonWithPassword(userKeysEncrypted, password);
|
|
|
|
//push keys to server only when server has none yet
|
|
if (!serverKeys?.string1) {
|
|
const updateRes = await fetchEncrypted(
|
|
"user/key/update",
|
|
JSON.stringify({
|
|
string1: userKeysEncrypted,
|
|
string2: JSON.stringify(userKeysPublic)
|
|
})
|
|
);
|
|
if (updateRes === "error:keys.public.mismatch") {
|
|
throw new Error("error:keys.local.server.mismatch");
|
|
}
|
|
}
|
|
|
|
publishUserKeysGlobals();
|
|
return "success:keys.local";
|
|
} catch (e) {
|
|
if (e.message && e.message.startsWith("error:keys.")) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (serverKeys?.string1) {
|
|
throw new Error("error:keys.server.decrypt.failed");
|
|
}
|
|
|
|
//new keys, encrypt private with user's password
|
|
const bundle = await generateUserKeysBundle();
|
|
userKeysPublic = bundle.pub;
|
|
userKeysPrivate = bundle.priv;
|
|
userKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, password);
|
|
|
|
localStorage.setItem(lsEncKey, userKeysEncrypted);
|
|
localStorage.setItem(lsPubKey, JSON.stringify(userKeysPublic));
|
|
|
|
await fetchEncrypted(
|
|
"user/key/update",
|
|
JSON.stringify({
|
|
string1: userKeysEncrypted,
|
|
string2: JSON.stringify(userKeysPublic)
|
|
})
|
|
);
|
|
|
|
publishUserKeysGlobals();
|
|
return "success:keys.generated";
|
|
}
|
|
|
|
async function mainJS() {
|
|
if (localStorage.getItem('lang') != null) {
|
|
lang = localStorage.getItem('lang');
|
|
}
|
|
|
|
await initBlahs();
|
|
await loadEmotes();
|
|
|
|
passwordHash = await hashSHA3_512(password);
|
|
|
|
if (host) {
|
|
await updateProtocolAndUrl(host);
|
|
} else {
|
|
await updateProtocolAndUrl(window.location.hostname);
|
|
}
|
|
|
|
if (!id && username) {
|
|
let resolvedId = await fetchAsync(`${url}/nametoid?u=${username}`);
|
|
if (resolvedId && !resolvedId.startsWith("error:")) {
|
|
id = resolvedId.split(":")[0];
|
|
localStorage.setItem("id", id);
|
|
}
|
|
}
|
|
|
|
await start();
|
|
}
|
|
|
|
id = localStorage.getItem('id');
|
|
username = localStorage.getItem('username');
|
|
password = localStorage.getItem('password');
|
|
host = localStorage.getItem('host');
|
|
|
|
window.addEventListener("load", () => {
|
|
mainJS();
|
|
});
|
|
|
|
|
|
|
|
|
|
function showFixedContextMenu(rect, html) {
|
|
let parser = new DOMParser();
|
|
let doc = parser.parseFromString(html, "text/html");
|
|
|
|
let blahTags = doc.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
blahTags = doc.getElementsByClassName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
|
|
let placeholders = doc.querySelectorAll("[placeholder]");
|
|
for (let i = 0; i < placeholders.length; i++) {
|
|
if (placeholders[i].placeholder.startsWith("{blah(")) {
|
|
let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
|
|
placeholders[i].placeholder = processBlah(value);
|
|
}
|
|
|
|
}
|
|
|
|
fixedContextMenu.innerHTML = doc.body.innerHTML;
|
|
|
|
fixedContextMenu.style.transition = 'none';
|
|
fixedContextMenu.style.opacity = '0';
|
|
fixedContextMenu.style.left = '0px';
|
|
fixedContextMenu.style.top = '0px';
|
|
|
|
fixedContextMenu.classList.add("show");
|
|
|
|
let menuRect = fixedContextMenu.getBoundingClientRect();
|
|
|
|
let newLeft = rect.right + 10;
|
|
let newTop = rect.top;
|
|
|
|
if (newLeft + menuRect.width > window.innerWidth) {
|
|
newLeft = window.innerWidth - menuRect.width - 10;
|
|
}
|
|
if (newTop + menuRect.height > window.innerHeight) {
|
|
newTop = window.innerHeight - menuRect.height - 10;
|
|
}
|
|
|
|
fixedContextMenu.style.left = `${newLeft}px`;
|
|
fixedContextMenu.style.top = `${newTop}px`;
|
|
|
|
fixedContextMenu.offsetHeight;
|
|
fixedContextMenu.style.transition = '';
|
|
fixedContextMenu.style.opacity = '';
|
|
}
|
|
|
|
var currentRoomsBarTitle = null;
|
|
var currentRoomsBarHtml = null;
|
|
|
|
async function switchRoomsBar(title, content) {
|
|
if (currentRoomsBarTitle === title && currentRoomsBarHtml === content) return;
|
|
currentRoomsBarTitle = title;
|
|
currentRoomsBarHtml = content;
|
|
|
|
let roomsTopBarTransition = roomsTopBar.children.item(0);
|
|
|
|
roomsBar.style.transform = "scale(0.85)";
|
|
roomsBar.style.opacity = "0";
|
|
|
|
roomsTopBarTransition.style.transform = "scale(0.85)";
|
|
roomsTopBarTransition.style.opacity = "0";
|
|
|
|
|
|
await delay(200);
|
|
|
|
roomsTopBarTransition.innerHTML = processBlah(title);
|
|
|
|
|
|
let parser = new DOMParser();
|
|
let doc = parser.parseFromString(content, "text/html");
|
|
|
|
let blahTags = doc.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
blahTags = doc.getElementsByClassName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
|
|
let placeholders = doc.querySelectorAll("[placeholder]");
|
|
for (let i = 0; i < placeholders.length; i++) {
|
|
if (placeholders[i].placeholder.startsWith("{blah(")) {
|
|
let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
|
|
placeholders[i].placeholder = processBlah(value);
|
|
}
|
|
}
|
|
|
|
roomsBar.innerHTML = doc.body.innerHTML;
|
|
|
|
roomsBar.style.transform = "";
|
|
roomsBar.style.opacity = "";
|
|
|
|
roomsTopBarTransition.style.transform = "";
|
|
roomsTopBarTransition.style.opacity = "";
|
|
}
|
|
|
|
var currentRoomContentTitle = null;
|
|
var currentRoomContentHtml = null;
|
|
|
|
async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) {
|
|
if (currentRoomContentTitle === title && currentRoomContentHtml === content) {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
|
|
if (title != "title.splash") { //do not show splash on mobile
|
|
mainScreen.classList.remove('mobile-details');
|
|
mainScreen.classList.add('mobile-content');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
currentRoomContentTitle = title;
|
|
currentRoomContentHtml = content;
|
|
|
|
let roomsTopBarTransition = roomTopBar.children.item(0);
|
|
|
|
roomContentMain.style.transform = "scale(0.85)";
|
|
roomContentMain.style.opacity = "0";
|
|
|
|
roomsTopBarTransition.style.transform = "scale(0.85)";
|
|
roomsTopBarTransition.style.opacity = "0";
|
|
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth <= 55 * rem && !skipMobileSlide) {
|
|
if (title != "title.splash") //do not show splash on mobile
|
|
{
|
|
mainScreen.classList.remove('mobile-details');
|
|
mainScreen.classList.add('mobile-content');
|
|
}
|
|
} else {
|
|
await delay(200);
|
|
}
|
|
|
|
|
|
let detailsBtnHtml = showRoomBar ? detailsBtn : '';
|
|
let pinCallBtns = showRoomBar ? `${pinBtnHtml}${callBtnHtml}` : '';
|
|
|
|
roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}<space></space><inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}${detailsBtnHtml}`;
|
|
roomDetailsBar.style.display = showRoomBar ? "flex" : "none";
|
|
|
|
let parser = new DOMParser();
|
|
let doc = parser.parseFromString(content, "text/html");
|
|
|
|
let blahTags = doc.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
blahTags = doc.getElementsByClassName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
|
|
let placeholders = doc.querySelectorAll("[placeholder]");
|
|
for (let i = 0; i < placeholders.length; i++) {
|
|
if (placeholders[i].placeholder.startsWith("{blah(")) {
|
|
let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
|
|
placeholders[i].placeholder = processBlah(value);
|
|
}
|
|
}
|
|
|
|
roomContentMain.innerHTML = doc.body.innerHTML;
|
|
|
|
roomContentMain.style.transform = "";
|
|
roomContentMain.style.opacity = "";
|
|
|
|
roomsTopBarTransition.style.transform = "";
|
|
roomsTopBarTransition.style.opacity = "";
|
|
|
|
|
|
}
|
|
|
|
var currentDetailsContentTitle = null;
|
|
var currentDetailsContentHtml = null;
|
|
|
|
async function switchDetailsContent(title, content) {
|
|
if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return;
|
|
currentDetailsContentTitle = title;
|
|
currentDetailsContentHtml = content;
|
|
|
|
let roomsTopBarTransition = detailsTopBar.children.item(0);
|
|
|
|
roomDetailsMain.style.transform = "scale(0.85)";
|
|
roomDetailsMain.style.opacity = "0";
|
|
|
|
roomsTopBarTransition.style.transform = "scale(0.85)";
|
|
roomsTopBarTransition.style.opacity = "0";
|
|
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth > 55 * rem) {
|
|
await delay(200);
|
|
}
|
|
|
|
|
|
let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`;
|
|
roomsTopBarTransition.innerHTML = `<inherit>${processBlah(title)}</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) => {
|
|
history.pushState({trap: true}, "", location.href);
|
|
|
|
if (fixedContextMenu.classList.contains("show")) {
|
|
if (!fixedContextMenu.contains(e.target)) {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
if (!e.target.closest('sidebarelement')) {
|
|
setActiveSidebarIndicator(currentMainIndicator);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mainScreen && mainScreen.classList.contains('mobile-details')) {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth <= 55 * rem) {
|
|
if (!roomDetailsBar.contains(e.target) && !e.target.closest('.mobile-nav-btn.right') && !e.target.closest('.profile-popup-container')) {
|
|
mobileNavBack();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
window.addEventListener("popstate", (e) => {
|
|
popstate();
|
|
});
|
|
|
|
const {App} = window.Capacitor?.Plugins || {};
|
|
if (App) {
|
|
App.addListener('backButton', () => {
|
|
popstate();
|
|
});
|
|
}
|
|
|
|
function popstate() {
|
|
if (isProfileOpen) {
|
|
closeProfile();
|
|
return;
|
|
}
|
|
if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) {
|
|
history.back();
|
|
if (App) {
|
|
App.minimizeApp();
|
|
}
|
|
}
|
|
mobileNavBack();
|
|
}
|
|
|
|
|
|
|
|
sidebarProfileButton.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
history.pushState({trap: true}, "", location.href);
|
|
|
|
if (fixedContextMenu.classList.contains("show")) {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
setActiveSidebarIndicator(currentMainIndicator);
|
|
} else {
|
|
setActiveSidebarIndicator(sidebarProfileIndicator, true);
|
|
const rect = sidebarProfileButton.getBoundingClientRect();
|
|
showFixedContextMenu({top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left}, profileContextMenu);
|
|
}
|
|
});
|
|
|
|
function logout() {
|
|
localStorage.clear();
|
|
window.location.href = "login/index.html";
|
|
}
|
|
|
|
let isProfileOpen = false;
|
|
|
|
async function openProfile(accountId) {
|
|
if (fixedContextMenu.classList.contains("show")) {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
setActiveSidebarIndicator(currentMainIndicator);
|
|
}
|
|
|
|
let popupContainer = document.getElementById("profile-popup-container");
|
|
let popupInner = document.getElementById("profile-popup-inner");
|
|
|
|
showAction('action.profile.loading', 'profile-loading');
|
|
|
|
try {
|
|
if (!accountId) accountId = window.id; //var fallback
|
|
|
|
if (accountId.endsWith(';' + host)) {
|
|
accountId = accountId.substring(0, accountId.length - host.length - 1);
|
|
} else if (accountId.endsWith(':' + host)) {
|
|
accountId = accountId.substring(0, accountId.length - host.length - 1);
|
|
}
|
|
|
|
let nameWithHost = await fetchAsync(`${url}/idtoname?id=${accountId}`);
|
|
if (nameWithHost.startsWith("error:")) nameWithHost = accountId; //api fallback
|
|
|
|
let nameParts = nameWithHost.split(":");
|
|
let profileName = nameParts[0] || accountId;
|
|
let profileHost = nameParts.slice(1).join(":") || host;
|
|
|
|
let pfpUrl = await getAvatarUrl(accountId, nameWithHost);
|
|
|
|
let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`;
|
|
if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
|
|
let bannerHtml = `<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">${bio.replace(/</g, "<").replace(/>/g, ">")}</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) {
|
|
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', '${nameWithHost}', '${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>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
popupInner.innerHTML = `
|
|
${bannerHtml}
|
|
<div class="profile-avatar-container">
|
|
<img src="${pfpUrl}" class="profile-avatar">
|
|
</div>
|
|
<div class="profile-info">
|
|
<div>
|
|
<div class="profile-name">${profileName}</div>
|
|
<div class="profile-id-host">${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})</div>
|
|
</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 renderInvites(res, type) {
|
|
let container = document.getElementById("invites-container");
|
|
if (!container) return;
|
|
|
|
if (res && res != "") {
|
|
|
|
res = JSON.parse(res);
|
|
|
|
let invites = [
|
|
...Object.entries(res.dms).map(([id, value]) => ({
|
|
id,
|
|
type: "dm",
|
|
...value
|
|
})),
|
|
|
|
...Object.entries(res.groups).map(([id, value]) => ({
|
|
id,
|
|
type: "group",
|
|
...value
|
|
}))
|
|
].sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
container.classList.remove("empty");
|
|
let html = "";
|
|
let count = 0;
|
|
|
|
for (let i = 0; i < invites.length; i++) {
|
|
let invite = invites[i];
|
|
|
|
|
|
if (invite.type === "dm") {
|
|
let id = invite.id;
|
|
|
|
if (!id.includes(":")) {
|
|
id += `:${host}`;
|
|
}
|
|
let onlyId = id.split(":")[0];
|
|
let username = await fetchAsync(`${url}/idtoname?id=${id}`);
|
|
if (!(username && username.trim() !== "")) return;
|
|
count++;
|
|
|
|
let pfp = await getAvatarUrl(id, username);
|
|
|
|
let actions = "";
|
|
let desc = "";
|
|
|
|
if (type === "received") {
|
|
desc = `:desc.invite.dm.received:${username};${onlyId}`;
|
|
actions = `
|
|
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptInvite('${id}')">
|
|
${ICONS.check({ fill: "#fff", width: "24", height: "24" })}
|
|
</button>
|
|
<button class="icon-button" style="background-color: var(--big-red);" onclick="declineInvite('${id}')">
|
|
${ICONS.close({ fill: "#fff", width: "24", height: "24" })}
|
|
</button>
|
|
`;
|
|
} else {
|
|
desc = `:desc.invite.dm.sent:${username};${onlyId}`;
|
|
actions = `
|
|
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeInvite('${id}')">
|
|
${ICONS.close({ fill: "#fff", width: "24", height: "24" })}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
let entry = invitesEntry
|
|
.replaceAll("{username}", username.split(':')[0])
|
|
.replaceAll("{pfp}", pfp)
|
|
.replaceAll("{desc}", desc)
|
|
.replaceAll("{actions}", actions)
|
|
.replaceAll("{id}", id);
|
|
|
|
html += entry;
|
|
}
|
|
}
|
|
|
|
if (count > 0) {
|
|
container.innerHTML = html;
|
|
} else {
|
|
container.classList.add("empty");
|
|
container.innerHTML = invitesEmptyState;
|
|
}
|
|
} else {
|
|
container.classList.add("empty");
|
|
container.innerHTML = invitesEmptyState;
|
|
}
|
|
|
|
let blahTags = container.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
}
|
|
|
|
window.cachedInvites = {'received': null, 'sent': null};
|
|
|
|
async function loadInvites(tab) {
|
|
try {
|
|
showAction(`action.fetching.invites.${tab === 'received' ? 'recv' : 'sent'}`, `fetching.invites.${tab}`);
|
|
if (window.cachedInvites[tab]) {
|
|
await renderInvites(window.cachedInvites[tab], tab);
|
|
}
|
|
let res = await fetchEncrypted(`user/invites/${tab}`);
|
|
window.cachedInvites[tab] = res;
|
|
await renderInvites(res, tab);
|
|
} catch (e) {
|
|
showBlahNotification("error:something.wrong");
|
|
console.error(e);
|
|
await renderInvites("", tab);
|
|
}
|
|
clearAction(`fetching.invites.${tab}`);
|
|
}
|
|
|
|
async function switchInvitesTab(tab) {
|
|
if (!document.getElementById(`tab-invites-${tab}`).classList.contains('tab-inactive')) return;
|
|
document.getElementById('tab-invites-received').classList.add('tab-inactive');
|
|
document.getElementById('tab-invites-sent').classList.add('tab-inactive');
|
|
document.getElementById(`tab-invites-${tab}`).classList.remove('tab-inactive');
|
|
|
|
await loadInvites(tab);
|
|
}
|
|
|
|
async function sendInviteTo(targetId) {
|
|
try {
|
|
showAction("action.invite.sending", "invite.action");
|
|
let res = await fetchEncrypted("user/dm/invite", targetId);
|
|
clearAction("invite.action");
|
|
showBlahNotification(res || "success:invite.sent");
|
|
if (typeof loadInvites === 'function') await loadInvites('sent');
|
|
} catch (e) {
|
|
clearAction("invite.action");
|
|
showBlahNotification("error:something.wrong");
|
|
}
|
|
}
|
|
|
|
async function acceptInvite(targetId) { //TODO: Implement key generation
|
|
try {
|
|
showAction("action.invite.accepting", "invite.action");
|
|
|
|
await ensureUserKeys();
|
|
|
|
const inviterPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
|
|
const inviterPublicKeys = JSON.parse(inviterPublicKeysRaw);
|
|
|
|
//generate room key
|
|
const roomKey = randomRoomKey256Chars();
|
|
|
|
//encrypt for self
|
|
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
|
|
|
|
//encrypt for inviter
|
|
const hybrid = await deriveHybridKeyToUser(inviterPublicKeys);
|
|
const encForInviter = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
|
|
const setupPayload = JSON.stringify({
|
|
v: 1,
|
|
alg: "hybrid-x25519-mlkem768",
|
|
ctMlKem: hybrid.ciphertextMlKemBase64,
|
|
enc: encForInviter
|
|
});
|
|
|
|
let payload = JSON.stringify({
|
|
string1: targetId,
|
|
string2: selfWrapped,
|
|
string3: setupPayload
|
|
});
|
|
let res = await fetchEncrypted("user/dm/create", payload);
|
|
clearAction("invite.action");
|
|
showBlahNotification(res || "success:invite.accepted");
|
|
await loadInvites('received');
|
|
} catch (e) {
|
|
clearAction("invite.action");
|
|
showBlahNotification("error:something.wrong");
|
|
}
|
|
}
|
|
|
|
async function declineInvite(username) {
|
|
try {
|
|
showAction("action.invite.declining", "invite.action");
|
|
let res = await fetchEncrypted("user/dm/invite/decline", username);
|
|
clearAction("invite.action");
|
|
showBlahNotification(res || "success:invite.declined");
|
|
await loadInvites('received');
|
|
} catch (e) {
|
|
clearAction("invite.action");
|
|
showBlahNotification("error:something.wrong");
|
|
}
|
|
}
|
|
|
|
async function revokeInvite(username) {
|
|
try {
|
|
showAction("action.invite.revoking", "invite.action");
|
|
let res = await fetchEncrypted("user/dm/invite/revoke", username);
|
|
clearAction("invite.action");
|
|
showBlahNotification(res || "success:invite.revoked");
|
|
await loadInvites('sent');
|
|
} catch (e) {
|
|
clearAction("invite.action");
|
|
showBlahNotification("error:something.wrong");
|
|
}
|
|
}
|
|
|
|
function switchNotifisTab(tab) {
|
|
if (!document.getElementById(`tab-notifis-${tab}`).classList.contains('tab-inactive')) return;
|
|
document.getElementById('tab-notifis-all').classList.add('tab-inactive');
|
|
document.getElementById('tab-notifis-unread').classList.add('tab-inactive');
|
|
document.getElementById(`tab-notifis-${tab}`).classList.remove('tab-inactive');
|
|
}
|
|
|
|
|
|
async function gotoInbox() {
|
|
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
|
setActiveSidebarIndicator(sidebarInboxIndicator);
|
|
|
|
await switchRoomsBar("title.inbox", inboxRoomBar);
|
|
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth > 55 * rem) {
|
|
gotoInvites();
|
|
}
|
|
}
|
|
|
|
async function gotoInvites() {
|
|
setActiveRoombarItem('collapse-invites');
|
|
await switchRoomContent("title.invites", invitesScreen, false);
|
|
switchInvitesTab('received');
|
|
}
|
|
|
|
async function gotoNotifis() {
|
|
setActiveRoombarItem('collapse-notifis');
|
|
await switchRoomContent("title.notifications", notifisScreen, false);
|
|
switchNotifisTab('all');
|
|
}
|
|
|
|
function gotoCreateSpace() {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar
|
|
switchRoomContent("title.create.space", createSpaceScreen, false);
|
|
}
|
|
|
|
function gotoJoinSpace() {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar
|
|
switchRoomContent("title.join.space", joinSpaceScreen, false);
|
|
}
|
|
|
|
async function gotoHome(splash = true) {
|
|
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
|
setActiveSidebarIndicator(sidebarHomeIndicator);
|
|
if (splash) {
|
|
switchRoomContent("title.splash", splashScreen, false);
|
|
}
|
|
switchRoomsBar("title.home", homeRoomBar);
|
|
setActiveRoombarItem(null);
|
|
setupWebSocket();
|
|
await refreshDms(210);
|
|
}
|
|
|
|
function setActiveSidebarIndicator(element, isTemporary = false) {
|
|
let indicators = document.getElementsByTagName("indicator");
|
|
for (let i = 0; i < indicators.length; i++) {
|
|
indicators[i].classList.remove("active");
|
|
}
|
|
|
|
if (element) {
|
|
element.classList.add("active");
|
|
}
|
|
|
|
if (!isTemporary && element) {
|
|
currentMainIndicator = element;
|
|
}
|
|
}
|
|
|
|
function mobileNavBack() {
|
|
if (mainScreen.classList.contains('mobile-details')) {
|
|
mainScreen.classList.remove('mobile-details');
|
|
mainScreen.classList.add('mobile-content');
|
|
|
|
setActiveSidebarIndicator(currentMainIndicator);
|
|
} else {
|
|
setActiveRoombarItem(null);
|
|
if (roomsBarContainer) {
|
|
roomsBarContainer.style.display = ""; //restore roombar on mobile
|
|
void roomsBarContainer.offsetWidth;
|
|
}
|
|
|
|
mainScreen.classList.remove('mobile-content');
|
|
|
|
setActiveSidebarIndicator(currentMainIndicator);
|
|
}
|
|
}
|
|
|
|
function mobileNavDetails() {
|
|
mainScreen.classList.add('mobile-details');
|
|
}
|
|
|
|
|
|
let touchStartX = 0;
|
|
let touchEndX = 0;
|
|
let touchStartY = 0;
|
|
let touchEndY = 0;
|
|
let touchMoved = false;
|
|
|
|
let pressDate;
|
|
|
|
document.addEventListener('touchstart', e => {
|
|
touchStartX = e.changedTouches[0].screenX;
|
|
touchStartY = e.changedTouches[0].screenY;
|
|
touchMoved = false;
|
|
|
|
activeTouchButton = e.target.closest('button, input, textarea');
|
|
if (activeTouchButton) {
|
|
activeTouchButton.classList.add('is-pressed');
|
|
pressDate = Date.now();
|
|
}
|
|
}, {passive: true});
|
|
|
|
document.addEventListener('touchmove', e => {
|
|
if (!touchMoved) {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
let diffX = Math.abs(e.changedTouches[0].screenX - touchStartX);
|
|
let diffY = Math.abs(e.changedTouches[0].screenY - touchStartY);
|
|
if (diffX > rem || diffY > rem) {
|
|
touchMoved = true;
|
|
|
|
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
if (activeTouchButton) {
|
|
activeTouchButton.classList.remove('is-pressed');
|
|
activeTouchButton = null;
|
|
}
|
|
}
|
|
}
|
|
}, {passive: true});
|
|
|
|
document.addEventListener('touchend', async (e) => {
|
|
touchEndX = e.changedTouches[0].screenX;
|
|
touchEndY = e.changedTouches[0].screenY;
|
|
|
|
isAdjusting = false; //resize scroll fix
|
|
|
|
handleMobileSwipe();
|
|
|
|
if (activeTouchButton) {
|
|
|
|
if (!touchMoved) {
|
|
if (activeTouchButton.tagName !== 'INPUT' && activeTouchButton.tagName !== 'TEXTAREA') {
|
|
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
if (e.cancelable) {
|
|
e.preventDefault();
|
|
}
|
|
activeTouchButton.click();
|
|
} else {
|
|
activeTouchButton.focus();
|
|
}
|
|
}
|
|
|
|
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
|
|
if (elapsed < 100) {
|
|
const remaining = 100 - elapsed;
|
|
await delay(remaining);
|
|
}
|
|
activeTouchButton.classList.remove('is-pressed');
|
|
|
|
activeTouchButton = null;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('touchcancel', () => {
|
|
if (activeTouchButton) {
|
|
activeTouchButton.classList.remove('is-pressed');
|
|
activeTouchButton = null;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('contextmenu', e => {
|
|
if (e.target.closest('button')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
function handleMobileSwipe() {
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth > 55 * rem) return;
|
|
|
|
let diffX = touchEndX - touchStartX;
|
|
let diffY = touchEndY - touchStartY;
|
|
|
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) {
|
|
if (diffX > 0) {
|
|
mobileNavBack();
|
|
} else {
|
|
if (mainScreen.classList.contains('mobile-content') && roomDetailsBar.style.display !== 'none') {
|
|
mobileNavDetails();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function renderDms(res) {
|
|
let container = document.getElementById("dms-list");
|
|
if (!container) return;
|
|
|
|
if (res && res !== "") {
|
|
let parsed = JSON.parse(res);
|
|
let dms = parsed.dms || {};
|
|
let dmEntries = Object.entries(dms);
|
|
|
|
//Sort by timestamp descending
|
|
dmEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0));
|
|
|
|
let html = "";
|
|
let count = 0;
|
|
|
|
for (let [dmId, dmData] of dmEntries) {
|
|
let parts = dmId.split('_');
|
|
let myId = `${id};${host}`;
|
|
let targetId = parts[0] === myId ? parts[1] : parts[0];
|
|
|
|
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
|
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
|
|
|
let pfp = await getAvatarUrl(targetId, targetUsername);
|
|
let displayName = targetUsername.split(':')[0];
|
|
|
|
let isRead = dmData.string3 === "true";
|
|
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
|
|
|
|
html += `
|
|
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', '${targetUsername}', '${targetId}')">
|
|
<img src="${pfp}" class="room-pfp">
|
|
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
|
|
<span class="room-name" style="${nameStyle}">${displayName}</span>
|
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
|
|
</div>
|
|
</button>
|
|
`;
|
|
count++;
|
|
}
|
|
|
|
if (count > 0) {
|
|
container.innerHTML = html;
|
|
container.classList.remove("empty");
|
|
} else {
|
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
|
container.classList.add("empty");
|
|
let blahTags = container.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
}
|
|
} else {
|
|
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
|
|
container.classList.add("empty");
|
|
let blahTags = container.getElementsByTagName("blah");
|
|
for (let i = 0; i < blahTags.length; i++) {
|
|
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
|
|
}
|
|
}
|
|
}
|
|
|
|
var currentDmId = null;
|
|
var currentDmKey = null; // AES key for encrypting/decrypting messages
|
|
var dmUsernameCache = {};
|
|
var dmPfpCache = {};
|
|
var loadedMessages = {};
|
|
var oldestLoadedMsgId = null;
|
|
var isLoadingOlderMessages = false;
|
|
|
|
|
|
|
|
|
|
function escapeHtml(unsafe) {
|
|
if (!unsafe) return "";
|
|
return unsafe
|
|
.replaceAll(/&/g, "&")
|
|
.replaceAll(/</g, "<")
|
|
.replaceAll(/>/g, ">")
|
|
.replaceAll(/"/g, """)
|
|
.replaceAll(/'/g, "'");
|
|
}
|
|
|
|
async function generateDmMembersHtml(targetId, targetUsername) {
|
|
let myIdStr = id + ";" + host;
|
|
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
|
|
let myPfp = await getAvatarUrl(myIdStr, myUsername);
|
|
|
|
let targetPfp = await getAvatarUrl(targetId, targetUsername);
|
|
let targetDisplayName = targetUsername.split(':')[0];
|
|
let myDisplayName = myUsername.split(':')[0];
|
|
|
|
return `
|
|
<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" onclick="openProfile('${targetId}')" 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;">${targetUsername}</span>
|
|
</div>
|
|
</button>
|
|
<button class="room-entry" onclick="openProfile('${myIdStr}')" 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">${myDisplayName}</span>
|
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${myUsername}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function openDm(dmId, username, targetId) {
|
|
try {
|
|
showAction("action.dm.opening", "dmopen");
|
|
|
|
markDmAsRead(dmId);
|
|
|
|
currentDmKey = await ensureDmRoomKey(dmId);
|
|
if (!currentDmKey) {
|
|
throw new Error("Missing DM room key");
|
|
}
|
|
currentDmId = dmId;
|
|
loadedMessages = {};
|
|
oldestLoadedMsgId = null;
|
|
isLoadingOlderMessages = false;
|
|
|
|
let pfp = await getAvatarUrl(targetId, username);
|
|
let iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
|
|
|
if (currentRoomsBarTitle !== "title.home") {
|
|
gotoHome(false);
|
|
}
|
|
|
|
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
|
|
let membersHtml = await generateDmMembersHtml(targetId, username);
|
|
await switchDetailsContent("title.details", membersHtml);
|
|
|
|
let msgContainer = document.getElementById("chat-messages");
|
|
if (msgContainer) {
|
|
msgContainer.innerHTML = `<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 res = await fetchEncrypted("dm/messages/get", payload);
|
|
|
|
if (res && res.startsWith("error:")) {
|
|
showBlahNotification(res);
|
|
return;
|
|
}
|
|
|
|
let messages = JSON.parse(res);
|
|
let ids = Object.keys(messages).map(id => parseInt(id));
|
|
if (ids.length > 0) {
|
|
let minId = Math.min(...ids);
|
|
if (oldestLoadedMsgId === null || minId < oldestLoadedMsgId) {
|
|
oldestLoadedMsgId = minId;
|
|
}
|
|
}
|
|
if (ids.length < 50 && startOffset !== "") {
|
|
oldestLoadedMsgId = 0;
|
|
}
|
|
|
|
Object.assign(loadedMessages, messages);
|
|
await renderMessages(loadedMessages, isPrepend);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showBlahNotification("error:dm.messages.fetch.failed");
|
|
}
|
|
}
|
|
|
|
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 renderMessages(messages, isPrepend = false) {
|
|
let container = document.getElementById("chat-messages");
|
|
if (!container) return;
|
|
|
|
let entries = Object.entries(messages).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
|
|
let html = "";
|
|
let lastAuthor = null;
|
|
|
|
for (let [msgId, msg] of entries) {
|
|
let content = msg.content;
|
|
let reactions = msg.reactions || "";
|
|
let decrypted = false;
|
|
if (msg.key && msg.key !== "") {
|
|
try {
|
|
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
|
|
if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes);
|
|
decrypted = true;
|
|
} catch (e) {
|
|
content = `<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: "24", 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" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
|
|
for (let att of atts) {
|
|
let attContainerId = `att-container-${msgId}-${att.id}`;
|
|
let isMedia = 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: "24", height: "24", stroke: "currentColor" })}`
|
|
: `${ICONS.download({ width: "24", height: "24", stroke: "currentColor" })}`;
|
|
|
|
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="downloadAndRenderAttachment('${msgId}', '${att.id}', '${escapeHtml(att.type)}', '${escapeHtml(att.name)}')">
|
|
<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" 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 === "larp.info") {
|
|
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem;">${content}</div>`;
|
|
lastAuthor = null;
|
|
} else {
|
|
let showAvatar = lastAuthor !== msg.author;
|
|
lastAuthor = msg.author;
|
|
|
|
let authorName = "Unknown";
|
|
let pfp = "assets/default_avatar.png";
|
|
if (msg.author !== "0") {
|
|
if (!dmUsernameCache[msg.author]) {
|
|
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
|
|
if (fullUsername && !fullUsername.startsWith("error:")) {
|
|
dmUsernameCache[msg.author] = fullUsername;
|
|
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
|
|
}
|
|
}
|
|
if (dmUsernameCache[msg.author] && !dmPfpCache[msg.author]) {
|
|
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
|
|
}
|
|
if (dmUsernameCache[msg.author]) {
|
|
authorName = dmUsernameCache[msg.author].split(':')[0];
|
|
pfp = dmPfpCache[msg.author];
|
|
}
|
|
}
|
|
|
|
let date = new Date(parseInt(msg.timestamp));
|
|
let timeStr = date.toLocaleString();
|
|
|
|
let extraClass = showAvatar ? "with-avatar" : "";
|
|
|
|
let 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')} ${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="scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
|
|
}
|
|
|
|
let reactionsHtml = "";
|
|
if (reactions) {
|
|
try {
|
|
let rxArr = JSON.parse(reactions);
|
|
if (rxArr.length > 0) {
|
|
let rxCounts = {};
|
|
for (let rx of rxArr) {
|
|
let parts = rx.split(':');
|
|
if (parts.length < 2) continue;
|
|
let rxVal = parts.slice(1).join(':');
|
|
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false};
|
|
rxCounts[rxVal].count++;
|
|
if (parts[0] === id) rxCounts[rxVal].me = true;
|
|
}
|
|
|
|
let rxKeys = Object.keys(rxCounts);
|
|
if (rxKeys.length > 0) {
|
|
reactionsHtml = `<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);
|
|
reactionsHtml += `<span class="reaction-pill" style="padding: 0.1rem 0.4rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}" onclick="reactMessagePrompt('${msgId}', '${safeRx}')">${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}</span>`;
|
|
}
|
|
reactionsHtml += `</div>`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
html += `
|
|
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
|
|
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
|
|
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')">
|
|
${showAvatar ? `<div class="chat-message-header">
|
|
<span class="chat-message-author" onclick="openProfile('${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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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 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(msgId, attId, type, name) {
|
|
if (!currentDmId || !currentDmKey) return;
|
|
|
|
let container = document.getElementById(`att-container-${msgId}-${attId}`);
|
|
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 dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
let nonce = await getNonce(id, passwordHash);
|
|
|
|
let res = await fetch(`${url}/dm/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: 300px; display: block;" onclick="window.open('${blobUrl}', '_blank')">`;
|
|
} else if (type.startsWith("video/")) {
|
|
container.innerHTML = `<video src="${blobUrl}" controls style="max-width: 100%; max-height: 300px; display: block;"></video>`;
|
|
} else {
|
|
container.innerHTML = originalHtml;
|
|
container.style.border = "1px solid var(--accent-color)";
|
|
let a = document.createElement("a");
|
|
a.href = blobUrl;
|
|
a.download = name;
|
|
a.click();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</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(10px); -webkit-backdrop-filter: blur(10px); 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 || !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">${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 dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
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: {
|
|
"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();
|
|
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:")) {
|
|
attachmentsPayload.push({
|
|
id: i,
|
|
type: file.type,
|
|
name: file.name,
|
|
size: file.size,
|
|
staging_id: decText.split(":")[1]
|
|
});
|
|
} 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 || ""
|
|
};
|
|
|
|
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("dm/message/send", JSON.stringify(msgPayload));
|
|
clearAction("msgsending");
|
|
if (res && res.startsWith("error:")) {
|
|
showBlahNotification(res);
|
|
let pElem = document.getElementById(pendingMsgId);
|
|
if (pElem) pElem.remove();
|
|
} else if (res && res.startsWith("success:")) {
|
|
let parts = res.split(":");
|
|
if (parts.length > 1 && parts[1] !== "message.sent") {
|
|
let newMsgId = parts[1];
|
|
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: "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:")) {
|
|
let parts = data.split(":");
|
|
let msgDmId = parts[1];
|
|
let msgType = parts[2] || "unread";
|
|
|
|
if (msgType === "unread") {
|
|
updateDmListUI(msgDmId, false, Date.now().toString());
|
|
} else if (msgType === "read") {
|
|
updateDmListUI(msgDmId, true, Date.now().toString());
|
|
} else if (msgType === "update") {
|
|
// do nothing to UI sorting/read state
|
|
}
|
|
|
|
if (currentDmId === msgDmId) {
|
|
if (dmMessageLoadTimeout) clearTimeout(dmMessageLoadTimeout);
|
|
dmMessageLoadTimeout = setTimeout(() => {
|
|
loadDmMessages(msgDmId);
|
|
}, 300);
|
|
}
|
|
}
|
|
};
|
|
|
|
appWebSocket.onclose = () => {
|
|
setTimeout(setupWebSocket, 5000);
|
|
};
|
|
}
|
|
|
|
let currentContextMenuMsgId = null;
|
|
let touchContextMenuFired = false;
|
|
|
|
function handleMessageContextMenu(e, msgId) {
|
|
e.preventDefault();
|
|
if (touchContextMenuFired) {
|
|
touchContextMenuFired = false;
|
|
return;
|
|
}
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
currentContextMenuMsgId = msgId;
|
|
let elem = document.getElementById(`msg-${msgId}`);
|
|
if (elem) elem.classList.add("context-menu-open");
|
|
|
|
showFixedContextMenu({top: e.clientY, right: e.clientX, bottom: e.clientY, left: e.clientX}, messageContextMenu);
|
|
let delBtn = fixedContextMenu.querySelector("#context-delete-btn");
|
|
let reactBtn = fixedContextMenu.querySelector("#context-react-btn");
|
|
let msg = loadedMessages[msgId];
|
|
|
|
if (msg && msg.type === "larp.redacted") {
|
|
if (delBtn) delBtn.style.display = "none";
|
|
if (reactBtn) reactBtn.style.display = "none";
|
|
} else {
|
|
if (delBtn) {
|
|
if (msg && msg.author !== id) {
|
|
delBtn.style.display = "none";
|
|
} else {
|
|
delBtn.style.display = "";
|
|
}
|
|
}
|
|
if (reactBtn) reactBtn.style.display = "";
|
|
}
|
|
}
|
|
|
|
async function deleteMessage(msgId) {
|
|
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
showAction("action.message.deleting", "msgdel");
|
|
try {
|
|
let msgPayload = {
|
|
string1: currentDmId,
|
|
string2: msgId
|
|
};
|
|
let res = await fetchEncrypted("dm/message/delete", JSON.stringify(msgPayload));
|
|
if (res && res.startsWith("error:")) {
|
|
showBlahNotification(res);
|
|
}
|
|
} catch (e) {
|
|
showBlahNotification("error:message.delete.failed");
|
|
}
|
|
clearAction("msgdel");
|
|
}
|
|
|
|
function cancelReply() {
|
|
replyingToMsgId = null;
|
|
let bar = document.getElementById("replying-bar");
|
|
if (bar) bar.style.display = "none";
|
|
let input = document.getElementById("chat-input");
|
|
if (input) input.focus();
|
|
}
|
|
|
|
async function scrollToMessage(msgId) {
|
|
let elem = document.getElementById(`msg-${msgId}`);
|
|
if (elem) {
|
|
elem.scrollIntoView({behavior: "smooth", block: "center"});
|
|
elem.style.transition = "background-color 0.5s";
|
|
let oldBg = elem.style.backgroundColor;
|
|
elem.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
|
|
setTimeout(() => {
|
|
elem.style.backgroundColor = oldBg;
|
|
}, 1500);
|
|
} else {
|
|
if (oldestLoadedMsgId !== null && parseInt(msgId) < oldestLoadedMsgId) {
|
|
showAction("info.messages.loading.older", "msgscroll");
|
|
let tries = 0;
|
|
while (!document.getElementById(`msg-${msgId}`) && oldestLoadedMsgId > 0 && tries < 15) {
|
|
await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true);
|
|
tries++;
|
|
}
|
|
clearAction("msgscroll");
|
|
let elem2 = document.getElementById(`msg-${msgId}`);
|
|
if (elem2) {
|
|
elem2.scrollIntoView({behavior: "smooth", block: "center"});
|
|
elem2.style.transition = "background-color 0.5s";
|
|
let oldBg = elem2.style.backgroundColor;
|
|
elem2.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
|
|
setTimeout(() => {
|
|
elem2.style.backgroundColor = oldBg;
|
|
}, 1500);
|
|
} else {
|
|
showBlahNotification("error:message.not.found");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function replyMessage(msgId) {
|
|
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
|
|
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
|
|
|
|
let msg = loadedMessages[msgId];
|
|
if (msg && msg.author) {
|
|
replyingToMsgId = msgId + ":" + msg.author;
|
|
} else {
|
|
replyingToMsgId = msgId;
|
|
}
|
|
|
|
let bar = document.getElementById("replying-bar");
|
|
if (bar) {
|
|
let msg = loadedMessages[msgId];
|
|
let authorName = processBlah('unknown.author');
|
|
if (msg && msg.author !== "0" && dmUsernameCache[msg.author]) {
|
|
authorName = dmUsernameCache[msg.author].split(':')[0];
|
|
}
|
|
document.getElementById("replying-to-name").textContent = authorName;
|
|
|
|
let content = msg ? msg.content : "";
|
|
if (msg && msg.key && msg.key !== "") {
|
|
try {
|
|
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
|
|
} catch (e) {
|
|
}
|
|
}
|
|
content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => processBlah(p1));
|
|
content = processBlah(content);
|
|
|
|
document.getElementById("replying-to-text").textContent = content;
|
|
bar.style.display = "flex";
|
|
}
|
|
let input = document.getElementById("chat-input");
|
|
input.focus();
|
|
}
|
|
|
|
var reactionPickerMenuHtml = "";
|
|
var loadedEmotes = [];
|
|
|
|
async function loadEmotes() {
|
|
try {
|
|
let res = await fetch("emotes.json");
|
|
let emotesObj = await res.json();
|
|
let skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
|
loadedEmotes = Object.keys(emotesObj).flatMap(char => {
|
|
let baseEmote = { char: char, name: emotesObj[char].name, slug: emotesObj[char].slug };
|
|
if (emotesObj[char].skin_tone_support) {
|
|
let variants = skinTones.map(tone => ({
|
|
char: char + tone,
|
|
name: emotesObj[char].name + " skin tone",
|
|
slug: emotesObj[char].slug
|
|
}));
|
|
return [baseEmote, ...variants];
|
|
}
|
|
return [baseEmote];
|
|
});
|
|
|
|
reactionPickerMenuHtml = `
|
|
<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(event.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 => `<button onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent('${emoji.char}'))" 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 existingReactions = [];
|
|
if (currentMsg && currentMsg.reactions) {
|
|
let rxStr = currentMsg.reactions;
|
|
if (currentMsg.key && currentMsg.key !== "") {
|
|
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
try {
|
|
rxStr = await decryptAesGcmFromBase64(rxStr, dmKeyBytes);
|
|
} catch (e) {
|
|
}
|
|
}
|
|
try {
|
|
existingReactions = JSON.parse(rxStr);
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
let targetEntry = id + ":" + reaction;
|
|
let index = existingReactions.indexOf(targetEntry);
|
|
if (index > -1) {
|
|
existingReactions.splice(index, 1);
|
|
} else {
|
|
existingReactions.push(targetEntry);
|
|
}
|
|
|
|
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
|
|
let encryptedReactions = await encryptAesGcmToBase64(JSON.stringify(existingReactions), dmKeyBytes);
|
|
|
|
let originalReactionsEncrypted = "";
|
|
if (currentMsg) {
|
|
originalReactionsEncrypted = currentMsg.reactions || "";
|
|
currentMsg.reactions = encryptedReactions;
|
|
renderMessages(loadedMessages);
|
|
}
|
|
|
|
let msgPayload = {
|
|
string1: currentDmId,
|
|
string2: msgId,
|
|
string3: encryptedReactions
|
|
};
|
|
let res = await fetchEncrypted("dm/message/react", JSON.stringify(msgPayload));
|
|
if (res && res.startsWith("error:")) {
|
|
showBlahNotification(res);
|
|
if (currentMsg) {
|
|
currentMsg.reactions = originalReactionsEncrypted;
|
|
renderMessages(loadedMessages);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
showBlahNotification("error:message.react.failed");
|
|
}
|
|
clearAction("msgreact");
|
|
}
|
|
|
|
// --- Settings Logic ---
|
|
async function gotoSettings(tab = 'account') {
|
|
fixedContextMenu.classList.remove("show");
|
|
if (roomsBarContainer) roomsBarContainer.style.display = "";
|
|
await switchRoomsBar("title.settings", settingsBar);
|
|
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
if (window.innerWidth > 55 * rem) {
|
|
switchSettingsTab(tab);
|
|
}
|
|
}
|
|
|
|
async function switchSettingsTab(tabName) {
|
|
setActiveRoombarItem('tab-settings-' + tabName);
|
|
if (tabName === 'account') {
|
|
await switchRoomContent("title.account", accountSettings, false);
|
|
let userEl = document.getElementById("settings-username");
|
|
if (userEl && window.username) userEl.value = window.username;
|
|
} else if (tabName === 'profile') {
|
|
showAction('title.loading', 'settings-loading');
|
|
|
|
try {
|
|
let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host);
|
|
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
|
|
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
|
|
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
|
|
|
|
clearAction('settings-loading');
|
|
await switchRoomContent("title.profile", profileSettings, false);
|
|
|
|
let pfpEl = document.getElementById("settings-avatar-preview");
|
|
if (pfpEl) pfpEl.src = pfpUrl;
|
|
|
|
let bannerEl = document.getElementById("settings-banner-preview");
|
|
if (bannerEl) bannerEl.src = bannerUrl;
|
|
|
|
let bioEl = document.getElementById("settings-bio");
|
|
if (bioEl && !bio.startsWith("error:")) bioEl.value = bio;
|
|
} catch (e) {
|
|
clearAction('settings-loading');
|
|
await switchRoomContent("title.profile", profileSettings, false);
|
|
}
|
|
} else if (tabName === 'appearance') {
|
|
await switchRoomContent("title.appearance", appearanceSettings, false);
|
|
populateLanguageDropdown();
|
|
}
|
|
}
|
|
|
|
async function populateLanguageDropdown() {
|
|
let sel = document.getElementById("settings-language");
|
|
if (!sel) return;
|
|
try {
|
|
let path = window.location.pathname.replace("/login/index.html", "/").replace("index.html", "");
|
|
let res = await fetchAsync(`${path}blah/index.json`);
|
|
let json = JSON.parse(res);
|
|
sel.innerHTML = "";
|
|
for (let code in json) {
|
|
let opt = document.createElement("option");
|
|
opt.value = code;
|
|
opt.textContent = json[code].native + " (" + json[code].english + ")";
|
|
if (code === lang) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load languages", e);
|
|
}
|
|
}
|
|
|
|
function changeLanguage(newLang) {
|
|
if (!newLang) return;
|
|
localStorage.setItem('lang', newLang);
|
|
window.location.reload();
|
|
}
|
|
|
|
async function changeUsername() {
|
|
let input = document.getElementById("settings-username");
|
|
let name = input.value;
|
|
if (!name) return;
|
|
showAction("action.account.saving", "accountsave");
|
|
let res = await fetchPostEnc(`${url}/chname?id=${window.id}`, name);
|
|
clearAction("accountsave");
|
|
let text = res;
|
|
if (text.startsWith("success:")) {
|
|
showBlahNotification(text);
|
|
loginUsername = name;
|
|
localStorage.setItem("username", name);
|
|
} else {
|
|
showBlahNotification(text);
|
|
}
|
|
}
|
|
|
|
async function changePassword() {
|
|
let input = document.getElementById("settings-password");
|
|
let newPass = input.value;
|
|
if (!newPass) return;
|
|
showAction("action.account.saving", "accountsave");
|
|
|
|
let newPassHash = await hashSHA3_512(newPass);
|
|
let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass);
|
|
|
|
let payload = JSON.stringify({
|
|
string1: newPassHash,
|
|
string2: JSON.stringify({
|
|
string1: newUserKeysEncrypted,
|
|
string2: JSON.stringify(userKeysPublic)
|
|
})
|
|
});
|
|
|
|
let res = await fetchPostEnc(`${url}/chpass?id=${window.id}`, payload);
|
|
|
|
clearAction("accountsave");
|
|
let text = res;
|
|
if (text.startsWith("success:")) {
|
|
password = newPass;
|
|
localStorage.setItem("password", newPass);
|
|
|
|
passwordHash = newPassHash;
|
|
userKeysEncrypted = newUserKeysEncrypted;
|
|
localStorage.setItem(`userKeys.enc.v1:${window.id}`, userKeysEncrypted);
|
|
|
|
showBlahNotification(text);
|
|
} else {
|
|
showBlahNotification(text);
|
|
}
|
|
}
|
|
|
|
async function deleteAccount() {
|
|
if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return;
|
|
showAction("action.account.deleting", "accountdel");
|
|
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
|
|
clearAction("accountdel");
|
|
let text = res;
|
|
if (text.startsWith("success:")) {
|
|
alert(processBlah(text));
|
|
logout();
|
|
} else {
|
|
showBlahNotification(text);
|
|
}
|
|
}
|
|
|
|
async function saveProfile() {
|
|
let bioInput = document.getElementById("settings-bio");
|
|
if (bioInput) {
|
|
let payload = { string1: "larp.profile.bio", string2: bioInput.value || "" };
|
|
showAction("action.bio.saving", "savebio");
|
|
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
|
|
clearAction("savebio");
|
|
if (res && res.startsWith("error:")) { showBlahNotification(res); return; }
|
|
}
|
|
showBlahNotification("success:profile.updated");
|
|
}
|
|
|
|
// --- Cropper Logic ---
|
|
let cropperType = 'avatar'; // 'avatar' or 'banner'
|
|
let cropperImageObj = null;
|
|
let cropperPosX = 0;
|
|
let cropperPosY = 0;
|
|
let cropperBaseZoom = 1;
|
|
let cropperOriginalType = '';
|
|
let cropperOriginalB64 = '';
|
|
|
|
let isDraggingCropper = false;
|
|
let startDragX = 0;
|
|
let startDragY = 0;
|
|
let startPosX = 0;
|
|
let startPosY = 0;
|
|
|
|
function openCropper(input, type) {
|
|
if (!input.files || input.files.length === 0) return;
|
|
let file = input.files[0];
|
|
cropperType = type;
|
|
|
|
let reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
let img = new Image();
|
|
img.onload = () => {
|
|
cropperImageObj = img;
|
|
initCropper();
|
|
};
|
|
cropperOriginalType = file.type;
|
|
cropperOriginalB64 = e.target.result;
|
|
img.src = e.target.result;
|
|
document.getElementById('cropper-image').src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
input.value = ""; // reset input
|
|
}
|
|
|
|
function initCropper() {
|
|
let container = document.getElementById('cropper-viewport-container');
|
|
let imgEl = document.getElementById('cropper-image');
|
|
let zoomSlider = document.getElementById('cropper-zoom');
|
|
|
|
// Set aspect ratio
|
|
let targetWidth = cropperType === 'avatar' ? 512 : 494;
|
|
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
|
|
let aspectRatio = targetWidth / targetHeight;
|
|
|
|
container.style.height = 'auto';
|
|
container.style.maxHeight = 'calc(85vh - 16rem)';
|
|
container.style.aspectRatio = `${targetWidth} / ${targetHeight}`;
|
|
container.style.maxWidth = `min(30rem, calc((85vh - 16rem) * ${aspectRatio}))`;
|
|
|
|
// Force layout recalculation
|
|
void container.offsetHeight;
|
|
let containerWidth = container.clientWidth || 300;
|
|
|
|
let imgWidth = cropperImageObj.width;
|
|
let imgHeight = cropperImageObj.height;
|
|
|
|
let scaleX = containerWidth / imgWidth;
|
|
let scaleY = (containerWidth * (targetHeight / targetWidth)) / imgHeight;
|
|
cropperBaseZoom = Math.max(scaleX, scaleY);
|
|
|
|
zoomSlider.min = cropperBaseZoom;
|
|
zoomSlider.max = cropperBaseZoom * 5;
|
|
zoomSlider.value = cropperBaseZoom;
|
|
|
|
cropperPosX = 0;
|
|
cropperPosY = 0;
|
|
|
|
updateCropperTransform();
|
|
document.getElementById('cropper-popup-container').classList.add('show');
|
|
}
|
|
|
|
function updateCropperZoom() {
|
|
updateCropperTransform();
|
|
}
|
|
|
|
function updateCropperTransform() {
|
|
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
|
let imgEl = document.getElementById('cropper-image');
|
|
let container = document.getElementById('cropper-viewport-container');
|
|
|
|
if (cropperImageObj) {
|
|
let scaledW = cropperImageObj.width * zoom;
|
|
let scaledH = cropperImageObj.height * zoom;
|
|
|
|
let minX = container.clientWidth - scaledW;
|
|
let maxX = 0;
|
|
let minY = container.clientHeight - scaledH;
|
|
let maxY = 0;
|
|
|
|
if (minX > maxX) minX = maxX;
|
|
if (minY > maxY) minY = maxY;
|
|
|
|
if (cropperPosX < minX) cropperPosX = minX;
|
|
if (cropperPosX > maxX) cropperPosX = maxX;
|
|
if (cropperPosY < minY) cropperPosY = minY;
|
|
if (cropperPosY > maxY) cropperPosY = maxY;
|
|
}
|
|
|
|
imgEl.style.transform = `translate(${cropperPosX}px, ${cropperPosY}px) scale(${zoom})`;
|
|
}
|
|
|
|
function closeCropper() {
|
|
document.getElementById('cropper-popup-container').classList.remove('show');
|
|
}
|
|
|
|
async function confirmCropper() {
|
|
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
|
let container = document.getElementById('cropper-viewport-container');
|
|
|
|
let targetWidth = cropperType === 'avatar' ? 512 : 988;
|
|
let targetHeight = cropperType === 'avatar' ? 512 : 400;
|
|
let scaleRatio = targetWidth / container.clientWidth;
|
|
let drawX = cropperPosX * scaleRatio;
|
|
let drawY = cropperPosY * scaleRatio;
|
|
let drawW = cropperImageObj.width * zoom * scaleRatio;
|
|
let drawH = cropperImageObj.height * zoom * scaleRatio;
|
|
|
|
let b64;
|
|
let newSrc;
|
|
let isAnimated = false;
|
|
let gifFrames = null;
|
|
let webpDecoded = null;
|
|
let parsedGif = null;
|
|
|
|
if (cropperOriginalType === 'image/gif') {
|
|
isAnimated = true;
|
|
} else if (cropperOriginalType === 'image/webp') {
|
|
showAction("action.processing", "uploadmedia");
|
|
try {
|
|
let res = await fetch(cropperOriginalB64);
|
|
let buffer = await res.arrayBuffer();
|
|
let webpWorker = new Worker('webp-worker.js', { type: 'module' });
|
|
webpDecoded = await new Promise((resolve, reject) => {
|
|
webpWorker.onmessage = (e) => {
|
|
if (e.data.success) resolve(e.data.data);
|
|
else reject(new Error(e.data.error));
|
|
webpWorker.terminate();
|
|
};
|
|
webpWorker.onerror = (err) => { reject(err); webpWorker.terminate(); };
|
|
webpWorker.postMessage({ action: 'decode', data: new Uint8Array(buffer) });
|
|
});
|
|
if (webpDecoded && webpDecoded.length > 1) {
|
|
isAnimated = true;
|
|
}
|
|
} catch (e) {
|
|
console.error("WebP decode check failed", e);
|
|
}
|
|
}
|
|
|
|
if (isAnimated) {
|
|
showAction("action.processing", "uploadmedia");
|
|
try {
|
|
let webpFrames = [];
|
|
let tempCanvas = document.createElement('canvas');
|
|
let tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
let gifCanvas = document.createElement('canvas');
|
|
let gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
|
|
gifCanvas.width = targetWidth;
|
|
gifCanvas.height = targetHeight;
|
|
|
|
if (cropperOriginalType === 'image/gif') {
|
|
let res = await fetch(cropperOriginalB64);
|
|
let buffer = await res.arrayBuffer();
|
|
parsedGif = gifuct.parseGIF(buffer);
|
|
gifFrames = gifuct.decompressFrames(parsedGif, true);
|
|
|
|
tempCanvas.width = parsedGif.lsd.width;
|
|
tempCanvas.height = parsedGif.lsd.height;
|
|
|
|
let frameImageData = null;
|
|
let previousImageData = null;
|
|
|
|
for (let i = 0; i < gifFrames.length; i++) {
|
|
let frame = gifFrames[i];
|
|
|
|
if (frame.disposalType === 3) {
|
|
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
|
}
|
|
|
|
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
|
|
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
|
|
}
|
|
|
|
frameImageData.data.set(frame.patch);
|
|
|
|
let patchCanvas = document.createElement('canvas');
|
|
patchCanvas.width = frame.dims.width;
|
|
patchCanvas.height = frame.dims.height;
|
|
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
|
|
|
|
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
|
|
|
|
gifCtx.fillStyle = '#000';
|
|
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
|
|
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
|
|
|
|
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
|
|
webpFrames.push({
|
|
data: frameImgData.data,
|
|
duration: Math.max(20, frame.delay || 0),
|
|
config: { lossless: 0, quality: 90 }
|
|
});
|
|
|
|
if (frame.disposalType === 2) {
|
|
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
|
|
} else if (frame.disposalType === 3 && previousImageData) {
|
|
tempCtx.putImageData(previousImageData, 0, 0);
|
|
}
|
|
}
|
|
} else if (cropperOriginalType === 'image/webp' && webpDecoded) {
|
|
tempCanvas.width = webpDecoded[0].width;
|
|
tempCanvas.height = webpDecoded[0].height;
|
|
for (let i = 0; i < webpDecoded.length; i++) {
|
|
let frame = webpDecoded[i];
|
|
let frameImageData = new ImageData(
|
|
new Uint8ClampedArray(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength),
|
|
frame.width,
|
|
frame.height
|
|
);
|
|
tempCtx.putImageData(frameImageData, 0, 0);
|
|
|
|
gifCtx.fillStyle = '#000';
|
|
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
|
|
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
|
|
|
|
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
|
|
webpFrames.push({
|
|
data: frameImgData.data,
|
|
duration: Math.max(20, frame.duration || 0),
|
|
config: { lossless: 0, quality: 90 }
|
|
});
|
|
}
|
|
}
|
|
|
|
let webpUint8 = await new Promise((resolve, reject) => {
|
|
let worker = new Worker('webp-worker.js', { type: 'module' });
|
|
worker.onmessage = (e) => {
|
|
if (e.data.success) {
|
|
resolve(e.data.data);
|
|
} else {
|
|
reject(new Error(e.data.error));
|
|
}
|
|
worker.terminate();
|
|
};
|
|
worker.onerror = (err) => {
|
|
reject(err);
|
|
worker.terminate();
|
|
};
|
|
worker.postMessage({ action: 'encode', targetWidth, targetHeight, webpFrames });
|
|
});
|
|
let webpBlob = new Blob([webpUint8], {type: 'image/webp'});
|
|
|
|
b64 = await new Promise((resolve) => {
|
|
let reader = new FileReader();
|
|
reader.onload = function() {
|
|
resolve(reader.result.split(',')[1]);
|
|
};
|
|
reader.readAsDataURL(webpBlob);
|
|
});
|
|
newSrc = 'data:image/webp;base64,' + b64;
|
|
} catch (e) {
|
|
console.error("Animated crop failed", e);
|
|
b64 = cropperOriginalB64.split(',')[1];
|
|
newSrc = cropperOriginalB64;
|
|
}
|
|
} else {
|
|
let canvas = document.createElement('canvas');
|
|
let ctx = canvas.getContext('2d');
|
|
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, targetWidth, targetHeight);
|
|
ctx.drawImage(cropperImageObj, drawX, drawY, drawW, drawH);
|
|
|
|
b64 = canvas.toDataURL('image/webp', 0.9).split(',')[1];
|
|
newSrc = 'data:image/webp;base64,' + b64;
|
|
}
|
|
|
|
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
|
|
let fileBytes = base64ToUint8(b64);
|
|
showAction("action.uploading", "uploadmedia");
|
|
let res = await fetchEncryptedUpload(payloadKey, fileBytes);
|
|
clearAction("uploadmedia");
|
|
if (res && res.startsWith("error:")) {
|
|
showBlahNotification(res);
|
|
} else {
|
|
showBlahNotification(`success:${cropperType}.updated`);
|
|
window.profileUpdateTimestamp = Date.now();
|
|
|
|
// Update all matching images on the page instantly
|
|
document.querySelectorAll('img').forEach(img => {
|
|
if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) {
|
|
img.src = newSrc;
|
|
}
|
|
});
|
|
}
|
|
|
|
closeCropper();
|
|
}
|
|
|
|
|
|
let container = document.getElementById('cropper-viewport-container');
|
|
if (container) {
|
|
const startDrag = (e) => {
|
|
isDraggingCropper = true;
|
|
startDragX = e.clientX || (e.touches && e.touches[0].clientX);
|
|
startDragY = e.clientY || (e.touches && e.touches[0].clientY);
|
|
startPosX = cropperPosX;
|
|
startPosY = cropperPosY;
|
|
if (e.type !== "touchstart") e.preventDefault();
|
|
};
|
|
|
|
const onDrag = (e) => {
|
|
if (!isDraggingCropper) return;
|
|
let clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
|
let clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
|
cropperPosX = startPosX + (clientX - startDragX);
|
|
cropperPosY = startPosY + (clientY - startDragY);
|
|
updateCropperTransform();
|
|
};
|
|
|
|
const endDrag = () => {
|
|
isDraggingCropper = false;
|
|
};
|
|
|
|
container.addEventListener('mousedown', startDrag);
|
|
container.addEventListener('touchstart', startDrag, {passive: true});
|
|
document.addEventListener('mousemove', onDrag);
|
|
document.addEventListener('touchmove', onDrag, {passive: true});
|
|
document.addEventListener('mouseup', endDrag);
|
|
document.addEventListener('touchend', endDrag);
|
|
}
|
|
|
|
function updateDmListUI(dmId, isRead, timestamp = null) {
|
|
try {
|
|
if (!window.cachedDms) return;
|
|
let parsedDms = JSON.parse(window.cachedDms);
|
|
if (!parsedDms.dms || !parsedDms.dms[dmId]) return;
|
|
|
|
let changed = false;
|
|
if (parsedDms.dms[dmId].string3 !== (isRead ? "true" : "false")) {
|
|
parsedDms.dms[dmId].string3 = isRead ? "true" : "false";
|
|
changed = true;
|
|
}
|
|
if (timestamp && parsedDms.dms[dmId].string2 !== timestamp) {
|
|
parsedDms.dms[dmId].string2 = timestamp;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
window.cachedDms = JSON.stringify(parsedDms);
|
|
}
|
|
|
|
let btn = document.getElementById(`dm-btn-${dmId}`);
|
|
let container = document.getElementById("dms-list");
|
|
if (btn && container) {
|
|
let nameSpan = btn.querySelector('.room-name');
|
|
if (nameSpan) {
|
|
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
|
}
|
|
if (timestamp) {
|
|
container.prepend(btn);
|
|
}
|
|
} else {
|
|
if (typeof refreshDms === 'function') refreshDms(0, false);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (typeof refreshDms === 'function') refreshDms(0, false);
|
|
}
|
|
}
|
|
|
|
function markDmAsRead(dmId) {
|
|
fetchEncrypted("dm/read", JSON.stringify({string1: dmId})).catch(()=>{});
|
|
updateDmListUI(dmId, true);
|
|
}
|
|
|
|
function handleDmInteraction() {
|
|
if (!currentDmId) return;
|
|
let container = document.getElementById("chat-messages");
|
|
if (!container) return;
|
|
|
|
if (lastChatScroll <= 10) {
|
|
try {
|
|
if (window.cachedDms) {
|
|
let parsedDms = JSON.parse(window.cachedDms);
|
|
if (parsedDms.dms && parsedDms.dms[currentDmId] && parsedDms.dms[currentDmId].string3 !== "true") {
|
|
markDmAsRead(currentDmId);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', handleDmInteraction);
|
|
document.addEventListener('keydown', handleDmInteraction);
|
|
document.addEventListener('touchstart', handleDmInteraction); |