Compare commits

...

3 commits

Author SHA1 Message Date
340c16b080 add message pinning
All checks were successful
Android Build / publish (push) Successful in 53s
Linux Build / publish (push) Successful in 1m2s
2026-07-25 01:06:45 +02:00
91d293ee05 remove depracated code & fix attachment name handling 2026-07-24 23:04:57 +02:00
def660e8a0 better nav back handling & replace call and pin icons 2026-07-24 22:43:11 +02:00
12 changed files with 466 additions and 82 deletions

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Type purr / meow...",
"action.cancel": "Cancel",
"title.crop.image": "Crop Image",
"title.zoom": "Zoom"
"title.zoom": "Zoom",
"title.pin.message": "Pin",
"title.unpin.message": "Unpin",
"message.pinned.by": "{0} pinned a {1}.",
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned meows",
"title.message.lowercase": "meowsage"
}

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Type text / emoji...",
"action.cancel": "Cancel",
"title.crop.image": "Crop Image",
"title.zoom": "Zoom"
"title.zoom": "Zoom",
"title.pin.message": "Pin",
"title.unpin.message": "Unpin",
"message.pinned.by": "{0} pinned a {1}.",
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned messages",
"title.message.lowercase": "message"
}

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Wpisz tekst / emoji...",
"action.cancel": "Anuluj",
"title.crop.image": "Przytnij obraz",
"title.zoom": "Przybliżenie"
"title.zoom": "Przybliżenie",
"title.pin.message": "Przypnij",
"title.unpin.message": "Odepnij",
"message.pinned.by": "{0} przypiął {1}.",
"message.unpinned.by": "{0} odpiął {1}.",
"title.pinned.messages": "Przypięte",
"error.pinned.messages.empty": "Brak przypiętych wiadomości",
"title.message.lowercase": "wiadomość"
}

View file

