Changes:
All checks were successful
Android Build / publish (push) Successful in 46s
Linux Build / publish (push) Successful in 50s

- add replies
- add reactions
- add message redacting
- fix chat height when opening mobile keyboard
This commit is contained in:
olcxja 2026-06-12 16:51:32 +02:00
commit 4016975900
10 changed files with 1286 additions and 310 deletions

View file

@ -120,7 +120,6 @@
"title.back.to.register": "back to registration", "title.back.to.register": "back to registration",
"placeholder.username": "cat name", "placeholder.username": "cat name",
"placeholder.captcha.code": "captcha code", "placeholder.captcha.code": "captcha code",
"placeholder.message.input": "meow...",
"desc.messages.loading": "loading meows...", "desc.messages.loading": "loading meows...",
"desc.no.dms": "no direct meowchats :c", "desc.no.dms": "no direct meowchats :c",
"action.dm.opening": "opening meowchat...", "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.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", "keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c",
"dm.messages.fetch.failed": "failed to fetch meows :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."
} }

View file

@ -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.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.", "keys.server.decrypt.failed": "Could not decrypt your account keys with this password.",
"dm.messages.fetch.failed": "Failed to fetch messages", "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."
} }

View file

@ -1,18 +1,16 @@
var prot = window.location.protocol; var prot = window.location.protocol;
async function updateProtocolAndUrl(host) async function updateProtocolAndUrl(host) {
{
prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol; prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol;
if (prot == "http:") { if (prot == "http:") {
try { try {
JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`)); JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`));
} } catch (error) {
catch (error) {
try { try {
JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`)); JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`));
prot = "https:"; prot = "https:";
} catch (error) {
} }
catch (error) {}
} }
} }
url = `${prot}//${host}/_larpix`; url = `${prot}//${host}/_larpix`;
@ -744,6 +742,7 @@ function getInitials(name) {
//1 word at least 2 letters //1 word at least 2 letters
return word.substring(0, 2).toUpperCase(); return word.substring(0, 2).toUpperCase();
} }
function stringToColor(str) { function stringToColor(str) {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
@ -755,6 +754,7 @@ function stringToColor(str) {
return `hsl(${h}, ${s}%, ${l}%)`; return `hsl(${h}, ${s}%, ${l}%)`;
} }
function createAvatarSvg(name, size = 512) { function createAvatarSvg(name, size = 512) {
const initials = getInitials(name); const initials = getInitials(name);
const color = stringToColor(name); const color = stringToColor(name);
@ -777,17 +777,14 @@ function createAvatarSvg(name, size = 512) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
} }
async function getAvatarUrl(id, username) async function getAvatarUrl(id, username) {
{
try { try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") if ((await fetchAsync(pfpUrl)) == "") {
{
throw Error(); throw Error();
} }
return pfpUrl; return pfpUrl;
} } catch (e) {
catch (e) {
return createAvatarSvg(username); return createAvatarSvg(username);
} }
} }
@ -810,6 +807,7 @@ async function loadingFadeOut() {
mainScreen.style.transform = ""; mainScreen.style.transform = "";
mainScreen.style.opacity = ""; mainScreen.style.opacity = "";
} }
async function loadingFadeIn() { async function loadingFadeIn() {
loadingScreen.style.transform = "scale(0.85)"; loadingScreen.style.transform = "scale(0.85)";
@ -852,8 +850,7 @@ async function initBlahs() {
let placeholders = document.querySelectorAll("[placeholder]"); let placeholders = document.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -908,9 +905,7 @@ function processBlah(blahmessage) {
message = message.replaceAll('{all}', valueslist); message = message.replaceAll('{all}', valueslist);
} }
return message; return message;
} } catch (e) {
catch (e)
{
if (prepended) { if (prepended) {
return blahmessage.substring(1); return blahmessage.substring(1);
} }
@ -1082,6 +1077,7 @@ async function mainJS() {
await start(); await start();
} }
id = localStorage.getItem('id'); id = localStorage.getItem('id');
username = localStorage.getItem('username'); username = localStorage.getItem('username');
password = localStorage.getItem('password'); password = localStorage.getItem('password');
@ -1104,8 +1100,7 @@ function showFixedContextMenu(rect, html) {
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1114,10 +1109,31 @@ function showFixedContextMenu(rect, html) {
fixedContextMenu.innerHTML = doc.body.innerHTML; fixedContextMenu.innerHTML = doc.body.innerHTML;
fixedContextMenu.style.left = `${rect.right + 10}px`; fixedContextMenu.style.transition = 'none';
fixedContextMenu.style.top = `${rect.top}px`; fixedContextMenu.style.opacity = '0';
fixedContextMenu.style.left = '0px';
fixedContextMenu.style.top = '0px';
fixedContextMenu.classList.add("show"); fixedContextMenu.classList.add("show");
let menuRect = fixedContextMenu.getBoundingClientRect();
let newLeft = rect.right + 10;
let newTop = rect.top;
if (newLeft + menuRect.width > window.innerWidth) {
newLeft = window.innerWidth - menuRect.width - 10;
}
if (newTop + menuRect.height > window.innerHeight) {
newTop = window.innerHeight - menuRect.height - 10;
}
fixedContextMenu.style.left = `${newLeft}px`;
fixedContextMenu.style.top = `${newTop}px`;
fixedContextMenu.offsetHeight;
fixedContextMenu.style.transition = '';
fixedContextMenu.style.opacity = '';
} }
var currentRoomsBarTitle = null; var currentRoomsBarTitle = null;
@ -1156,8 +1172,7 @@ async function switchRoomsBar(title, content) {
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1171,10 +1186,11 @@ async function switchRoomsBar(title, content) {
roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.transform = "";
roomsTopBarTransition.style.opacity = ""; roomsTopBarTransition.style.opacity = "";
} }
var currentRoomContentTitle = null; var currentRoomContentTitle = null;
var currentRoomContentHtml = 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) { if (currentRoomContentTitle === title && currentRoomContentHtml === content) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) { if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
@ -1203,9 +1219,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
mainScreen.classList.remove('mobile-details'); mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content'); mainScreen.classList.add('mobile-content');
} }
} } else {
else
{
await delay(200); await delay(200);
} }
@ -1229,8 +1243,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1246,10 +1259,11 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
} }
var currentDetailsContentTitle = null; var currentDetailsContentTitle = null;
var currentDetailsContentHtml = null; var currentDetailsContentHtml = null;
async function switchDetailsContent(title, content)
{ async function switchDetailsContent(title, content) {
if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return; if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return;
currentDetailsContentTitle = title; currentDetailsContentTitle = title;
currentDetailsContentHtml = content; currentDetailsContentHtml = content;
@ -1284,8 +1298,7 @@ async function switchDetailsContent(title, content)
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1301,8 +1314,8 @@ async function switchDetailsContent(title, content)
} }
function clickCollapseDms()
{ function clickCollapseDms() {
var collapseDmsBtn = document.getElementById("collapse-dms"); var collapseDmsBtn = document.getElementById("collapse-dms");
collapseDmsBtn.classList.toggle("collapsed"); collapseDmsBtn.classList.toggle("collapsed");
var dmsWrapper = document.getElementById("dms-wrapper"); var dmsWrapper = document.getElementById("dms-wrapper");
@ -1314,8 +1327,8 @@ function clickCollapseDms()
} }
} }
} }
function clickCollapseGroups()
{ function clickCollapseGroups() {
var collapseGroupsBtn = document.getElementById("collapse-groups"); var collapseGroupsBtn = document.getElementById("collapse-groups");
collapseGroupsBtn.classList.toggle("collapsed"); collapseGroupsBtn.classList.toggle("collapsed");
var groupsWrapper = document.getElementById("groups-wrapper"); var groupsWrapper = document.getElementById("groups-wrapper");
@ -1327,12 +1340,12 @@ function clickCollapseGroups()
} }
} }
} }
function clickAddGroup()
{ function clickAddGroup() {
switchRoomContent("title.create.group", createGroupScreen, false); switchRoomContent("title.create.group", createGroupScreen, false);
} }
function clickAddDm()
{ function clickAddDm() {
switchRoomContent("title.create.dm", addDmScreen, false); 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) => { document.addEventListener("click", (e) => {
history.pushState({trap: true}, "", location.href); history.pushState({trap: true}, "", location.href);
if (fixedContextMenu.classList.contains("show")) { if (fixedContextMenu.classList.contains("show")) {
if (!fixedContextMenu.contains(e.target)) { if (!fixedContextMenu.contains(e.target)) {
fixedContextMenu.classList.remove("show"); fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
if (!e.target.closest('sidebarelement')) { if (!e.target.closest('sidebarelement')) {
setActiveSidebarIndicator(currentMainIndicator); setActiveSidebarIndicator(currentMainIndicator);
} }
@ -1384,10 +1405,9 @@ if (App) {
popstate(); popstate();
}); });
} }
function popstate()
{ function popstate() {
if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) {
{
history.back(); history.back();
if (App) { if (App) {
App.minimizeApp(); App.minimizeApp();
@ -1400,6 +1420,7 @@ function popstate()
function gotoSideProfilePopup() { function gotoSideProfilePopup() {
} }
function setActiveRoombarItem(itemId) { function setActiveRoombarItem(itemId) {
let items = document.querySelectorAll('.collapse-text-button'); let items = document.querySelectorAll('.collapse-text-button');
items.forEach(item => item.classList.remove('selected')); items.forEach(item => item.classList.remove('selected'));
@ -1529,6 +1550,7 @@ async function switchInvitesTab(tab) {
await loadInvites(tab); await loadInvites(tab);
} }
async function acceptInvite(targetId) { //TODO: Implement key generation async function acceptInvite(targetId) { //TODO: Implement key generation
try { try {
showAction("action.invite.accepting", "invite.action"); showAction("action.invite.accepting", "invite.action");
@ -1663,6 +1685,7 @@ function setActiveSidebarIndicator(element, isTemporary = false) {
currentMainIndicator = element; currentMainIndicator = element;
} }
} }
function mobileNavBack() { function mobileNavBack() {
if (mainScreen.classList.contains('mobile-details')) { if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details'); mainScreen.classList.remove('mobile-details');
@ -1681,6 +1704,7 @@ function mobileNavBack() {
setActiveSidebarIndicator(currentMainIndicator); setActiveSidebarIndicator(currentMainIndicator);
} }
} }
function mobileNavDetails() { function mobileNavDetails() {
mainScreen.classList.add('mobile-details'); mainScreen.classList.add('mobile-details');
} }
@ -1711,6 +1735,10 @@ document.addEventListener('touchmove', e => {
if (diffX > rem || diffY > rem) { if (diffX > rem || diffY > rem) {
touchMoved = true; touchMoved = true;
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
document.activeElement.blur();
}
if (activeTouchButton) { if (activeTouchButton) {
activeTouchButton.classList.remove('is-pressed'); activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null; activeTouchButton = null;
@ -1757,6 +1785,7 @@ document.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
} }
}); });
function handleMobileSwipe() { function handleMobileSwipe() {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) return; if (window.innerWidth > 52 * rem) return;
@ -1842,6 +1871,19 @@ var loadedMessages = {};
var oldestLoadedMsgId = null; var oldestLoadedMsgId = null;
var isLoadingOlderMessages = false; var isLoadingOlderMessages = false;
function escapeHtml(unsafe) {
if (!unsafe) return "";
return unsafe
.replaceAll(/&/g, "&amp;")
.replaceAll(/</g, "&lt;")
.replaceAll(/>/g, "&gt;")
.replaceAll(/"/g, "&quot;")
.replaceAll(/'/g, "&#039;");
}
async function openDm(dmId, username, targetId) { async function openDm(dmId, username, targetId) {
try { try {
showAction("action.dm.opening", "dmopen"); showAction("action.dm.opening", "dmopen");
@ -1934,14 +1976,24 @@ async function renderMessages(messages, isPrepend = false) {
for (let [msgId, msg] of entries) { for (let [msgId, msg] of entries) {
let content = msg.content; let content = msg.content;
let reactions = msg.reactions || "";
let decrypted = false;
if (msg.key && msg.key !== "") { if (msg.key && msg.key !== "") {
try { try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
content = await decryptAesGcmFromBase64(content, dmKeyBytes); content = await decryptAesGcmFromBase64(content, dmKeyBytes);
if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes);
decrypted = true;
} catch (e) { } catch (e) {
content = `<span style="color:var(--error-color)"><blah>error:messages.decrypt.failed</blah></span>`; content = `<span style="color:var(--error-color)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
} }
} else {
decrypted = true;
}
if (decrypted) {
content = escapeHtml(content);
} }
content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => { content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => {
return processBlah(p1); return processBlah(p1);
@ -1967,6 +2019,9 @@ async function renderMessages(messages, isPrepend = false) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername); dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
} }
} }
if (dmUsernameCache[msg.author] && !dmPfpCache[msg.author]) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
}
if (dmUsernameCache[msg.author]) { if (dmUsernameCache[msg.author]) {
authorName = dmUsernameCache[msg.author].split(':')[0]; authorName = dmUsernameCache[msg.author].split(':')[0];
pfp = dmPfpCache[msg.author]; pfp = dmPfpCache[msg.author];
@ -1978,15 +2033,129 @@ async function renderMessages(messages, isPrepend = false) {
let extraClass = showAvatar ? "with-avatar" : ""; 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="0.8rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg> ${processBlah('title.replied.to')} ${sName}`;
}
}
}
} catch (e) {
}
}, 10);
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="scrollToMessage('${respondedId}')"><svg xmlns="http://www.w3.org/2000/svg" width="0.8rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg> ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
}
let reactionsHtml = "";
if (reactions) {
try {
let rxArr = JSON.parse(reactions);
if (rxArr.length > 0) {
let rxCounts = {};
for (let rx of rxArr) {
let parts = rx.split(':');
if (parts.length < 2) continue;
let rxVal = parts.slice(1).join(':');
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false};
rxCounts[rxVal].count++;
if (parts[0] === id) rxCounts[rxVal].me = true;
}
let rxKeys = Object.keys(rxCounts);
if (rxKeys.length > 0) {
reactionsHtml = `<div class="chat-message-reactions" style="display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.3rem;">`;
for (let rxVal of rxKeys) {
let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);";
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal);
reactionsHtml += `<span class="reaction-pill" style="padding: 0.1rem 0.4rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}" onclick="reactMessagePrompt('${msgId}', '${safeRx}')">${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}</span>`;
}
reactionsHtml += `</div>`;
}
}
} catch (e) {
}
}
html += ` html += `
<div class="chat-message ${extraClass}"> <div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`} ${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content"> <div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')" ontouchstart="handleMessageTouchStart(event, '${msgId}')" ontouchend="handleMessageTouchEnd()" ontouchmove="handleMessageTouchMove()">
${showAvatar ? `<div class="chat-message-header"> ${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author">${authorName}</span> <span class="chat-message-author">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span> <span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""} </div>` : ""}
${repliedHtml}
<div class="chat-message-text">${content}</div> <div class="chat-message-text">${content}</div>
${reactionsHtml}
</div> </div>
</div> </div>
`; `;
@ -2013,24 +2182,52 @@ 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() { function setupChatScrollListener() {
let container = document.getElementById("chat-messages"); let container = document.getElementById("chat-messages");
if (!container) return; if (!container) return;
container.onscroll = async () => { container.onscroll = async () => {
if (!ignoreScroll) {
isAdjusting = false;
lastChatScroll = container.scrollHeight - container.scrollTop - container.clientHeight;
}
if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) { if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) {
isLoadingOlderMessages = true; isLoadingOlderMessages = true;
showAction("info.messages.loading.older", "messages.loading.older"); showAction("info.messages.loading.older", "messages.loading.older");
try { try {
await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true); await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true);
isLoadingOlderMessages = false; isLoadingOlderMessages = false;
} catch (e) {
} }
catch (e) {}
clearAction("messages.loading.older"); clearAction("messages.loading.older");
} }
}; };
} }
let replyingToMsgId = null;
async function sendMessage() { async function sendMessage() {
if (!currentDmId || !currentDmKey) return; if (!currentDmId || !currentDmKey) return;
@ -2038,6 +2235,21 @@ async function sendMessage() {
let content = input.value.trim(); let content = input.value.trim();
if (!content) return; if (!content) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
if (container) {
let html = `
<div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;">
<div style="width: 2.5rem; flex-shrink: 0;"></div>
<div class="chat-message-content">
<div class="chat-message-text">${content} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
container.scrollTop = container.scrollHeight;
}
try { try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
@ -2045,23 +2257,33 @@ async function sendMessage() {
let msgPayload = { let msgPayload = {
string1: currentDmId, string1: currentDmId,
string2: encryptedContent, string2: encryptedContent,
string3: "" string3: "",
string4: replyingToMsgId || ""
}; };
input.value = ""; input.value = "";
input.style.height = '2.5rem'; input.style.height = '2.5rem';
window.forceScrollToBottom = true; 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)); let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload));
clearAction("msgsending");
if (res && res.startsWith("error:")) { if (res && res.startsWith("error:")) {
showBlahNotification(res); showBlahNotification(res);
} else { let pElem = document.getElementById(pendingMsgId);
await loadDmMessages(currentDmId); if (pElem) pElem.remove();
} }
} catch (e) { } catch (e) {
clearAction("msgsending");
console.error(e); console.error(e);
showBlahNotification("error:message.send.failed"); showBlahNotification("error:message.send.failed");
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
} }
} }
@ -2118,3 +2340,212 @@ function setupWebSocket() {
setTimeout(setupWebSocket, 5000); 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");
}

View file

@ -133,6 +133,14 @@ var chatScreen = `
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;"> <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;"> <div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
</div> </div>
<div id="replying-bar" style="display: none; padding: 0.5rem 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
<span style="opacity: 0.7;"><blah>title.replied.to</blah> </span><strong id="replying-to-name"></strong>: <span id="replying-to-text" style="opacity: 0.7;"></span>
</div>
<button onclick="cancelReply()" style="background: none; border: none; color: var(--text-color); cursor: pointer; padding: 0.2rem; margin-left: 0.5rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" viewBox="0 -960 960 960" fill="currentColor"><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>
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;"> <div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea> <textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button class="submit-button" onclick="sendMessage()" style="margin: 0; padding: 0; aspect-ratio: 1;"> <button class="submit-button" onclick="sendMessage()" style="margin: 0; padding: 0; aspect-ratio: 1;">
@ -220,6 +228,20 @@ var addSpaceMenu = `
</button> </button>
`; `;
var messageContextMenu = `
<button onclick="replyMessage(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/></svg>
<span class="blah">title.reply.message</span>
</button>
<button onclick="reactMessagePrompt(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-260q70 0 126.5-40.5T682-400H278q19 59 75.5 99.5T480-260Zm-160-220q25 0 42.5-17.5T380-540q0-25-17.5-42.5T320-600q-25 0-42.5 17.5T260-540q0 25 17.5 42.5T320-480Zm320 0q25 0 42.5-17.5T700-540q0-25-17.5-42.5T640-600q-25 0-42.5 17.5T580-540q0 25 17.5 42.5T640-480ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
<span class="blah">title.react.message</span>
</button>
<button id="context-delete-btn" onclick="deleteMessage(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" height="1.4rem" viewBox="0 -960 960 960" fill="var(--big-red)"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
<span class="blah" style="color: var(--big-red)">title.delete.message</span>
</button>
`;
//elements //elements
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`; var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;

View file

@ -630,7 +630,18 @@ space{
.chat-message { .chat-message {
display: flex; display: flex;
gap: 1rem; 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 { .chat-message.with-avatar {
margin-top: 0.8rem; margin-top: 0.8rem;

View file

@ -120,7 +120,6 @@
"title.back.to.register": "back to registration", "title.back.to.register": "back to registration",
"placeholder.username": "cat name", "placeholder.username": "cat name",
"placeholder.captcha.code": "captcha code", "placeholder.captcha.code": "captcha code",
"placeholder.message.input": "meow...",
"desc.messages.loading": "loading meows...", "desc.messages.loading": "loading meows...",
"desc.no.dms": "no direct meowchats :c", "desc.no.dms": "no direct meowchats :c",
"action.dm.opening": "opening meowchat...", "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.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", "keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c",
"dm.messages.fetch.failed": "failed to fetch meows :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."
} }

View file

@ -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.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.", "keys.server.decrypt.failed": "Could not decrypt your account keys with this password.",
"dm.messages.fetch.failed": "Failed to fetch messages", "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."
} }

View file

@ -1,18 +1,16 @@
var prot = window.location.protocol; var prot = window.location.protocol;
async function updateProtocolAndUrl(host) async function updateProtocolAndUrl(host) {
{
prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol; prot = window.location.protocol == "miarven:" ? "http:" : window.location.protocol;
if (prot == "http:") { if (prot == "http:") {
try { try {
JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`)); JSON.parse(await fetchAsync(`${prot}//${host}/_larpix/serverinfo`));
} } catch (error) {
catch (error) {
try { try {
JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`)); JSON.parse(await fetchAsync(`https://${host}/_larpix/serverinfo`));
prot = "https:"; prot = "https:";
} catch (error) {
} }
catch (error) {}
} }
} }
url = `${prot}//${host}/_larpix`; url = `${prot}//${host}/_larpix`;
@ -744,6 +742,7 @@ function getInitials(name) {
//1 word at least 2 letters //1 word at least 2 letters
return word.substring(0, 2).toUpperCase(); return word.substring(0, 2).toUpperCase();
} }
function stringToColor(str) { function stringToColor(str) {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
@ -755,6 +754,7 @@ function stringToColor(str) {
return `hsl(${h}, ${s}%, ${l}%)`; return `hsl(${h}, ${s}%, ${l}%)`;
} }
function createAvatarSvg(name, size = 512) { function createAvatarSvg(name, size = 512) {
const initials = getInitials(name); const initials = getInitials(name);
const color = stringToColor(name); const color = stringToColor(name);
@ -777,17 +777,14 @@ function createAvatarSvg(name, size = 512) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
} }
async function getAvatarUrl(id, username) async function getAvatarUrl(id, username) {
{
try { try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") if ((await fetchAsync(pfpUrl)) == "") {
{
throw Error(); throw Error();
} }
return pfpUrl; return pfpUrl;
} } catch (e) {
catch (e) {
return createAvatarSvg(username); return createAvatarSvg(username);
} }
} }
@ -810,6 +807,7 @@ async function loadingFadeOut() {
mainScreen.style.transform = ""; mainScreen.style.transform = "";
mainScreen.style.opacity = ""; mainScreen.style.opacity = "";
} }
async function loadingFadeIn() { async function loadingFadeIn() {
loadingScreen.style.transform = "scale(0.85)"; loadingScreen.style.transform = "scale(0.85)";
@ -852,8 +850,7 @@ async function initBlahs() {
let placeholders = document.querySelectorAll("[placeholder]"); let placeholders = document.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -908,9 +905,7 @@ function processBlah(blahmessage) {
message = message.replaceAll('{all}', valueslist); message = message.replaceAll('{all}', valueslist);
} }
return message; return message;
} } catch (e) {
catch (e)
{
if (prepended) { if (prepended) {
return blahmessage.substring(1); return blahmessage.substring(1);
} }
@ -1082,6 +1077,7 @@ async function mainJS() {
await start(); await start();
} }
id = localStorage.getItem('id'); id = localStorage.getItem('id');
username = localStorage.getItem('username'); username = localStorage.getItem('username');
password = localStorage.getItem('password'); password = localStorage.getItem('password');
@ -1104,8 +1100,7 @@ function showFixedContextMenu(rect, html) {
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1114,10 +1109,31 @@ function showFixedContextMenu(rect, html) {
fixedContextMenu.innerHTML = doc.body.innerHTML; fixedContextMenu.innerHTML = doc.body.innerHTML;
fixedContextMenu.style.left = `${rect.right + 10}px`; fixedContextMenu.style.transition = 'none';
fixedContextMenu.style.top = `${rect.top}px`; fixedContextMenu.style.opacity = '0';
fixedContextMenu.style.left = '0px';
fixedContextMenu.style.top = '0px';
fixedContextMenu.classList.add("show"); fixedContextMenu.classList.add("show");
let menuRect = fixedContextMenu.getBoundingClientRect();
let newLeft = rect.right + 10;
let newTop = rect.top;
if (newLeft + menuRect.width > window.innerWidth) {
newLeft = window.innerWidth - menuRect.width - 10;
}
if (newTop + menuRect.height > window.innerHeight) {
newTop = window.innerHeight - menuRect.height - 10;
}
fixedContextMenu.style.left = `${newLeft}px`;
fixedContextMenu.style.top = `${newTop}px`;
fixedContextMenu.offsetHeight;
fixedContextMenu.style.transition = '';
fixedContextMenu.style.opacity = '';
} }
var currentRoomsBarTitle = null; var currentRoomsBarTitle = null;
@ -1156,8 +1172,7 @@ async function switchRoomsBar(title, content) {
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1171,10 +1186,11 @@ async function switchRoomsBar(title, content) {
roomsTopBarTransition.style.transform = ""; roomsTopBarTransition.style.transform = "";
roomsTopBarTransition.style.opacity = ""; roomsTopBarTransition.style.opacity = "";
} }
var currentRoomContentTitle = null; var currentRoomContentTitle = null;
var currentRoomContentHtml = 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) { if (currentRoomContentTitle === title && currentRoomContentHtml === content) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 52 * rem && !skipMobileSlide) { if (window.innerWidth <= 52 * rem && !skipMobileSlide) {
@ -1203,9 +1219,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
mainScreen.classList.remove('mobile-details'); mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content'); mainScreen.classList.add('mobile-content');
} }
} } else {
else
{
await delay(200); await delay(200);
} }
@ -1229,8 +1243,7 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1246,10 +1259,11 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
} }
var currentDetailsContentTitle = null; var currentDetailsContentTitle = null;
var currentDetailsContentHtml = null; var currentDetailsContentHtml = null;
async function switchDetailsContent(title, content)
{ async function switchDetailsContent(title, content) {
if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return; if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return;
currentDetailsContentTitle = title; currentDetailsContentTitle = title;
currentDetailsContentHtml = content; currentDetailsContentHtml = content;
@ -1284,8 +1298,7 @@ async function switchDetailsContent(title, content)
let placeholders = doc.querySelectorAll("[placeholder]"); let placeholders = doc.querySelectorAll("[placeholder]");
for (let i = 0; i < placeholders.length; i++) { 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]; let value = placeholders[i].placeholder.split("{blah(")[1].split(")}")[0];
placeholders[i].placeholder = processBlah(value); placeholders[i].placeholder = processBlah(value);
} }
@ -1301,8 +1314,8 @@ async function switchDetailsContent(title, content)
} }
function clickCollapseDms()
{ function clickCollapseDms() {
var collapseDmsBtn = document.getElementById("collapse-dms"); var collapseDmsBtn = document.getElementById("collapse-dms");
collapseDmsBtn.classList.toggle("collapsed"); collapseDmsBtn.classList.toggle("collapsed");
var dmsWrapper = document.getElementById("dms-wrapper"); var dmsWrapper = document.getElementById("dms-wrapper");
@ -1314,8 +1327,8 @@ function clickCollapseDms()
} }
} }
} }
function clickCollapseGroups()
{ function clickCollapseGroups() {
var collapseGroupsBtn = document.getElementById("collapse-groups"); var collapseGroupsBtn = document.getElementById("collapse-groups");
collapseGroupsBtn.classList.toggle("collapsed"); collapseGroupsBtn.classList.toggle("collapsed");
var groupsWrapper = document.getElementById("groups-wrapper"); var groupsWrapper = document.getElementById("groups-wrapper");
@ -1327,12 +1340,12 @@ function clickCollapseGroups()
} }
} }
} }
function clickAddGroup()
{ function clickAddGroup() {
switchRoomContent("title.create.group", createGroupScreen, false); switchRoomContent("title.create.group", createGroupScreen, false);
} }
function clickAddDm()
{ function clickAddDm() {
switchRoomContent("title.create.dm", addDmScreen, false); 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) => { document.addEventListener("click", (e) => {
history.pushState({trap: true}, "", location.href); history.pushState({trap: true}, "", location.href);
if (fixedContextMenu.classList.contains("show")) { if (fixedContextMenu.classList.contains("show")) {
if (!fixedContextMenu.contains(e.target)) { if (!fixedContextMenu.contains(e.target)) {
fixedContextMenu.classList.remove("show"); fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
if (!e.target.closest('sidebarelement')) { if (!e.target.closest('sidebarelement')) {
setActiveSidebarIndicator(currentMainIndicator); setActiveSidebarIndicator(currentMainIndicator);
} }
@ -1384,10 +1405,9 @@ if (App) {
popstate(); popstate();
}); });
} }
function popstate()
{ function popstate() {
if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) if (!mainScreen.classList.contains('mobile-content') && !mainScreen.classList.contains('mobile-details')) {
{
history.back(); history.back();
if (App) { if (App) {
App.minimizeApp(); App.minimizeApp();
@ -1400,6 +1420,7 @@ function popstate()
function gotoSideProfilePopup() { function gotoSideProfilePopup() {
} }
function setActiveRoombarItem(itemId) { function setActiveRoombarItem(itemId) {
let items = document.querySelectorAll('.collapse-text-button'); let items = document.querySelectorAll('.collapse-text-button');
items.forEach(item => item.classList.remove('selected')); items.forEach(item => item.classList.remove('selected'));
@ -1529,6 +1550,7 @@ async function switchInvitesTab(tab) {
await loadInvites(tab); await loadInvites(tab);
} }
async function acceptInvite(targetId) { //TODO: Implement key generation async function acceptInvite(targetId) { //TODO: Implement key generation
try { try {
showAction("action.invite.accepting", "invite.action"); showAction("action.invite.accepting", "invite.action");
@ -1663,6 +1685,7 @@ function setActiveSidebarIndicator(element, isTemporary = false) {
currentMainIndicator = element; currentMainIndicator = element;
} }
} }
function mobileNavBack() { function mobileNavBack() {
if (mainScreen.classList.contains('mobile-details')) { if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details'); mainScreen.classList.remove('mobile-details');
@ -1681,6 +1704,7 @@ function mobileNavBack() {
setActiveSidebarIndicator(currentMainIndicator); setActiveSidebarIndicator(currentMainIndicator);
} }
} }
function mobileNavDetails() { function mobileNavDetails() {
mainScreen.classList.add('mobile-details'); mainScreen.classList.add('mobile-details');
} }
@ -1711,6 +1735,10 @@ document.addEventListener('touchmove', e => {
if (diffX > rem || diffY > rem) { if (diffX > rem || diffY > rem) {
touchMoved = true; touchMoved = true;
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
document.activeElement.blur();
}
if (activeTouchButton) { if (activeTouchButton) {
activeTouchButton.classList.remove('is-pressed'); activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null; activeTouchButton = null;
@ -1757,6 +1785,7 @@ document.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
} }
}); });
function handleMobileSwipe() { function handleMobileSwipe() {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) return; if (window.innerWidth > 52 * rem) return;
@ -1842,6 +1871,19 @@ var loadedMessages = {};
var oldestLoadedMsgId = null; var oldestLoadedMsgId = null;
var isLoadingOlderMessages = false; var isLoadingOlderMessages = false;
function escapeHtml(unsafe) {
if (!unsafe) return "";
return unsafe
.replaceAll(/&/g, "&amp;")
.replaceAll(/</g, "&lt;")
.replaceAll(/>/g, "&gt;")
.replaceAll(/"/g, "&quot;")
.replaceAll(/'/g, "&#039;");
}
async function openDm(dmId, username, targetId) { async function openDm(dmId, username, targetId) {
try { try {
showAction("action.dm.opening", "dmopen"); showAction("action.dm.opening", "dmopen");
@ -1934,14 +1976,24 @@ async function renderMessages(messages, isPrepend = false) {
for (let [msgId, msg] of entries) { for (let [msgId, msg] of entries) {
let content = msg.content; let content = msg.content;
let reactions = msg.reactions || "";
let decrypted = false;
if (msg.key && msg.key !== "") { if (msg.key && msg.key !== "") {
try { try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
content = await decryptAesGcmFromBase64(content, dmKeyBytes); content = await decryptAesGcmFromBase64(content, dmKeyBytes);
if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes);
decrypted = true;
} catch (e) { } catch (e) {
content = `<span style="color:var(--error-color)"><blah>error:messages.decrypt.failed</blah></span>`; content = `<span style="color:var(--error-color)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
} }
} else {
decrypted = true;
}
if (decrypted) {
content = escapeHtml(content);
} }
content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => { content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => {
return processBlah(p1); return processBlah(p1);
@ -1967,6 +2019,9 @@ async function renderMessages(messages, isPrepend = false) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername); dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
} }
} }
if (dmUsernameCache[msg.author] && !dmPfpCache[msg.author]) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
}
if (dmUsernameCache[msg.author]) { if (dmUsernameCache[msg.author]) {
authorName = dmUsernameCache[msg.author].split(':')[0]; authorName = dmUsernameCache[msg.author].split(':')[0];
pfp = dmPfpCache[msg.author]; pfp = dmPfpCache[msg.author];
@ -1978,15 +2033,129 @@ async function renderMessages(messages, isPrepend = false) {
let extraClass = showAvatar ? "with-avatar" : ""; 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="0.8rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg> ${processBlah('title.replied.to')} ${sName}`;
}
}
}
} catch (e) {
}
}, 10);
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="scrollToMessage('${respondedId}')"><svg xmlns="http://www.w3.org/2000/svg" width="0.8rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle;"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg> ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
}
let reactionsHtml = "";
if (reactions) {
try {
let rxArr = JSON.parse(reactions);
if (rxArr.length > 0) {
let rxCounts = {};
for (let rx of rxArr) {
let parts = rx.split(':');
if (parts.length < 2) continue;
let rxVal = parts.slice(1).join(':');
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false};
rxCounts[rxVal].count++;
if (parts[0] === id) rxCounts[rxVal].me = true;
}
let rxKeys = Object.keys(rxCounts);
if (rxKeys.length > 0) {
reactionsHtml = `<div class="chat-message-reactions" style="display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.3rem;">`;
for (let rxVal of rxKeys) {
let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);";
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal);
reactionsHtml += `<span class="reaction-pill" style="padding: 0.1rem 0.4rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}" onclick="reactMessagePrompt('${msgId}', '${safeRx}')">${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}</span>`;
}
reactionsHtml += `</div>`;
}
}
} catch (e) {
}
}
html += ` html += `
<div class="chat-message ${extraClass}"> <div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`} ${showAvatar ? `<img src="${pfp}" class="chat-message-pfp">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content"> <div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')" ontouchstart="handleMessageTouchStart(event, '${msgId}')" ontouchend="handleMessageTouchEnd()" ontouchmove="handleMessageTouchMove()">
${showAvatar ? `<div class="chat-message-header"> ${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author">${authorName}</span> <span class="chat-message-author">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span> <span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""} </div>` : ""}
${repliedHtml}
<div class="chat-message-text">${content}</div> <div class="chat-message-text">${content}</div>
${reactionsHtml}
</div> </div>
</div> </div>
`; `;
@ -2013,24 +2182,52 @@ 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() { function setupChatScrollListener() {
let container = document.getElementById("chat-messages"); let container = document.getElementById("chat-messages");
if (!container) return; if (!container) return;
container.onscroll = async () => { container.onscroll = async () => {
if (!ignoreScroll) {
isAdjusting = false;
lastChatScroll = container.scrollHeight - container.scrollTop - container.clientHeight;
}
if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) { if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) {
isLoadingOlderMessages = true; isLoadingOlderMessages = true;
showAction("info.messages.loading.older", "messages.loading.older"); showAction("info.messages.loading.older", "messages.loading.older");
try { try {
await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true); await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true);
isLoadingOlderMessages = false; isLoadingOlderMessages = false;
} catch (e) {
} }
catch (e) {}
clearAction("messages.loading.older"); clearAction("messages.loading.older");
} }
}; };
} }
let replyingToMsgId = null;
async function sendMessage() { async function sendMessage() {
if (!currentDmId || !currentDmKey) return; if (!currentDmId || !currentDmKey) return;
@ -2038,6 +2235,21 @@ async function sendMessage() {
let content = input.value.trim(); let content = input.value.trim();
if (!content) return; if (!content) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
if (container) {
let html = `
<div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;">
<div style="width: 2.5rem; flex-shrink: 0;"></div>
<div class="chat-message-content">
<div class="chat-message-text">${content} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
container.scrollTop = container.scrollHeight;
}
try { try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey)); let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes); let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
@ -2045,23 +2257,33 @@ async function sendMessage() {
let msgPayload = { let msgPayload = {
string1: currentDmId, string1: currentDmId,
string2: encryptedContent, string2: encryptedContent,
string3: "" string3: "",
string4: replyingToMsgId || ""
}; };
input.value = ""; input.value = "";
input.style.height = '2.5rem'; input.style.height = '2.5rem';
window.forceScrollToBottom = true; 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)); let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload));
clearAction("msgsending");
if (res && res.startsWith("error:")) { if (res && res.startsWith("error:")) {
showBlahNotification(res); showBlahNotification(res);
} else { let pElem = document.getElementById(pendingMsgId);
await loadDmMessages(currentDmId); if (pElem) pElem.remove();
} }
} catch (e) { } catch (e) {
clearAction("msgsending");
console.error(e); console.error(e);
showBlahNotification("error:message.send.failed"); showBlahNotification("error:message.send.failed");
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
} }
} }
@ -2118,3 +2340,212 @@ function setupWebSocket() {
setTimeout(setupWebSocket, 5000); 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");
}

View file

@ -133,6 +133,14 @@ var chatScreen = `
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;"> <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;"> <div id="chat-messages" style="flex-grow: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
</div> </div>
<div id="replying-bar" style="display: none; padding: 0.5rem 1rem; border-top: var(--border-width) solid var(--light-border-color); font-size: 0.85rem; justify-content: space-between; align-items: center; background: var(--main-bg-color);">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-grow: 1;">
<span style="opacity: 0.7;"><blah>title.replied.to</blah> </span><strong id="replying-to-name"></strong>: <span id="replying-to-text" style="opacity: 0.7;"></span>
</div>
<button onclick="cancelReply()" style="background: none; border: none; color: var(--text-color); cursor: pointer; padding: 0.2rem; margin-left: 0.5rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" viewBox="0 -960 960 960" fill="currentColor"><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>
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;"> <div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea> <textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button class="submit-button" onclick="sendMessage()" style="margin: 0; padding: 0; aspect-ratio: 1;"> <button class="submit-button" onclick="sendMessage()" style="margin: 0; padding: 0; aspect-ratio: 1;">
@ -220,6 +228,20 @@ var addSpaceMenu = `
</button> </button>
`; `;
var messageContextMenu = `
<button onclick="replyMessage(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/></svg>
<span class="blah">title.reply.message</span>
</button>
<button onclick="reactMessagePrompt(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-260q70 0 126.5-40.5T682-400H278q19 59 75.5 99.5T480-260Zm-160-220q25 0 42.5-17.5T380-540q0-25-17.5-42.5T320-600q-25 0-42.5 17.5T260-540q0 25 17.5 42.5T320-480Zm320 0q25 0 42.5-17.5T700-540q0-25-17.5-42.5T640-600q-25 0-42.5 17.5T580-540q0 25 17.5 42.5T640-480ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
<span class="blah">title.react.message</span>
</button>
<button id="context-delete-btn" onclick="deleteMessage(currentContextMenuMsgId)">
<svg xmlns="http://www.w3.org/2000/svg" height="1.4rem" viewBox="0 -960 960 960" fill="var(--big-red)"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>
<span class="blah" style="color: var(--big-red)">title.delete.message</span>
</button>
`;
//elements //elements
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`; var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;

View file

@ -630,7 +630,18 @@ space{
.chat-message { .chat-message {
display: flex; display: flex;
gap: 1rem; 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 { .chat-message.with-avatar {
margin-top: 0.8rem; margin-top: 0.8rem;