diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index 75fcf968..11bc708c 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -120,7 +120,6 @@ "title.back.to.register": "back to registration", "placeholder.username": "cat name", "placeholder.captcha.code": "captcha code", - "placeholder.message.input": "meow...", "desc.messages.loading": "loading meows...", "desc.no.dms": "no direct meowchats :c", "action.dm.opening": "opening meowchat...", @@ -128,5 +127,18 @@ "keys.local.server.mismatch": "this device has different nametags than server :c use the device u first logged in on", "keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c", "dm.messages.fetch.failed": "failed to fetch meows :c", - "messages.decrypt.failed": "failed to decrypt meow :c" + "messages.decrypt.failed": "failed to decrypt meow :c", + "title.reply.message": "meow back", + "title.react.message": "purr", + "title.delete.message": "hiss away", + "title.replied.to": "meowed back to", + "action.message.deleting": "hissing message away...", + "action.message.sending": "sending meow...", + "action.message.reacting": "adding purr...", + "error:message.delete.failed": "failed to hiss away meow", + "error:message.send.failed": "failed to send meow", + "error:message.react.failed": "failed to add purr", + "info.sending.message": "sending...", + "placeholder.message.input": "meow...", + "larp.redacted": "this meow was hissed away." } diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index 104354bb..6b1710ad 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -127,5 +127,17 @@ "keys.local.server.mismatch": "This device has different encryption keys than the server. Use the device where you first logged in, or restore server keys from backup.", "keys.server.decrypt.failed": "Could not decrypt your account keys with this password.", "dm.messages.fetch.failed": "Failed to fetch messages", - "messages.decrypt.failed": "Failed to decrypt message" + "messages.decrypt.failed": "Failed to decrypt message", + "title.reply.message": "Reply", + "title.react.message": "React", + "title.delete.message": "Delete", + "title.replied.to": "Replied to", + "action.message.deleting": "Deleting message...", + "action.message.sending": "Sending message...", + "action.message.reacting": "Adding reaction...", + "error:message.delete.failed": "Failed to delete message", + "error:message.send.failed": "Failed to send message", + "error:message.react.failed": "Failed to add reaction", + "info.sending.message": "Sending...", + "larp.redacted": "This message was deleted." } diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index 27f760e6..a1bc9de9 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -1,18 +1,16 @@ var prot = window.location.protocol; -async function updateProtocolAndUrl(host) -{ +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) { + } catch (error) { try { JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`)); prot = "https:"; + } catch (error) { } - catch (error) {} } } url = `${prot}//${host}/_larpix`; @@ -26,7 +24,7 @@ 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"); @@ -59,17 +57,17 @@ try { 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) { } @@ -148,7 +146,7 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(passwordPlain), - { name: "PBKDF2" }, + {name: "PBKDF2"}, false, ["deriveKey"] ); @@ -160,7 +158,7 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { hash: "SHA-256" }, keyMaterial, - { name: "AES-GCM", length: 256 }, + {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"] ); @@ -168,11 +166,11 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { async function encryptJsonWithPassword(jsonObj, passwordPlain) { const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); + 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 ciphertextBuf = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext); const ciphertext = new Uint8Array(ciphertextBuf); return JSON.stringify({ @@ -195,7 +193,7 @@ async function decryptJsonWithPassword(envelopeJson, passwordPlain) { 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); + const plainBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } @@ -209,7 +207,7 @@ async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = 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 }, + {name: "HKDF", hash: "SHA-256", salt, info}, keyMaterial, length * 8 ); @@ -217,14 +215,14 @@ async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = } async function importAesGcmKey(keyBytes) { - return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); + 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 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); @@ -236,7 +234,7 @@ async function decryptAesGcmFromBase64(cipherBase64, keyBytes) { 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); + const ptBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct); return new TextDecoder().decode(ptBuf); } @@ -704,7 +702,7 @@ function clearAction(actionid) { notif.addEventListener('transitionend', async () => { notif.remove(); - }, { once: true }); + }, {once: true}); setTimeout(() => notif.remove(), 300); }); } @@ -744,6 +742,7 @@ function getInitials(name) { //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++) { @@ -755,6 +754,7 @@ function stringToColor(str) { return `hsl(${h}, ${s}%, ${l}%)`; } + function createAvatarSvg(name, size = 512) { const initials = getInitials(name); const color = stringToColor(name); @@ -777,17 +777,14 @@ function createAvatarSvg(name, size = 512) { return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } -async function getAvatarUrl(id, username) -{ +async function getAvatarUrl(id, username) { try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; - if ((await fetchAsync(pfpUrl)) == "") - { + if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } return pfpUrl; - } - catch (e) { + } catch (e) { return createAvatarSvg(username); } } @@ -797,10 +794,10 @@ function updateLoadingStatus(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); @@ -810,6 +807,7 @@ async function loadingFadeOut() { mainScreen.style.transform = ""; mainScreen.style.opacity = ""; } + async function loadingFadeIn() { loadingScreen.style.transform = "scale(0.85)"; @@ -831,33 +829,32 @@ 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++) { + 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(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } - + } } @@ -881,7 +878,7 @@ function processBlah(blahmessage) { blahmessage = `:${blahmessage}`; prepended = true; } - + let split = splitLimit(blahmessage, ":", 3); let values = []; @@ -891,14 +888,14 @@ function processBlah(blahmessage) { } } 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}, `; + valueslist += `${value}, `; message = message.replaceAll(`{${i}}`, value); } if (values.length > 0) { @@ -908,9 +905,7 @@ function processBlah(blahmessage) { message = message.replaceAll('{all}', valueslist); } return message; - } - catch (e) - { + } catch (e) { if (prepended) { return blahmessage.substring(1); } @@ -962,7 +957,7 @@ async function generateUserKeysBundle() { privX25519: uint8ToBase64(privX25519), privMlKem: uint8ToBase64(privMlKem) }; - return { pub, priv }; + return {pub, priv}; } async function ensureUserKeys() { @@ -1060,7 +1055,7 @@ async function ensureUserKeys() { async function mainJS() { await initBlahs(); - + passwordHash = await hashSHA3_512(password); if (localStorage.getItem('lang') != null) { lang = localStorage.getItem('lang'); @@ -1082,6 +1077,7 @@ async function mainJS() { await start(); } + id = localStorage.getItem('id'); username = localStorage.getItem('username'); password = localStorage.getItem('password'); @@ -1104,8 +1100,7 @@ function showFixedContextMenu(rect, html) { let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1114,10 +1109,31 @@ function showFixedContextMenu(rect, html) { fixedContextMenu.innerHTML = doc.body.innerHTML; - fixedContextMenu.style.left = `${rect.right + 10}px`; - fixedContextMenu.style.top = `${rect.top}px`; + 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; @@ -1127,20 +1143,20 @@ 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"); @@ -1156,8 +1172,7 @@ async function switchRoomsBar(title, content) { let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1171,25 +1186,26 @@ async function switchRoomsBar(title, content) { roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; } + var currentRoomContentTitle = null; var currentRoomContentHtml = null; -async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) -{ + +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'); - } + 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"; @@ -1203,13 +1219,11 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); } - } - else - { + } else { await delay(200); } - - + + let detailsBtnHtml = showRoomBar ? detailsBtn : ''; roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}${processBlah(title)}
${detailsBtnHtml}`; @@ -1229,8 +1243,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1244,16 +1257,17 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; - + } + var currentDetailsContentTitle = null; var currentDetailsContentHtml = null; -async function switchDetailsContent(title, content) -{ + +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)"; @@ -1284,8 +1298,7 @@ async function switchDetailsContent(title, content) let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1301,8 +1314,8 @@ async function switchDetailsContent(title, content) } -function clickCollapseDms() -{ + +function clickCollapseDms() { var collapseDmsBtn = document.getElementById("collapse-dms"); collapseDmsBtn.classList.toggle("collapsed"); var dmsWrapper = document.getElementById("dms-wrapper"); @@ -1314,8 +1327,8 @@ function clickCollapseDms() } } } -function clickCollapseGroups() -{ + +function clickCollapseGroups() { var collapseGroupsBtn = document.getElementById("collapse-groups"); collapseGroupsBtn.classList.toggle("collapsed"); var groupsWrapper = document.getElementById("groups-wrapper"); @@ -1327,12 +1340,12 @@ function clickCollapseGroups() } } } -function clickAddGroup() -{ + +function clickAddGroup() { switchRoomContent("title.create.group", createGroupScreen, false); } -function clickAddDm() -{ + +function clickAddDm() { switchRoomContent("title.create.dm", addDmScreen, false); } @@ -1353,12 +1366,20 @@ sidebarAddButton.addEventListener("click", (e) => { } }); +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); } @@ -1378,16 +1399,15 @@ window.addEventListener("popstate", (e) => { popstate(); }); -const { App } = window.Capacitor?.Plugins || {}; +const {App} = window.Capacitor?.Plugins || {}; if (App) { App.addListener('backButton', () => { popstate(); }); } -function popstate() -{ - if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) - { + +function popstate() { + if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) { history.back(); if (App) { App.minimizeApp(); @@ -1398,8 +1418,9 @@ function popstate() function gotoSideProfilePopup() { - + } + function setActiveRoombarItem(itemId) { let items = document.querySelectorAll('.collapse-text-button'); items.forEach(item => item.classList.remove('selected')); @@ -1415,7 +1436,7 @@ async function renderInvites(res, type) { if (!container) return; if (res && res != "") { - + res = JSON.parse(res); let invites = [ @@ -1431,18 +1452,18 @@ async function renderInvites(res, type) { ...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}`; } @@ -1502,7 +1523,7 @@ async function renderInvites(res, type) { } } -window.cachedInvites = { 'received': null, 'sent': null }; +window.cachedInvites = {'received': null, 'sent': null}; async function loadInvites(tab) { try { @@ -1529,6 +1550,7 @@ async function switchInvitesTab(tab) { await loadInvites(tab); } + async function acceptInvite(targetId) { //TODO: Implement key generation try { showAction("action.invite.accepting", "invite.action"); @@ -1569,7 +1591,7 @@ async function acceptInvite(targetId) { //TODO: Implement key generation } } -async function declineInvite(username) { +async function declineInvite(username) { try { showAction("action.invite.declining", "invite.action"); let res = await fetchEncrypted("user/dm/invite/decline", username); @@ -1582,7 +1604,7 @@ async function declineInvite(username) { } } -async function revokeInvite(username) { +async function revokeInvite(username) { try { showAction("action.invite.revoking", "invite.action"); let res = await fetchEncrypted("user/dm/invite/revoke", username); @@ -1606,7 +1628,7 @@ function switchNotifisTab(tab) { async function gotoInbox() { if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar setActiveSidebarIndicator(sidebarInboxIndicator); - + await switchRoomsBar("title.inbox", inboxRoomBar); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); @@ -1663,8 +1685,9 @@ function setActiveSidebarIndicator(element, isTemporary = false) { currentMainIndicator = element; } } + function mobileNavBack() { - if(mainScreen.classList.contains('mobile-details')) { + if (mainScreen.classList.contains('mobile-details')) { mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); @@ -1675,12 +1698,13 @@ function mobileNavBack() { roomsBarContainer.style.display = ""; //restore roombar on mobile void roomsBarContainer.offsetWidth; } - + mainScreen.classList.remove('mobile-content'); setActiveSidebarIndicator(currentMainIndicator); } } + function mobileNavDetails() { mainScreen.classList.add('mobile-details'); } @@ -1701,7 +1725,7 @@ document.addEventListener('touchstart', e => { if (activeTouchButton) { activeTouchButton.classList.add('is-pressed'); } -}, { passive: true }); +}, {passive: true}); document.addEventListener('touchmove', e => { if (!touchMoved) { @@ -1711,13 +1735,17 @@ document.addEventListener('touchmove', e => { 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 }); +}, {passive: true}); document.addEventListener('touchend', e => { touchEndX = e.changedTouches[0].screenX; @@ -1732,7 +1760,7 @@ document.addEventListener('touchend', e => { if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { document.activeElement.blur(); } - + if (e.cancelable) { e.preventDefault(); } @@ -1757,6 +1785,7 @@ document.addEventListener('contextmenu', e => { e.preventDefault(); } }); + function handleMobileSwipe() { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 52 * rem) return; @@ -1783,9 +1812,9 @@ async function renderDms(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)); + dmEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0)); let html = ""; let count = 0; @@ -1794,10 +1823,10 @@ async function renderDms(res) { 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]; @@ -1812,7 +1841,7 @@ async function renderDms(res) { `; count++; } - + if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); @@ -1842,23 +1871,36 @@ var loadedMessages = {}; var oldestLoadedMsgId = null; var isLoadingOlderMessages = false; + + + +function escapeHtml(unsafe) { + if (!unsafe) return ""; + return unsafe + .replaceAll(/&/g, "&") + .replaceAll(//g, ">") + .replaceAll(/"/g, """) + .replaceAll(/'/g, "'"); +} + async function openDm(dmId, username, targetId) { try { showAction("action.dm.opening", "dmopen"); - + currentDmKey = await ensureDmRoomKey(dmId); if (!currentDmKey) { - throw new Error("Missing DM room key"); + throw new Error("Missing DM room key"); } currentDmId = dmId; loadedMessages = {}; oldestLoadedMsgId = null; isLoadingOlderMessages = false; - + let pfp = await getAvatarUrl(targetId, username); let iconHtml = ``; await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml); - + let msgContainer = document.getElementById("chat-messages"); if (msgContainer) { msgContainer.innerHTML = `
desc.messages.loading
`; @@ -1867,10 +1909,10 @@ async function openDm(dmId, username, targetId) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } - + await loadDmMessages(dmId); setupChatScrollListener(); - + if (dmMessagePollInterval) clearInterval(dmMessagePollInterval); dmMessagePollInterval = setInterval(() => { if (currentDmId === dmId) { @@ -1879,7 +1921,7 @@ async function openDm(dmId, username, targetId) { clearInterval(dmMessagePollInterval); } }, 15000); - + setActiveRoombarItem(`dm-btn-${dmId}`); clearAction("dmopen"); } catch (e) { @@ -1895,14 +1937,14 @@ async function openDm(dmId, username, targetId) { async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { try { - let payload = JSON.stringify({ string1: dmId, string2: "50", string3: startOffset }); + 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) { @@ -1926,37 +1968,47 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { 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 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); - } catch(e) { + if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes); + decrypted = true; + } catch (e) { content = `error:messages.decrypt.failed`; + decrypted = false; } + } else { + decrypted = true; + } + + if (decrypted) { + content = escapeHtml(content); } content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => { return processBlah(p1); }); - + // Also still process the whole thing in case it's just "dm.begin.notice" without {blah()} content = processBlah(content); - + if (msg.type === "larp.info") { html += `
${content}
`; lastAuthor = null; } else { let showAvatar = lastAuthor !== msg.author; lastAuthor = msg.author; - + let authorName = "Unknown"; let pfp = "assets/default_avatar.png"; if (msg.author !== "0") { @@ -1967,32 +2019,149 @@ async function renderMessages(messages, isPrepend = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername); } } + if (dmUsernameCache[msg.author] && !dmPfpCache[msg.author]) { + dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); + } if (dmUsernameCache[msg.author]) { authorName = dmUsernameCache[msg.author].split(':')[0]; pfp = dmPfpCache[msg.author]; } } - + let date = new Date(parseInt(msg.timestamp)); let timeStr = date.toLocaleString(); - + let extraClass = showAvatar ? "with-avatar" : ""; - + + let repliedHtml = ""; + if (msg.responded && msg.responded !== "") { + let respondedId = msg.responded; + if (typeof respondedId === 'number') respondedId = respondedId.toString(); + let respondedAuthorId = null; + if (typeof respondedId === 'string' && respondedId.includes(':')) { + let parts = respondedId.split(':'); + respondedId = parts[0]; + respondedAuthorId = parts[1]; + } + + let replyAuthor = "Someone"; + 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 = "Someone"; + setTimeout(async () => { + try { + let aId = respondedAuthorId; + if (!aId) { + let payload = JSON.stringify({ + string1: currentDmId, + string2: "1", + string3: respondedId + }); + let res = await fetchEncrypted("dm/messages/get", payload); + if (res && !res.startsWith("error:")) { + let msgs = JSON.parse(res); + if (msgs[respondedId] && msgs[respondedId].author !== "0") { + aId = msgs[respondedId].author; + } + } + } + if (aId) { + let fname = dmUsernameCache[aId]; + if (!fname) { + fname = await fetchAsync(`${url}/idtoname?id=${aId}`); + if (fname && !fname.startsWith("error:")) dmUsernameCache[aId] = fname; + } + if (fname && !fname.startsWith("error:")) { + let sName = fname.split(':')[0]; + window.replyAuthorCache[respondedId] = sName; + let el = document.getElementById(randId); + if (el) { + el.innerHTML = ` ${processBlah('title.replied.to')} ${sName}`; + } + } + } + } catch (e) { + } + }, 10); + } + + let divIdStr = randId !== "" ? `id="${randId}" ` : ""; + repliedHtml = `
${processBlah('title.replied.to')} ${replyAuthor}
`; + } + + let reactionsHtml = ""; + if (reactions) { + try { + let rxArr = JSON.parse(reactions); + if (rxArr.length > 0) { + let rxCounts = {}; + for (let rx of rxArr) { + let parts = rx.split(':'); + if (parts.length < 2) continue; + let rxVal = parts.slice(1).join(':'); + if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false}; + rxCounts[rxVal].count++; + if (parts[0] === id) rxCounts[rxVal].me = true; + } + + let rxKeys = Object.keys(rxCounts); + if (rxKeys.length > 0) { + reactionsHtml = `
`; + for (let rxVal of rxKeys) { + let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);"; + let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); + let escapedRxVal = escapeHtml(rxVal); + reactionsHtml += `${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}`; + } + reactionsHtml += `
`; + } + } + } catch (e) { + } + } + html += ` -
+
${showAvatar ? `` : `
`} -
+
${showAvatar ? `
${authorName} ${timeStr}
` : ""} + ${repliedHtml}
${content}
+ ${reactionsHtml}
`; } } - + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem); if (window.forceScrollToBottom) { @@ -2001,9 +2170,9 @@ async function renderMessages(messages, isPrepend = false) { } let oldScrollTop = container.scrollTop; let oldScrollHeight = container.scrollHeight; - + container.innerHTML = html; - + if (wasAtBottom) { container.scrollTop = container.scrollHeight; } else if (isPrepend) { @@ -2013,55 +2182,108 @@ async function renderMessages(messages, isPrepend = false) { } } +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) { + ignoreScroll = true; + container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll; + requestAnimationFrame(() => { + ignoreScroll = false; + }); + await delay(10); + } +}); 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 (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) { } - catch (e) {} clearAction("messages.loading.older"); } }; } + + +let replyingToMsgId = null; + async function sendMessage() { if (!currentDmId || !currentDmKey) return; - + let input = document.getElementById("chat-input"); let content = input.value.trim(); if (!content) return; - + + let pendingMsgId = "pending_" + Date.now(); + let container = document.getElementById("chat-messages"); + if (container) { + let html = ` +
+
+
+
${content} (${processBlah("info.sending.message")})
+
+
+ `; + container.insertAdjacentHTML('beforeend', html); + container.scrollTop = container.scrollHeight; + } + try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); - + let msgPayload = { string1: currentDmId, string2: encryptedContent, - string3: "" + string3: "", + string4: replyingToMsgId || "" }; - + input.value = ""; input.style.height = '2.5rem'; - + window.forceScrollToBottom = true; - + replyingToMsgId = null; + let bar = document.getElementById("replying-bar"); + if (bar) bar.style.display = "none"; + + showAction("action.message.sending", "msgsending"); + let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload)); + clearAction("msgsending"); if (res && res.startsWith("error:")) { showBlahNotification(res); - } else { - await loadDmMessages(currentDmId); + let pElem = document.getElementById(pendingMsgId); + if (pElem) pElem.remove(); } } catch (e) { + clearAction("msgsending"); console.error(e); showBlahNotification("error:message.send.failed"); + let pElem = document.getElementById(pendingMsgId); + if (pElem) pElem.remove(); } } @@ -2090,20 +2312,20 @@ let appWebSocket = null; function setupWebSocket() { if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) return; - + let wsUrl = url.replace(/^http/, "ws") + "/ws"; appWebSocket = new WebSocket(wsUrl); - + appWebSocket.onopen = async () => { try { let nonce = await getNonce(id, passwordHash); let secretEnc = await encryptWithNonce(passwordHash, passwordHash, nonce); - appWebSocket.send(JSON.stringify({ string1: id, string2: secretEnc })); + appWebSocket.send(JSON.stringify({string1: id, string2: secretEnc})); } catch (e) { console.error("WS auth failed", e); } }; - + appWebSocket.onmessage = (event) => { let data = event.data; if (data.startsWith("dm_message:")) { @@ -2113,8 +2335,217 @@ function setupWebSocket() { } } }; - + appWebSocket.onclose = () => { setTimeout(setupWebSocket, 5000); }; +} + +let currentContextMenuMsgId = null; +let touchTimeout = null; +let touchTarget = null; + +function handleMessageContextMenu(e, msgId) { + e.preventDefault(); + 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"); + if (delBtn) { + if (loadedMessages[msgId] && loadedMessages[msgId].author !== id) { + delBtn.style.display = "none"; + } else { + delBtn.style.display = ""; + } + } +} + +function handleMessageTouchStart(e, msgId) { + touchTarget = e.target; + touchTimeout = setTimeout(() => { + if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); + currentContextMenuMsgId = msgId; + let elem = document.getElementById(`msg-${msgId}`); + if (elem) elem.classList.add("context-menu-open"); + + let touch = e.touches[0]; + showFixedContextMenu({ + top: touch.clientY, + right: touch.clientX, + bottom: touch.clientY, + left: touch.clientX + }, messageContextMenu); + let delBtn = fixedContextMenu.querySelector("#context-delete-btn"); + if (delBtn) { + if (loadedMessages[msgId] && loadedMessages[msgId].author !== id) { + delBtn.style.display = "none"; + } else { + delBtn.style.display = ""; + } + } + }, 500); +} + +function handleMessageTouchEnd() { + if (touchTimeout) clearTimeout(touchTimeout); +} + +function handleMessageTouchMove() { + if (touchTimeout) clearTimeout(touchTimeout); +} + +async function deleteMessage(msgId) { + if (fixedContextMenu) fixedContextMenu.classList.remove("show"); + 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.sending.message", "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"); + + 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 = "Someone"; + 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(); +} + +async function reactMessagePrompt(msgId, quickReaction = null) { + if (fixedContextMenu) fixedContextMenu.classList.remove("show"); + let reaction = quickReaction; + if (!reaction) { + reaction = prompt("Enter reaction (emoji or text):"); + if (!reaction) return; + } else { + reaction = decodeURIComponent(reaction); + } + + showAction("action.message.reacting", "msgreact"); + try { + let currentMsg = loadedMessages[msgId]; + 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 msgPayload = { + string1: currentDmId, + string2: msgId, + string3: encryptedReactions + }; + let res = await fetchEncrypted("dm/message/react", JSON.stringify(msgPayload)); + if (res && res.startsWith("error:")) { + showBlahNotification(res); + } + } catch (e) { + showBlahNotification("error:message.react.failed"); + } + clearAction("msgreact"); } \ No newline at end of file diff --git a/android/app/src/main/assets/public/screens.js b/android/app/src/main/assets/public/screens.js index 3e16f55f..76dff8f4 100644 --- a/android/app/src/main/assets/public/screens.js +++ b/android/app/src/main/assets/public/screens.js @@ -133,6 +133,14 @@ var chatScreen = `
+
`; +var messageContextMenu = ` + + + +`; //elements var detailsBtn = ``; diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index f41d515b..ebd1bda1 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -630,7 +630,18 @@ space{ .chat-message { display: flex; gap: 1rem; - padding: 0.2rem 0; + padding: 0.4rem 0.6rem; + margin: 0 -0.6rem; + border-radius: 0.8rem; + transition: background-color 0.15s ease; +} +@media (hover: hover) { + .chat-message:hover { + background-color: rgba(255, 255, 255, 0.04); + } +} +.chat-message:active, .chat-message.context-menu-open { + background-color: rgba(255, 255, 255, 0.04); } .chat-message.with-avatar { margin-top: 0.8rem; diff --git a/webroot/blah/en-cat.json b/webroot/blah/en-cat.json index 75fcf968..11bc708c 100644 --- a/webroot/blah/en-cat.json +++ b/webroot/blah/en-cat.json @@ -120,7 +120,6 @@ "title.back.to.register": "back to registration", "placeholder.username": "cat name", "placeholder.captcha.code": "captcha code", - "placeholder.message.input": "meow...", "desc.messages.loading": "loading meows...", "desc.no.dms": "no direct meowchats :c", "action.dm.opening": "opening meowchat...", @@ -128,5 +127,18 @@ "keys.local.server.mismatch": "this device has different nametags than server :c use the device u first logged in on", "keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c", "dm.messages.fetch.failed": "failed to fetch meows :c", - "messages.decrypt.failed": "failed to decrypt meow :c" + "messages.decrypt.failed": "failed to decrypt meow :c", + "title.reply.message": "meow back", + "title.react.message": "purr", + "title.delete.message": "hiss away", + "title.replied.to": "meowed back to", + "action.message.deleting": "hissing message away...", + "action.message.sending": "sending meow...", + "action.message.reacting": "adding purr...", + "error:message.delete.failed": "failed to hiss away meow", + "error:message.send.failed": "failed to send meow", + "error:message.react.failed": "failed to add purr", + "info.sending.message": "sending...", + "placeholder.message.input": "meow...", + "larp.redacted": "this meow was hissed away." } diff --git a/webroot/blah/en-us.json b/webroot/blah/en-us.json index 104354bb..6b1710ad 100644 --- a/webroot/blah/en-us.json +++ b/webroot/blah/en-us.json @@ -127,5 +127,17 @@ "keys.local.server.mismatch": "This device has different encryption keys than the server. Use the device where you first logged in, or restore server keys from backup.", "keys.server.decrypt.failed": "Could not decrypt your account keys with this password.", "dm.messages.fetch.failed": "Failed to fetch messages", - "messages.decrypt.failed": "Failed to decrypt message" + "messages.decrypt.failed": "Failed to decrypt message", + "title.reply.message": "Reply", + "title.react.message": "React", + "title.delete.message": "Delete", + "title.replied.to": "Replied to", + "action.message.deleting": "Deleting message...", + "action.message.sending": "Sending message...", + "action.message.reacting": "Adding reaction...", + "error:message.delete.failed": "Failed to delete message", + "error:message.send.failed": "Failed to send message", + "error:message.react.failed": "Failed to add reaction", + "info.sending.message": "Sending...", + "larp.redacted": "This message was deleted." } diff --git a/webroot/main.js b/webroot/main.js index 27f760e6..a1bc9de9 100644 --- a/webroot/main.js +++ b/webroot/main.js @@ -1,18 +1,16 @@ var prot = window.location.protocol; -async function updateProtocolAndUrl(host) -{ +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) { + } catch (error) { try { JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`)); prot = "https:"; + } catch (error) { } - catch (error) {} } } url = `${prot}//${host}/_larpix`; @@ -26,7 +24,7 @@ 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"); @@ -59,17 +57,17 @@ try { 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) { } @@ -148,7 +146,7 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(passwordPlain), - { name: "PBKDF2" }, + {name: "PBKDF2"}, false, ["deriveKey"] ); @@ -160,7 +158,7 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { hash: "SHA-256" }, keyMaterial, - { name: "AES-GCM", length: 256 }, + {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"] ); @@ -168,11 +166,11 @@ async function deriveAesGcmKeyFromPassword(passwordPlain, salt, iterations) { async function encryptJsonWithPassword(jsonObj, passwordPlain) { const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); + 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 ciphertextBuf = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext); const ciphertext = new Uint8Array(ciphertextBuf); return JSON.stringify({ @@ -195,7 +193,7 @@ async function decryptJsonWithPassword(envelopeJson, passwordPlain) { 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); + const plainBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct); return JSON.parse(new TextDecoder().decode(plainBuf)); } @@ -209,7 +207,7 @@ async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = 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 }, + {name: "HKDF", hash: "SHA-256", salt, info}, keyMaterial, length * 8 ); @@ -217,14 +215,14 @@ async function hkdfSha256Bytes(ikmBytes, infoString, saltBytes = null, length = } async function importAesGcmKey(keyBytes) { - return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); + 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 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); @@ -236,7 +234,7 @@ async function decryptAesGcmFromBase64(cipherBase64, keyBytes) { 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); + const ptBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct); return new TextDecoder().decode(ptBuf); } @@ -704,7 +702,7 @@ function clearAction(actionid) { notif.addEventListener('transitionend', async () => { notif.remove(); - }, { once: true }); + }, {once: true}); setTimeout(() => notif.remove(), 300); }); } @@ -744,6 +742,7 @@ function getInitials(name) { //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++) { @@ -755,6 +754,7 @@ function stringToColor(str) { return `hsl(${h}, ${s}%, ${l}%)`; } + function createAvatarSvg(name, size = 512) { const initials = getInitials(name); const color = stringToColor(name); @@ -777,17 +777,14 @@ function createAvatarSvg(name, size = 512) { return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } -async function getAvatarUrl(id, username) -{ +async function getAvatarUrl(id, username) { try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; - if ((await fetchAsync(pfpUrl)) == "") - { + if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } return pfpUrl; - } - catch (e) { + } catch (e) { return createAvatarSvg(username); } } @@ -797,10 +794,10 @@ function updateLoadingStatus(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); @@ -810,6 +807,7 @@ async function loadingFadeOut() { mainScreen.style.transform = ""; mainScreen.style.opacity = ""; } + async function loadingFadeIn() { loadingScreen.style.transform = "scale(0.85)"; @@ -831,33 +829,32 @@ 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++) { + 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(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } - + } } @@ -881,7 +878,7 @@ function processBlah(blahmessage) { blahmessage = `:${blahmessage}`; prepended = true; } - + let split = splitLimit(blahmessage, ":", 3); let values = []; @@ -891,14 +888,14 @@ function processBlah(blahmessage) { } } 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}, `; + valueslist += `${value}, `; message = message.replaceAll(`{${i}}`, value); } if (values.length > 0) { @@ -908,9 +905,7 @@ function processBlah(blahmessage) { message = message.replaceAll('{all}', valueslist); } return message; - } - catch (e) - { + } catch (e) { if (prepended) { return blahmessage.substring(1); } @@ -962,7 +957,7 @@ async function generateUserKeysBundle() { privX25519: uint8ToBase64(privX25519), privMlKem: uint8ToBase64(privMlKem) }; - return { pub, priv }; + return {pub, priv}; } async function ensureUserKeys() { @@ -1060,7 +1055,7 @@ async function ensureUserKeys() { async function mainJS() { await initBlahs(); - + passwordHash = await hashSHA3_512(password); if (localStorage.getItem('lang') != null) { lang = localStorage.getItem('lang'); @@ -1082,6 +1077,7 @@ async function mainJS() { await start(); } + id = localStorage.getItem('id'); username = localStorage.getItem('username'); password = localStorage.getItem('password'); @@ -1104,8 +1100,7 @@ function showFixedContextMenu(rect, html) { let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1114,10 +1109,31 @@ function showFixedContextMenu(rect, html) { fixedContextMenu.innerHTML = doc.body.innerHTML; - fixedContextMenu.style.left = `${rect.right + 10}px`; - fixedContextMenu.style.top = `${rect.top}px`; + 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; @@ -1127,20 +1143,20 @@ 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"); @@ -1156,8 +1172,7 @@ async function switchRoomsBar(title, content) { let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1171,25 +1186,26 @@ async function switchRoomsBar(title, content) { roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; } + var currentRoomContentTitle = null; var currentRoomContentHtml = null; -async function switchRoomContent(title, content, showRoomBar, icon = "", skipMobileSlide = false) -{ + +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'); - } + 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"; @@ -1203,13 +1219,11 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); } - } - else - { + } else { await delay(200); } - - + + let detailsBtnHtml = showRoomBar ? detailsBtn : ''; roomsTopBarTransition.innerHTML = `${backBtnHtml}${icon}${processBlah(title)}
${detailsBtnHtml}`; @@ -1229,8 +1243,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1244,16 +1257,17 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.opacity = ""; - + } + var currentDetailsContentTitle = null; var currentDetailsContentHtml = null; -async function switchDetailsContent(title, content) -{ + +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)"; @@ -1284,8 +1298,7 @@ async function switchDetailsContent(title, content) let placeholders = doc.querySelectorAll("[placeholder]"); for (let i = 0; i < placeholders.length; i++) { - if (placeholders[i].placeholder.startsWith("{blah(")) - { + if (placeholders[i].placeholder.startsWith("{blah(")) { let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0]; placeholders[i].placeholder = processBlah(value); } @@ -1301,8 +1314,8 @@ async function switchDetailsContent(title, content) } -function clickCollapseDms() -{ + +function clickCollapseDms() { var collapseDmsBtn = document.getElementById("collapse-dms"); collapseDmsBtn.classList.toggle("collapsed"); var dmsWrapper = document.getElementById("dms-wrapper"); @@ -1314,8 +1327,8 @@ function clickCollapseDms() } } } -function clickCollapseGroups() -{ + +function clickCollapseGroups() { var collapseGroupsBtn = document.getElementById("collapse-groups"); collapseGroupsBtn.classList.toggle("collapsed"); var groupsWrapper = document.getElementById("groups-wrapper"); @@ -1327,12 +1340,12 @@ function clickCollapseGroups() } } } -function clickAddGroup() -{ + +function clickAddGroup() { switchRoomContent("title.create.group", createGroupScreen, false); } -function clickAddDm() -{ + +function clickAddDm() { switchRoomContent("title.create.dm", addDmScreen, false); } @@ -1353,12 +1366,20 @@ sidebarAddButton.addEventListener("click", (e) => { } }); +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); } @@ -1378,16 +1399,15 @@ window.addEventListener("popstate", (e) => { popstate(); }); -const { App } = window.Capacitor?.Plugins || {}; +const {App} = window.Capacitor?.Plugins || {}; if (App) { App.addListener('backButton', () => { popstate(); }); } -function popstate() -{ - if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) - { + +function popstate() { + if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) { history.back(); if (App) { App.minimizeApp(); @@ -1398,8 +1418,9 @@ function popstate() function gotoSideProfilePopup() { - + } + function setActiveRoombarItem(itemId) { let items = document.querySelectorAll('.collapse-text-button'); items.forEach(item => item.classList.remove('selected')); @@ -1415,7 +1436,7 @@ async function renderInvites(res, type) { if (!container) return; if (res && res != "") { - + res = JSON.parse(res); let invites = [ @@ -1431,18 +1452,18 @@ async function renderInvites(res, type) { ...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}`; } @@ -1502,7 +1523,7 @@ async function renderInvites(res, type) { } } -window.cachedInvites = { 'received': null, 'sent': null }; +window.cachedInvites = {'received': null, 'sent': null}; async function loadInvites(tab) { try { @@ -1529,6 +1550,7 @@ async function switchInvitesTab(tab) { await loadInvites(tab); } + async function acceptInvite(targetId) { //TODO: Implement key generation try { showAction("action.invite.accepting", "invite.action"); @@ -1569,7 +1591,7 @@ async function acceptInvite(targetId) { //TODO: Implement key generation } } -async function declineInvite(username) { +async function declineInvite(username) { try { showAction("action.invite.declining", "invite.action"); let res = await fetchEncrypted("user/dm/invite/decline", username); @@ -1582,7 +1604,7 @@ async function declineInvite(username) { } } -async function revokeInvite(username) { +async function revokeInvite(username) { try { showAction("action.invite.revoking", "invite.action"); let res = await fetchEncrypted("user/dm/invite/revoke", username); @@ -1606,7 +1628,7 @@ function switchNotifisTab(tab) { async function gotoInbox() { if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar setActiveSidebarIndicator(sidebarInboxIndicator); - + await switchRoomsBar("title.inbox", inboxRoomBar); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); @@ -1663,8 +1685,9 @@ function setActiveSidebarIndicator(element, isTemporary = false) { currentMainIndicator = element; } } + function mobileNavBack() { - if(mainScreen.classList.contains('mobile-details')) { + if (mainScreen.classList.contains('mobile-details')) { mainScreen.classList.remove('mobile-details'); mainScreen.classList.add('mobile-content'); @@ -1675,12 +1698,13 @@ function mobileNavBack() { roomsBarContainer.style.display = ""; //restore roombar on mobile void roomsBarContainer.offsetWidth; } - + mainScreen.classList.remove('mobile-content'); setActiveSidebarIndicator(currentMainIndicator); } } + function mobileNavDetails() { mainScreen.classList.add('mobile-details'); } @@ -1701,7 +1725,7 @@ document.addEventListener('touchstart', e => { if (activeTouchButton) { activeTouchButton.classList.add('is-pressed'); } -}, { passive: true }); +}, {passive: true}); document.addEventListener('touchmove', e => { if (!touchMoved) { @@ -1711,13 +1735,17 @@ document.addEventListener('touchmove', e => { 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 }); +}, {passive: true}); document.addEventListener('touchend', e => { touchEndX = e.changedTouches[0].screenX; @@ -1732,7 +1760,7 @@ document.addEventListener('touchend', e => { if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) { document.activeElement.blur(); } - + if (e.cancelable) { e.preventDefault(); } @@ -1757,6 +1785,7 @@ document.addEventListener('contextmenu', e => { e.preventDefault(); } }); + function handleMobileSwipe() { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 52 * rem) return; @@ -1783,9 +1812,9 @@ async function renderDms(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)); + dmEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0)); let html = ""; let count = 0; @@ -1794,10 +1823,10 @@ async function renderDms(res) { 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]; @@ -1812,7 +1841,7 @@ async function renderDms(res) { `; count++; } - + if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); @@ -1842,23 +1871,36 @@ var loadedMessages = {}; var oldestLoadedMsgId = null; var isLoadingOlderMessages = false; + + + +function escapeHtml(unsafe) { + if (!unsafe) return ""; + return unsafe + .replaceAll(/&/g, "&") + .replaceAll(//g, ">") + .replaceAll(/"/g, """) + .replaceAll(/'/g, "'"); +} + async function openDm(dmId, username, targetId) { try { showAction("action.dm.opening", "dmopen"); - + currentDmKey = await ensureDmRoomKey(dmId); if (!currentDmKey) { - throw new Error("Missing DM room key"); + throw new Error("Missing DM room key"); } currentDmId = dmId; loadedMessages = {}; oldestLoadedMsgId = null; isLoadingOlderMessages = false; - + let pfp = await getAvatarUrl(targetId, username); let iconHtml = ``; await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml); - + let msgContainer = document.getElementById("chat-messages"); if (msgContainer) { msgContainer.innerHTML = `
desc.messages.loading
`; @@ -1867,10 +1909,10 @@ async function openDm(dmId, username, targetId) { blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML); } } - + await loadDmMessages(dmId); setupChatScrollListener(); - + if (dmMessagePollInterval) clearInterval(dmMessagePollInterval); dmMessagePollInterval = setInterval(() => { if (currentDmId === dmId) { @@ -1879,7 +1921,7 @@ async function openDm(dmId, username, targetId) { clearInterval(dmMessagePollInterval); } }, 15000); - + setActiveRoombarItem(`dm-btn-${dmId}`); clearAction("dmopen"); } catch (e) { @@ -1895,14 +1937,14 @@ async function openDm(dmId, username, targetId) { async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { try { - let payload = JSON.stringify({ string1: dmId, string2: "50", string3: startOffset }); + 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) { @@ -1926,37 +1968,47 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) { 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 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); - } catch(e) { + if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes); + decrypted = true; + } catch (e) { content = `error:messages.decrypt.failed`; + decrypted = false; } + } else { + decrypted = true; + } + + if (decrypted) { + content = escapeHtml(content); } content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => { return processBlah(p1); }); - + // Also still process the whole thing in case it's just "dm.begin.notice" without {blah()} content = processBlah(content); - + if (msg.type === "larp.info") { html += `
${content}
`; lastAuthor = null; } else { let showAvatar = lastAuthor !== msg.author; lastAuthor = msg.author; - + let authorName = "Unknown"; let pfp = "assets/default_avatar.png"; if (msg.author !== "0") { @@ -1967,32 +2019,149 @@ async function renderMessages(messages, isPrepend = false) { dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername); } } + if (dmUsernameCache[msg.author] && !dmPfpCache[msg.author]) { + dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); + } if (dmUsernameCache[msg.author]) { authorName = dmUsernameCache[msg.author].split(':')[0]; pfp = dmPfpCache[msg.author]; } } - + let date = new Date(parseInt(msg.timestamp)); let timeStr = date.toLocaleString(); - + let extraClass = showAvatar ? "with-avatar" : ""; - + + let repliedHtml = ""; + if (msg.responded && msg.responded !== "") { + let respondedId = msg.responded; + if (typeof respondedId === 'number') respondedId = respondedId.toString(); + let respondedAuthorId = null; + if (typeof respondedId === 'string' && respondedId.includes(':')) { + let parts = respondedId.split(':'); + respondedId = parts[0]; + respondedAuthorId = parts[1]; + } + + let replyAuthor = "Someone"; + 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 = "Someone"; + setTimeout(async () => { + try { + let aId = respondedAuthorId; + if (!aId) { + let payload = JSON.stringify({ + string1: currentDmId, + string2: "1", + string3: respondedId + }); + let res = await fetchEncrypted("dm/messages/get", payload); + if (res && !res.startsWith("error:")) { + let msgs = JSON.parse(res); + if (msgs[respondedId] && msgs[respondedId].author !== "0") { + aId = msgs[respondedId].author; + } + } + } + if (aId) { + let fname = dmUsernameCache[aId]; + if (!fname) { + fname = await fetchAsync(`${url}/idtoname?id=${aId}`); + if (fname && !fname.startsWith("error:")) dmUsernameCache[aId] = fname; + } + if (fname && !fname.startsWith("error:")) { + let sName = fname.split(':')[0]; + window.replyAuthorCache[respondedId] = sName; + let el = document.getElementById(randId); + if (el) { + el.innerHTML = ` ${processBlah('title.replied.to')} ${sName}`; + } + } + } + } catch (e) { + } + }, 10); + } + + let divIdStr = randId !== "" ? `id="${randId}" ` : ""; + repliedHtml = `
${processBlah('title.replied.to')} ${replyAuthor}
`; + } + + let reactionsHtml = ""; + if (reactions) { + try { + let rxArr = JSON.parse(reactions); + if (rxArr.length > 0) { + let rxCounts = {}; + for (let rx of rxArr) { + let parts = rx.split(':'); + if (parts.length < 2) continue; + let rxVal = parts.slice(1).join(':'); + if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false}; + rxCounts[rxVal].count++; + if (parts[0] === id) rxCounts[rxVal].me = true; + } + + let rxKeys = Object.keys(rxCounts); + if (rxKeys.length > 0) { + reactionsHtml = `
`; + for (let rxVal of rxKeys) { + let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);"; + let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); + let escapedRxVal = escapeHtml(rxVal); + reactionsHtml += `${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}`; + } + reactionsHtml += `
`; + } + } + } catch (e) { + } + } + html += ` -
+
${showAvatar ? `` : `
`} -
+
${showAvatar ? `
${authorName} ${timeStr}
` : ""} + ${repliedHtml}
${content}
+ ${reactionsHtml}
`; } } - + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem); if (window.forceScrollToBottom) { @@ -2001,9 +2170,9 @@ async function renderMessages(messages, isPrepend = false) { } let oldScrollTop = container.scrollTop; let oldScrollHeight = container.scrollHeight; - + container.innerHTML = html; - + if (wasAtBottom) { container.scrollTop = container.scrollHeight; } else if (isPrepend) { @@ -2013,55 +2182,108 @@ async function renderMessages(messages, isPrepend = false) { } } +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) { + ignoreScroll = true; + container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll; + requestAnimationFrame(() => { + ignoreScroll = false; + }); + await delay(10); + } +}); 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 (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) { } - catch (e) {} clearAction("messages.loading.older"); } }; } + + +let replyingToMsgId = null; + async function sendMessage() { if (!currentDmId || !currentDmKey) return; - + let input = document.getElementById("chat-input"); let content = input.value.trim(); if (!content) return; - + + let pendingMsgId = "pending_" + Date.now(); + let container = document.getElementById("chat-messages"); + if (container) { + let html = ` +
+
+
+
${content} (${processBlah("info.sending.message")})
+
+
+ `; + container.insertAdjacentHTML('beforeend', html); + container.scrollTop = container.scrollHeight; + } + try { let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); - + let msgPayload = { string1: currentDmId, string2: encryptedContent, - string3: "" + string3: "", + string4: replyingToMsgId || "" }; - + input.value = ""; input.style.height = '2.5rem'; - + window.forceScrollToBottom = true; - + replyingToMsgId = null; + let bar = document.getElementById("replying-bar"); + if (bar) bar.style.display = "none"; + + showAction("action.message.sending", "msgsending"); + let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload)); + clearAction("msgsending"); if (res && res.startsWith("error:")) { showBlahNotification(res); - } else { - await loadDmMessages(currentDmId); + let pElem = document.getElementById(pendingMsgId); + if (pElem) pElem.remove(); } } catch (e) { + clearAction("msgsending"); console.error(e); showBlahNotification("error:message.send.failed"); + let pElem = document.getElementById(pendingMsgId); + if (pElem) pElem.remove(); } } @@ -2090,20 +2312,20 @@ let appWebSocket = null; function setupWebSocket() { if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) return; - + let wsUrl = url.replace(/^http/, "ws") + "/ws"; appWebSocket = new WebSocket(wsUrl); - + appWebSocket.onopen = async () => { try { let nonce = await getNonce(id, passwordHash); let secretEnc = await encryptWithNonce(passwordHash, passwordHash, nonce); - appWebSocket.send(JSON.stringify({ string1: id, string2: secretEnc })); + appWebSocket.send(JSON.stringify({string1: id, string2: secretEnc})); } catch (e) { console.error("WS auth failed", e); } }; - + appWebSocket.onmessage = (event) => { let data = event.data; if (data.startsWith("dm_message:")) { @@ -2113,8 +2335,217 @@ function setupWebSocket() { } } }; - + appWebSocket.onclose = () => { setTimeout(setupWebSocket, 5000); }; +} + +let currentContextMenuMsgId = null; +let touchTimeout = null; +let touchTarget = null; + +function handleMessageContextMenu(e, msgId) { + e.preventDefault(); + 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"); + if (delBtn) { + if (loadedMessages[msgId] && loadedMessages[msgId].author !== id) { + delBtn.style.display = "none"; + } else { + delBtn.style.display = ""; + } + } +} + +function handleMessageTouchStart(e, msgId) { + touchTarget = e.target; + touchTimeout = setTimeout(() => { + if (typeof clearContextMenuStyles === "function") clearContextMenuStyles(); + currentContextMenuMsgId = msgId; + let elem = document.getElementById(`msg-${msgId}`); + if (elem) elem.classList.add("context-menu-open"); + + let touch = e.touches[0]; + showFixedContextMenu({ + top: touch.clientY, + right: touch.clientX, + bottom: touch.clientY, + left: touch.clientX + }, messageContextMenu); + let delBtn = fixedContextMenu.querySelector("#context-delete-btn"); + if (delBtn) { + if (loadedMessages[msgId] && loadedMessages[msgId].author !== id) { + delBtn.style.display = "none"; + } else { + delBtn.style.display = ""; + } + } + }, 500); +} + +function handleMessageTouchEnd() { + if (touchTimeout) clearTimeout(touchTimeout); +} + +function handleMessageTouchMove() { + if (touchTimeout) clearTimeout(touchTimeout); +} + +async function deleteMessage(msgId) { + if (fixedContextMenu) fixedContextMenu.classList.remove("show"); + 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.sending.message", "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"); + + 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 = "Someone"; + 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(); +} + +async function reactMessagePrompt(msgId, quickReaction = null) { + if (fixedContextMenu) fixedContextMenu.classList.remove("show"); + let reaction = quickReaction; + if (!reaction) { + reaction = prompt("Enter reaction (emoji or text):"); + if (!reaction) return; + } else { + reaction = decodeURIComponent(reaction); + } + + showAction("action.message.reacting", "msgreact"); + try { + let currentMsg = loadedMessages[msgId]; + 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 msgPayload = { + string1: currentDmId, + string2: msgId, + string3: encryptedReactions + }; + let res = await fetchEncrypted("dm/message/react", JSON.stringify(msgPayload)); + if (res && res.startsWith("error:")) { + showBlahNotification(res); + } + } catch (e) { + showBlahNotification("error:message.react.failed"); + } + clearAction("msgreact"); } \ No newline at end of file diff --git a/webroot/screens.js b/webroot/screens.js index 3e16f55f..76dff8f4 100644 --- a/webroot/screens.js +++ b/webroot/screens.js @@ -133,6 +133,14 @@ var chatScreen = `
+
`; +var messageContextMenu = ` + + + +`; //elements var detailsBtn = ``; diff --git a/webroot/style.css b/webroot/style.css index f41d515b..ebd1bda1 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -630,7 +630,18 @@ space{ .chat-message { display: flex; gap: 1rem; - padding: 0.2rem 0; + padding: 0.4rem 0.6rem; + margin: 0 -0.6rem; + border-radius: 0.8rem; + transition: background-color 0.15s ease; +} +@media (hover: hover) { + .chat-message:hover { + background-color: rgba(255, 255, 255, 0.04); + } +} +.chat-message:active, .chat-message.context-menu-open { + background-color: rgba(255, 255, 255, 0.04); } .chat-message.with-avatar { margin-top: 0.8rem;