@ -16,7 +16,6 @@ const ICONS = {
_feather: function(content, props) {
return this._create(content, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "none", stroke: "var(--text-color)", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }, props);
},
addChat: (props) => ICONS._material(`<path d="M120-160v-600q0-33 23.5-56.5T200-840h480q33 0 56.5 23.5T760-760v203q-10-2-20-2.5t-20-.5q-10 0-20 .5t-20 2.5v-203H200v400h283q-2 10-2.5 20t-.5 20q0 10 .5 20t2.5 20H240L120-160Zm160-440h320v-80H280v80Zm0 160h200v-80H280v80Zm400 280v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM200-360v-400 400Z"/>`, props),
createGroup: (props) => ICONS._material(`<path d="M500-482q29-32 44.5-73t15.5-85q0-44-15.5-85T500-798q60 8 100 53t40 105q0 60-40 105t-100 53Zm220 322v-120q0-36-16-68.5T662-406q51 18 94.5 46.5T800-280v120h-80Zm80-280v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Zm-593-87q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM0-160v-112q0-34 17.5-62.5T64-378q62-31 126-46.5T320-440q66 0 130 15.5T576-378q29 15 46.5 43.5T640-272v112H0Zm320-400q33 0 56.5-23.5T400-640q0-33-23.5-56.5T320-720q-33 0-56.5 23.5T240-640q0 33 23.5 56.5T320-560ZM80-240h480v-32q0-11-5.5-20T540-306q-54-27-109-40.5T320-360q-56 0-111 13.5T100-306q-9 5-14.5 14T80-272v32Zm240-400Zm0 400Z"/>`, props),
createSpace: (props) => ICONS._material(`<path d="M80-120v-720h400v160h400v320h-80v-240H480v80h80v80h-80v80h80v80h-80v80h160v80H80Zm80-80h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm160 480h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80ZM800-40v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM640-440v-80h80v80h-80Zm0 160v-80h80v80h-80Z"/>`, props),
@ -28,8 +27,8 @@ const ICONS = {
delete: (props) => ICONS._material(`<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"/>`, props),
menu: (props) => ICONS._material(`<path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/>`, props),
arrowBack: (props) => ICONS._material(`<path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/>`, props),
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280q0-33 23.5-56.5T400-840h160q33 0 56.5 23.5T640-760v280Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12Z"/>`, props),
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12ZM241-600l66-66-17-94h-89q5 41 14 81t26 79Zm358 358q39 17 79.5 27t81.5 13v-88l-94-19-67 67ZM241-600Zm358 358Z"/>`, props),
account: (props) => ICONS._material(`<path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>`, props),
settings: (props) => ICONS._material(`<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/>`, props),
profile: (props) => ICONS._material(`<path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/>`, props),

View file

@ -1350,7 +1350,14 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
var currentDetailsContentTitle = null;
var currentDetailsContentHtml = null;
async function switchDetailsContent(title, content) {
var savedDetailsHtml = null;
var savedDetailsTitle = null;
async function switchDetailsContent(title, content, showBack = false, backAction = "", saveCurrent = false) {
if (saveCurrent && currentDetailsContentTitle !== title) {
savedDetailsHtml = currentDetailsContentHtml;
savedDetailsTitle = currentDetailsContentTitle;
}
if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return;
currentDetailsContentTitle = title;
currentDetailsContentHtml = content;
@ -1364,13 +1371,14 @@ async function switchDetailsContent(title, content) {
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 55 * rem) {
if (window.innerWidth > 55 * rem || mainScreen.classList.contains('mobile-details')) {
await delay(200);
}
let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`;
roomsTopBarTransition.innerHTML = `<inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let pinCallBtns = showBack ? `${callBtnHtml}` : `${pinBtnHtml}${callBtnHtml}`;
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html");
@ -1495,7 +1503,7 @@ document.addEventListener("click", (e) => {
if (mainScreen && mainScreen.classList.contains('mobile-details')) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('#room-topbar-clickable') && !e.target.closest('.profile-popup-container')) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('#room-topbar-clickable') && !e.target.closest('.profile-popup-container') && !e.target.closest('.room-action-pin')) {
mobileNavBack();
}
}
@ -1513,6 +1521,21 @@ if (App) {
}
function popstate() {
if (fixedContextMenu && fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
return;
}
let cropperPopup = document.getElementById("cropper-popup-container");
if (cropperPopup && cropperPopup.classList.contains("show")) {
cropperPopup.classList.remove("show");
return;
}
if (isProfileOpen) {
closeProfile();
return;
@ -1582,7 +1605,17 @@ async function openProfile(accountId) {
let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`;
if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner" onerror="this.outerHTML='<div class=\\'profile-banner\\'></div>'">`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner">`;
setTimeout(() => {
let bannerImg = document.querySelector("#profile-popup-inner .profile-banner");
if (bannerImg) {
bannerImg.addEventListener("error", function() {
let div = document.createElement("div");
div.className = "profile-banner";
this.parentNode.replaceChild(div, this);
});
}
}, 50);
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
if (bio.startsWith("error:")) bio = "";
@ -1624,7 +1657,8 @@ async function openProfile(accountId) {
}
if (hasDm) {
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', '${nameWithHost}', '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
let safeName = encodeURIComponent(nameWithHost).replace(/'/g, "%27");
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', decodeURIComponent('${safeName}'), '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
} else {
let sentInvite = false;
let receivedInvite = false;
@ -2141,8 +2175,10 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
html += `
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', '${targetUsername}', '${targetId}')">
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
<img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${displayName}</span>
@ -2342,12 +2378,7 @@ function truncateFilename(name, maxLength = 20) {
return baseName.substring(0, half) + "..." + baseName.substring(baseName.length - (allowedBaseLength - half)) + ext;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
let entries = Object.entries(messages).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
async function generateMessagesHtml(entries, isPinnedContext = false) {
let html = "";
let lastAuthor = null;
@ -2388,7 +2419,10 @@ async function renderMessages(messages, isPrepend = false) {
? `${ICONS.image({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`
: `${ICONS.download({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="downloadAndRenderAttachment('${msgId}', '${att.id}', '${escapeHtml(att.type)}', '${escapeHtml(att.name)}')">
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'))">
<div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -2418,7 +2452,33 @@ async function renderMessages(messages, isPrepend = false) {
// Also still process the whole thing in case it's just "dm.begin.notice" without {blah()}
content = processBlah(content);
if (msg.type === "larp.info") {
if (msg.type && msg.type.startsWith("larp.info")) {
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin") {
let authorName = "Unknown";
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
if (fullUsername && !fullUsername.startsWith("error:")) {
dmUsernameCache[msg.author] = fullUsername;
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
}
}
if (dmUsernameCache[msg.author]) {
authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]);
} else {
authorName = escapeHtml(msg.author.split(';')[0]);
}
}
let isPinned = msg.type === "larp.info.pin";
let actionStr = isPinned ? "message.pinned.by" : "message.unpinned.by";
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr);
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
}
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem;">${content}</div>`;
lastAuthor = null;
} else {
@ -2529,7 +2589,7 @@ async function renderMessages(messages, isPrepend = false) {
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
}
let reactionsHtml = "";
@ -2561,7 +2621,8 @@ async function renderMessages(messages, isPrepend = false) {
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>`;
let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`;
reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${msgId}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
}
reactionsHtml += `</div>`;
}
@ -2571,11 +2632,11 @@ async function renderMessages(messages, isPrepend = false) {
}
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')">
<div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${msgId}" id="${isPinnedContext ? `pinned-` : ``}msg-${msgId}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${msgId}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${msgId}')">
${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author" onclick="openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
@ -2588,6 +2649,18 @@ async function renderMessages(messages, isPrepend = false) {
}
}
return html;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
let entries = Object.entries(messages).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
let html = await generateMessagesHtml(entries, false);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem);
if (window.forceScrollToBottom) {
@ -2750,10 +2823,8 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(msgId, attId, type, name) {
async function downloadAndRenderAttachment(container, msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
let container = document.getElementById(`att-container-${msgId}-${attId}`);
if (!container) return;
if (container.dataset.loaded === "true") {
@ -3078,6 +3149,9 @@ function setupWebSocket() {
if (dmMessageLoadTimeout) clearTimeout(dmMessageLoadTimeout);
dmMessageLoadTimeout = setTimeout(() => {
loadDmMessages(msgDmId);
if (currentDetailsContentTitle === "title.pinned.messages") {
showPinnedMessages(true, false);
}
}, 300);
}
}
@ -3119,6 +3193,41 @@ function handleMessageContextMenu(e, msgId) {
}
}
if (reactBtn) reactBtn.style.display = "";
let pinContainer = fixedContextMenu.querySelector("#context-pin-container");
if (pinContainer) {
let isPinned = msg && msg.pinned === "true";
pinContainer.innerHTML = `
<button onclick="togglePinMessage('${msgId}')">
${ICONS.pin()}
<span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span>
</button>`;
}
}
}
async function togglePinMessage(msgId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
try {
let payload = JSON.stringify({
string1: currentDmId,
string2: msgId
});
await fetchEncrypted("dm/message/pin/toggle", payload);
} catch (e) {
console.error(e);
showBlahNotification("error.network.error");
}
}
async function restoreDmDetails() {
if (savedDetailsHtml && savedDetailsTitle) {
await switchDetailsContent(savedDetailsTitle, savedDetailsHtml);
savedDetailsHtml = null;
savedDetailsTitle = null;
}
}
@ -3245,9 +3354,12 @@ async function loadEmotes() {
reactionPickerMenuHtml = `
<div style="display: flex; flex-direction: column; gap: 0.4rem; padding: 0.2rem; min-width: 14rem;">
<input type="text" id="reaction-picker-search" placeholder="{blah(placeholder.type.text.emoji)}" oninput="filterReactions(this.value)" onkeydown="if(event.key === 'Enter') { if(this.value.trim() !== '') reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(this.value)); }" style="width: auto; align-self: stretch; margin: 0; box-sizing: border-box; padding: 0.5rem 0.6rem; border-radius: 0.65rem; border: var(--border-width) solid var(--light-border-color); background: transparent; color: var(--text-color); font-family: inherit;">
<input type="text" id="reaction-picker-search" placeholder="{blah(placeholder.type.text.emoji)}" oninput="filterReactions(this.value)" onkeydown="if(arguments[0].key === 'Enter') { if(this.value.trim() !== '') reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(this.value)); }" style="width: auto; align-self: stretch; margin: 0; box-sizing: border-box; padding: 0.5rem 0.6rem; border-radius: 0.65rem; border: var(--border-width) solid var(--light-border-color); background: transparent; color: var(--text-color); font-family: inherit;">
<div id="reaction-picker-grid" style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.2rem; max-height: 12rem; overflow-y: auto; overflow-x: hidden;">
${loadedEmotes.map(emoji => `<button onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent('${emoji.char}'))" data-name="${emoji.name}" style="min-width: unset; width: 100%; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; padding: 0; font-size: 1.2rem; border-radius: 0.65rem;" class="reaction-emoji-btn">${emoji.char}</button>`).join('')}
${loadedEmotes.map(emoji => {
let safeChar = encodeURIComponent(emoji.char).replace(/'/g, "%27");
return `<button onclick="reactMessagePrompt(currentContextMenuMsgId, '${safeChar}')" data-name="${emoji.name}" style="min-width: unset; width: 100%; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; padding: 0; font-size: 1.2rem; border-radius: 0.65rem;" class="reaction-emoji-btn">${emoji.char}</button>`;
}).join('')}
</div>
<button class="reaction-text-btn" onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(document.getElementById('reaction-picker-search').value))" style="margin: 0; padding: 0.5rem; justify-content: center; border-radius: 0.65rem; width: 100%;">
<span class="blah">title.text.react</span>
@ -3899,4 +4011,63 @@ function handleDmInteraction() {
document.addEventListener('click', handleDmInteraction);
document.addEventListener('keydown', handleDmInteraction);
document.addEventListener('touchstart', handleDmInteraction);
document.addEventListener('touchstart', handleDmInteraction);
async function showPinnedMessages(forceReload = false, showPanel = true) {
if (!forceReload && currentDetailsContentTitle === "title.pinned.messages" && savedDetailsHtml) {
if (showPanel) mobileNavDetails();
return;
}
if (showPanel) mobileNavDetails();
// Do not show loading html on refresh if we are already in pinned messages, to avoid jarring flashing
if (!forceReload) {
let loadingHtml = `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>desc.messages.loading</blah></div>`;
switchDetailsContent("title.pinned.messages", loadingHtml, true, "restoreDmDetails()", true);
}
try {
let res = await fetchEncrypted("dm/messages/pinned", currentDmId);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
let entries = Object.entries(msgs).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
if (entries.length === 0) {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.pinned.messages.empty</blah></div>`, true, "restoreDmDetails()");
return;
}
let innerHtml = await generateMessagesHtml(entries, true);
let html = `<div id="pinned-messages" style="display: flex; flex-direction: column; padding: 1rem; gap: 0.5rem; overflow-y: auto; height: 100%;">
${innerHtml}
</div>`;
switchDetailsContent("title.pinned.messages", html, true, "restoreDmDetails()");
} else {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error:message.not.found</blah></div>`, true, "restoreDmDetails()");
}
} catch (e) {
console.error(e);
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.network.error</blah></div>`, true, "restoreDmDetails()");
}
}
function scrollToPinnedMessage(e, msgId) {
if (e && e.target) {
if (e.target.closest('a') ||
e.target.closest('button') ||
e.target.closest('.attachment-box') ||
e.target.closest('.chat-message-replied') ||
e.target.closest('.reaction-pill') ||
e.target.closest('.chat-message-author') ||
e.target.closest('.chat-message-pfp')) {
return;
}
}
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
}
scrollToMessage(msgId);
}

View file

@ -143,10 +143,10 @@ var chatScreen = `
</div>
<div id="attachments-preview" style="display: none; padding: 0.5rem 1rem; gap: 0.8rem; overflow-x: auto; border-top: var(--border-width) solid var(--light-border-color); background: var(--main-bg-color); align-items: center; flex-shrink: 0;"></div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem; align-items: flex-end; flex-shrink: 0;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, event)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, arguments[0])" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
${ICONS.plus({ width: "1.5rem", height: "1.5rem", stroke: "var(--main-bg-color)" })}
</button>
<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(arguments[0])" oninput="handleChatInputResize(this)"></textarea>
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
${ICONS.send()}
</button>
@ -236,9 +236,9 @@ var attachmentMenu = `
${ICONS.poll()}
<span class="blah">action.attachment.poll</span>
</button>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
`;
var addSpaceMenu = `
@ -255,7 +255,7 @@ var messageContextMenu = `
${ICONS.replyAction()}
<span class="blah">title.reply.message</span>
</button>
<button id="context-react-btn" onclick="openReactionPicker(event)">
<button id="context-react-btn" onclick="openReactionPicker(arguments[0])">
${ICONS.react()}
<span class="blah">title.react.message</span>
</button>
@ -263,11 +263,12 @@ var messageContextMenu = `
${ICONS.delete({ fill: "var(--big-red)" })}
<span class="blah" style="color: var(--big-red)">title.delete.message</span>
</button>
<div id="context-pin-container"></div>
`;
//elements
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right">${ICONS.pin({ width: "1.5rem", height: "1.5rem" })}</button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right" onclick="showPinnedMessages()">${ICONS.pin({ width: "1.5rem", height: "1.5rem" })}</button>`;
var callBtnHtml = `<button class="topbar-action-btn room-action-call right">${ICONS.call({ width: "1.5rem", height: "1.5rem" })}</button>`;
var settingsBar = `

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Type purr / meow...",
"action.cancel": "Cancel",
"title.crop.image": "Crop Image",
"title.zoom": "Zoom"
"title.zoom": "Zoom",
"title.pin.message": "Pin",
"title.unpin.message": "Unpin",
"message.pinned.by": "{0} pinned a {1}.",
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned meows",
"title.message.lowercase": "meowsage"
}

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Type text / emoji...",
"action.cancel": "Cancel",
"title.crop.image": "Crop Image",
"title.zoom": "Zoom"
"title.zoom": "Zoom",
"title.pin.message": "Pin",
"title.unpin.message": "Unpin",
"message.pinned.by": "{0} pinned a {1}.",
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned messages",
"title.message.lowercase": "message"
}

View file

@ -194,5 +194,12 @@
"placeholder.type.text.emoji": "Wpisz tekst / emoji...",
"action.cancel": "Anuluj",
"title.crop.image": "Przytnij obraz",
"title.zoom": "Przybliżenie"
"title.zoom": "Przybliżenie",
"title.pin.message": "Przypnij",
"title.unpin.message": "Odepnij",
"message.pinned.by": "{0} przypiął {1}.",
"message.unpinned.by": "{0} odpiął {1}.",
"title.pinned.messages": "Przypięte",
"error.pinned.messages.empty": "Brak przypiętych wiadomości",
"title.message.lowercase": "wiadomość"
}

View file

@ -16,7 +16,6 @@ const ICONS = {
_feather: function(content, props) {
return this._create(content, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "none", stroke: "var(--text-color)", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }, props);
},
addChat: (props) => ICONS._material(`<path d="M120-160v-600q0-33 23.5-56.5T200-840h480q33 0 56.5 23.5T760-760v203q-10-2-20-2.5t-20-.5q-10 0-20 .5t-20 2.5v-203H200v400h283q-2 10-2.5 20t-.5 20q0 10 .5 20t2.5 20H240L120-160Zm160-440h320v-80H280v80Zm0 160h200v-80H280v80Zm400 280v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM200-360v-400 400Z"/>`, props),
createGroup: (props) => ICONS._material(`<path d="M500-482q29-32 44.5-73t15.5-85q0-44-15.5-85T500-798q60 8 100 53t40 105q0 60-40 105t-100 53Zm220 322v-120q0-36-16-68.5T662-406q51 18 94.5 46.5T800-280v120h-80Zm80-280v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Zm-593-87q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM0-160v-112q0-34 17.5-62.5T64-378q62-31 126-46.5T320-440q66 0 130 15.5T576-378q29 15 46.5 43.5T640-272v112H0Zm320-400q33 0 56.5-23.5T400-640q0-33-23.5-56.5T320-720q-33 0-56.5 23.5T240-640q0 33 23.5 56.5T320-560ZM80-240h480v-32q0-11-5.5-20T540-306q-54-27-109-40.5T320-360q-56 0-111 13.5T100-306q-9 5-14.5 14T80-272v32Zm240-400Zm0 400Z"/>`, props),
createSpace: (props) => ICONS._material(`<path d="M80-120v-720h400v160h400v320h-80v-240H480v80h80v80h-80v80h80v80h-80v80h160v80H80Zm80-80h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm160 480h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80Zm0-160h80v-80h-80v80ZM800-40v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM640-440v-80h80v80h-80Zm0 160v-80h80v80h-80Z"/>`, props),
@ -28,8 +27,8 @@ const ICONS = {
delete: (props) => ICONS._material(`<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"/>`, props),
menu: (props) => ICONS._material(`<path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/>`, props),
arrowBack: (props) => ICONS._material(`<path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/>`, props),
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280q0-33 23.5-56.5T400-840h160q33 0 56.5 23.5T640-760v280Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12Z"/>`, props),
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12ZM241-600l66-66-17-94h-89q5 41 14 81t26 79Zm358 358q39 17 79.5 27t81.5 13v-88l-94-19-67 67ZM241-600Zm358 358Z"/>`, props),
account: (props) => ICONS._material(`<path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>`, props),
settings: (props) => ICONS._material(`<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/>`, props),
profile: (props) => ICONS._material(`<path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/>`, props),

View file

@ -1350,7 +1350,14 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
var currentDetailsContentTitle = null;
var currentDetailsContentHtml = null;
async function switchDetailsContent(title, content) {
var savedDetailsHtml = null;
var savedDetailsTitle = null;
async function switchDetailsContent(title, content, showBack = false, backAction = "", saveCurrent = false) {
if (saveCurrent && currentDetailsContentTitle !== title) {
savedDetailsHtml = currentDetailsContentHtml;
savedDetailsTitle = currentDetailsContentTitle;
}
if (currentDetailsContentTitle === title && currentDetailsContentHtml === content) return;
currentDetailsContentTitle = title;
currentDetailsContentHtml = content;
@ -1364,13 +1371,14 @@ async function switchDetailsContent(title, content) {
roomsTopBarTransition.style.opacity = "0";
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 55 * rem) {
if (window.innerWidth > 55 * rem || mainScreen.classList.contains('mobile-details')) {
await delay(200);
}
let pinCallBtns = `${pinBtnHtml}${callBtnHtml}`;
roomsTopBarTransition.innerHTML = `<inherit>${processBlah(title)}</inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let pinCallBtns = showBack ? `${callBtnHtml}` : `${pinBtnHtml}${callBtnHtml}`;
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html");
@ -1495,7 +1503,7 @@ document.addEventListener("click", (e) => {
if (mainScreen && mainScreen.classList.contains('mobile-details')) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('#room-topbar-clickable') && !e.target.closest('.profile-popup-container')) {
if (!roomDetailsBar.contains(e.target) && !e.target.closest('#room-topbar-clickable') && !e.target.closest('.profile-popup-container') && !e.target.closest('.room-action-pin')) {
mobileNavBack();
}
}
@ -1513,6 +1521,21 @@ if (App) {
}
function popstate() {
if (fixedContextMenu && fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
return;
}
let cropperPopup = document.getElementById("cropper-popup-container");
if (cropperPopup && cropperPopup.classList.contains("show")) {
cropperPopup.classList.remove("show");
return;
}
if (isProfileOpen) {
closeProfile();
return;
@ -1582,7 +1605,17 @@ async function openProfile(accountId) {
let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`;
if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner" onerror="this.outerHTML='<div class=\\'profile-banner\\'></div>'">`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner">`;
setTimeout(() => {
let bannerImg = document.querySelector("#profile-popup-inner .profile-banner");
if (bannerImg) {
bannerImg.addEventListener("error", function() {
let div = document.createElement("div");
div.className = "profile-banner";
this.parentNode.replaceChild(div, this);
});
}
}, 50);
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
if (bio.startsWith("error:")) bio = "";
@ -1624,7 +1657,8 @@ async function openProfile(accountId) {
}
if (hasDm) {
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', '${nameWithHost}', '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
let safeName = encodeURIComponent(nameWithHost).replace(/'/g, "%27");
actionBtnHtml = `<button class="profile-action-btn" onclick="openDm('${dmRoomId}', decodeURIComponent('${safeName}'), '${accountId}'); closeProfile();">${processBlah('action.open.dm')}</button>`;
} else {
let sentInvite = false;
let receivedInvite = false;
@ -2141,8 +2175,10 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
html += `
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', '${targetUsername}', '${targetId}')">
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
<img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${displayName}</span>
@ -2342,12 +2378,7 @@ function truncateFilename(name, maxLength = 20) {
return baseName.substring(0, half) + "..." + baseName.substring(baseName.length - (allowedBaseLength - half)) + ext;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
let entries = Object.entries(messages).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
async function generateMessagesHtml(entries, isPinnedContext = false) {
let html = "";
let lastAuthor = null;
@ -2388,7 +2419,10 @@ async function renderMessages(messages, isPrepend = false) {
? `${ICONS.image({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`
: `${ICONS.download({ width: "1.5rem", height: "1.5rem", stroke: "currentColor" })}`;
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="downloadAndRenderAttachment('${msgId}', '${att.id}', '${escapeHtml(att.type)}', '${escapeHtml(att.name)}')">
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'))">
<div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -2418,7 +2452,33 @@ async function renderMessages(messages, isPrepend = false) {
// Also still process the whole thing in case it's just "dm.begin.notice" without {blah()}
content = processBlah(content);
if (msg.type === "larp.info") {
if (msg.type && msg.type.startsWith("larp.info")) {
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin") {
let authorName = "Unknown";
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
if (fullUsername && !fullUsername.startsWith("error:")) {
dmUsernameCache[msg.author] = fullUsername;
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, fullUsername);
}
}
if (dmUsernameCache[msg.author]) {
authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]);
} else {
authorName = escapeHtml(msg.author.split(';')[0]);
}
}
let isPinned = msg.type === "larp.info.pin";
let actionStr = isPinned ? "message.pinned.by" : "message.unpinned.by";
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr);
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
}
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem;">${content}</div>`;
lastAuthor = null;
} else {
@ -2529,7 +2589,7 @@ async function renderMessages(messages, isPrepend = false) {
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
}
let reactionsHtml = "";
@ -2561,7 +2621,8 @@ async function renderMessages(messages, isPrepend = false) {
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>`;
let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`;
reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${msgId}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
}
reactionsHtml += `</div>`;
}
@ -2571,11 +2632,11 @@ async function renderMessages(messages, isPrepend = false) {
}
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(event, '${msgId}')">
<div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${msgId}" id="${isPinnedContext ? `pinned-` : ``}msg-${msgId}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${msgId}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${msgId}')">
${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author" onclick="openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
@ -2588,6 +2649,18 @@ async function renderMessages(messages, isPrepend = false) {
}
}
return html;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
let entries = Object.entries(messages).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
let html = await generateMessagesHtml(entries, false);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem);
if (window.forceScrollToBottom) {
@ -2750,10 +2823,8 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(msgId, attId, type, name) {
async function downloadAndRenderAttachment(container, msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
let container = document.getElementById(`att-container-${msgId}-${attId}`);
if (!container) return;
if (container.dataset.loaded === "true") {
@ -3078,6 +3149,9 @@ function setupWebSocket() {
if (dmMessageLoadTimeout) clearTimeout(dmMessageLoadTimeout);
dmMessageLoadTimeout = setTimeout(() => {
loadDmMessages(msgDmId);
if (currentDetailsContentTitle === "title.pinned.messages") {
showPinnedMessages(true, false);
}
}, 300);
}
}
@ -3119,6 +3193,41 @@ function handleMessageContextMenu(e, msgId) {
}
}
if (reactBtn) reactBtn.style.display = "";
let pinContainer = fixedContextMenu.querySelector("#context-pin-container");
if (pinContainer) {
let isPinned = msg && msg.pinned === "true";
pinContainer.innerHTML = `
<button onclick="togglePinMessage('${msgId}')">
${ICONS.pin()}
<span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span>
</button>`;
}
}
}
async function togglePinMessage(msgId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
if (typeof clearContextMenuStyles === "function") clearContextMenuStyles();
try {
let payload = JSON.stringify({
string1: currentDmId,
string2: msgId
});
await fetchEncrypted("dm/message/pin/toggle", payload);
} catch (e) {
console.error(e);
showBlahNotification("error.network.error");
}
}
async function restoreDmDetails() {
if (savedDetailsHtml && savedDetailsTitle) {
await switchDetailsContent(savedDetailsTitle, savedDetailsHtml);
savedDetailsHtml = null;
savedDetailsTitle = null;
}
}
@ -3245,9 +3354,12 @@ async function loadEmotes() {
reactionPickerMenuHtml = `
<div style="display: flex; flex-direction: column; gap: 0.4rem; padding: 0.2rem; min-width: 14rem;">
<input type="text" id="reaction-picker-search" placeholder="{blah(placeholder.type.text.emoji)}" oninput="filterReactions(this.value)" onkeydown="if(event.key === 'Enter') { if(this.value.trim() !== '') reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(this.value)); }" style="width: auto; align-self: stretch; margin: 0; box-sizing: border-box; padding: 0.5rem 0.6rem; border-radius: 0.65rem; border: var(--border-width) solid var(--light-border-color); background: transparent; color: var(--text-color); font-family: inherit;">
<input type="text" id="reaction-picker-search" placeholder="{blah(placeholder.type.text.emoji)}" oninput="filterReactions(this.value)" onkeydown="if(arguments[0].key === 'Enter') { if(this.value.trim() !== '') reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(this.value)); }" style="width: auto; align-self: stretch; margin: 0; box-sizing: border-box; padding: 0.5rem 0.6rem; border-radius: 0.65rem; border: var(--border-width) solid var(--light-border-color); background: transparent; color: var(--text-color); font-family: inherit;">
<div id="reaction-picker-grid" style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.2rem; max-height: 12rem; overflow-y: auto; overflow-x: hidden;">
${loadedEmotes.map(emoji => `<button onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent('${emoji.char}'))" data-name="${emoji.name}" style="min-width: unset; width: 100%; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; padding: 0; font-size: 1.2rem; border-radius: 0.65rem;" class="reaction-emoji-btn">${emoji.char}</button>`).join('')}
${loadedEmotes.map(emoji => {
let safeChar = encodeURIComponent(emoji.char).replace(/'/g, "%27");
return `<button onclick="reactMessagePrompt(currentContextMenuMsgId, '${safeChar}')" data-name="${emoji.name}" style="min-width: unset; width: 100%; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; padding: 0; font-size: 1.2rem; border-radius: 0.65rem;" class="reaction-emoji-btn">${emoji.char}</button>`;
}).join('')}
</div>
<button class="reaction-text-btn" onclick="reactMessagePrompt(currentContextMenuMsgId, encodeURIComponent(document.getElementById('reaction-picker-search').value))" style="margin: 0; padding: 0.5rem; justify-content: center; border-radius: 0.65rem; width: 100%;">
<span class="blah">title.text.react</span>
@ -3899,4 +4011,63 @@ function handleDmInteraction() {
document.addEventListener('click', handleDmInteraction);
document.addEventListener('keydown', handleDmInteraction);
document.addEventListener('touchstart', handleDmInteraction);
document.addEventListener('touchstart', handleDmInteraction);
async function showPinnedMessages(forceReload = false, showPanel = true) {
if (!forceReload && currentDetailsContentTitle === "title.pinned.messages" && savedDetailsHtml) {
if (showPanel) mobileNavDetails();
return;
}
if (showPanel) mobileNavDetails();
// Do not show loading html on refresh if we are already in pinned messages, to avoid jarring flashing
if (!forceReload) {
let loadingHtml = `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>desc.messages.loading</blah></div>`;
switchDetailsContent("title.pinned.messages", loadingHtml, true, "restoreDmDetails()", true);
}
try {
let res = await fetchEncrypted("dm/messages/pinned", currentDmId);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
let entries = Object.entries(msgs).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
if (entries.length === 0) {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.pinned.messages.empty</blah></div>`, true, "restoreDmDetails()");
return;
}
let innerHtml = await generateMessagesHtml(entries, true);
let html = `<div id="pinned-messages" style="display: flex; flex-direction: column; padding: 1rem; gap: 0.5rem; overflow-y: auto; height: 100%;">
${innerHtml}
</div>`;
switchDetailsContent("title.pinned.messages", html, true, "restoreDmDetails()");
} else {
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error:message.not.found</blah></div>`, true, "restoreDmDetails()");
}
} catch (e) {
console.error(e);
switchDetailsContent("title.pinned.messages", `<div style="padding: 2rem; text-align: center; opacity: 0.5;"><blah>error.network.error</blah></div>`, true, "restoreDmDetails()");
}
}
function scrollToPinnedMessage(e, msgId) {
if (e && e.target) {
if (e.target.closest('a') ||
e.target.closest('button') ||
e.target.closest('.attachment-box') ||
e.target.closest('.chat-message-replied') ||
e.target.closest('.reaction-pill') ||
e.target.closest('.chat-message-author') ||
e.target.closest('.chat-message-pfp')) {
return;
}
}
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth <= 55 * rem) {
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
if (typeof setActiveSidebarIndicator === "function" && typeof currentMainIndicator !== "undefined") {
setActiveSidebarIndicator(currentMainIndicator);
}
}
scrollToMessage(msgId);
}

View file

@ -143,10 +143,10 @@ var chatScreen = `
</div>
<div id="attachments-preview" style="display: none; padding: 0.5rem 1rem; gap: 0.8rem; overflow-x: auto; border-top: var(--border-width) solid var(--light-border-color); background: var(--main-bg-color); align-items: center; flex-shrink: 0;"></div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem; align-items: flex-end; flex-shrink: 0;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, event)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, arguments[0])" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
${ICONS.plus({ width: "1.5rem", height: "1.5rem", stroke: "var(--main-bg-color)" })}
</button>
<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(arguments[0])" oninput="handleChatInputResize(this)"></textarea>
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
${ICONS.send()}
</button>
@ -236,9 +236,9 @@ var attachmentMenu = `
${ICONS.poll()}
<span class="blah">action.attachment.poll</span>
</button>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(arguments[0])" multiple>
`;
var addSpaceMenu = `
@ -255,7 +255,7 @@ var messageContextMenu = `
${ICONS.replyAction()}
<span class="blah">title.reply.message</span>
</button>
<button id="context-react-btn" onclick="openReactionPicker(event)">
<button id="context-react-btn" onclick="openReactionPicker(arguments[0])">
${ICONS.react()}
<span class="blah">title.react.message</span>
</button>
@ -263,11 +263,12 @@ var messageContextMenu = `
${ICONS.delete({ fill: "var(--big-red)" })}
<span class="blah" style="color: var(--big-red)">title.delete.message</span>
</button>
<div id="context-pin-container"></div>
`;
//elements
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right">${ICONS.pin({ width: "1.5rem", height: "1.5rem" })}</button>`;
var pinBtnHtml = `<button class="topbar-action-btn room-action-pin right" onclick="showPinnedMessages()">${ICONS.pin({ width: "1.5rem", height: "1.5rem" })}</button>`;
var callBtnHtml = `<button class="topbar-action-btn room-action-call right">${ICONS.call({ width: "1.5rem", height: "1.5rem" })}</button>`;
var settingsBar = `