add message pinning
All checks were successful
Android Build / publish (push) Successful in 53s
Linux Build / publish (push) Successful in 1m2s

This commit is contained in:
olcxja 2026-07-25 01:06:45 +02:00
commit 340c16b080
10 changed files with 368 additions and 52 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

@ -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();
}
}
@ -2370,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;
@ -2419,7 +2422,7 @@ async function renderMessages(messages, isPrepend = false) {
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="downloadAndRenderAttachment('${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'))">
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>
@ -2449,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 {
@ -2560,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 = "";
@ -2593,7 +2622,7 @@ async function renderMessages(messages, isPrepend = false) {
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal);
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="reactMessagePrompt('${msgId}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
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>`;
}
@ -2603,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 ${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}
@ -2620,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) {
@ -2782,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") {
@ -3110,6 +3149,9 @@ function setupWebSocket() {
if (dmMessageLoadTimeout) clearTimeout(dmMessageLoadTimeout);
dmMessageLoadTimeout = setTimeout(() => {
loadDmMessages(msgDmId);
if (currentDetailsContentTitle === "title.pinned.messages") {
showPinnedMessages(true, false);
}
}, 300);
}
}
@ -3151,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;
}
}
@ -3934,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

@ -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 = `