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