`;
console.error(e);
}
}
function closeProfile() {
let popupContainer = document.getElementById("profile-popup-container");
popupContainer.classList.remove("show");
isProfileOpen = false;
setTimeout(() => {
popupContainer.style.display = "none";
}, 300);
}
function setActiveRoombarItem(itemId) {
let items = document.querySelectorAll('.collapse-text-button');
items.forEach(item => item.classList.remove('selected'));
if (itemId) {
let activeItem = document.getElementById(itemId);
if (activeItem) activeItem.classList.add('selected');
}
}
async function renderInvites(res, type) {
let container = document.getElementById("invites-container");
if (!container) return;
if (res && res != "") {
res = JSON.parse(res);
let invites = [
...Object.entries(res.dms).map(([id, value]) => ({
id,
type: "dm",
...value
})),
...Object.entries(res.groups).map(([id, value]) => ({
id,
type: "group",
...value
}))
].sort((a, b) => b.timestamp - a.timestamp);
container.classList.remove("empty");
let html = "";
let count = 0;
for (let i = 0; i < invites.length; i++) {
let invite = invites[i];
if (invite.type === "dm") {
let id = invite.id;
if (!id.includes(":")) {
id += `:${host}`;
}
let onlyId = id.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${id}`);
if (!(username && username.trim() !== "")) return;
count++;
let pfp = await getAvatarUrl(id, username);
let actions = "";
let desc = "";
if (type === "received") {
desc = `:desc.invite.dm.received:${username};${onlyId}`;
actions = `
`;
} else {
desc = `:desc.invite.dm.sent:${username};${onlyId}`;
actions = `
`;
}
let entry = invitesEntry
.replaceAll("{username}", username.split(':')[0])
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", desc)
.replaceAll("{actions}", actions)
.replaceAll("{id}", id);
html += entry;
}
}
if (count > 0) {
container.innerHTML = html;
} else {
container.classList.add("empty");
container.innerHTML = invitesEmptyState;
}
} else {
container.classList.add("empty");
container.innerHTML = invitesEmptyState;
}
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
window.cachedInvites = {'received': null, 'sent': null};
async function loadInvites(tab) {
try {
showAction(`action.fetching.invites.${tab === 'received' ? 'recv' : 'sent'}`, `fetching.invites.${tab}`);
if (window.cachedInvites[tab]) {
await renderInvites(window.cachedInvites[tab], tab);
}
let res = await fetchEncrypted(`user/invites/${tab}`);
window.cachedInvites[tab] = res;
await renderInvites(res, tab);
} catch (e) {
showBlahNotification("error:something.wrong");
console.error(e);
await renderInvites("", tab);
}
clearAction(`fetching.invites.${tab}`);
}
async function switchInvitesTab(tab) {
if (!document.getElementById(`tab-invites-${tab}`).classList.contains('tab-inactive')) return;
document.getElementById('tab-invites-received').classList.add('tab-inactive');
document.getElementById('tab-invites-sent').classList.add('tab-inactive');
document.getElementById(`tab-invites-${tab}`).classList.remove('tab-inactive');
await loadInvites(tab);
}
async function sendInviteTo(targetId) {
try {
showAction("action.invite.sending", "invite.action");
let res = await fetchEncrypted("user/dm/invite", targetId);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
if (typeof loadInvites === 'function') await loadInvites('sent');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function acceptInvite(targetId) { //TODO: Implement key generation
try {
showAction("action.invite.accepting", "invite.action");
await ensureUserKeys();
const inviterPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
const inviterPublicKeys = JSON.parse(inviterPublicKeysRaw);
//generate room key
const roomKey = randomRoomKey256Chars();
//encrypt for self
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
//encrypt for inviter
const hybrid = await deriveHybridKeyToUser(inviterPublicKeys);
const encForInviter = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
const setupPayload = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForInviter
});
let payload = JSON.stringify({
string1: targetId,
string2: selfWrapped,
string3: setupPayload
});
let res = await fetchEncrypted("user/dm/create", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.accepted");
await loadInvites('received');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function declineInvite(username) {
try {
showAction("action.invite.declining", "invite.action");
let res = await fetchEncrypted("user/dm/invite/decline", username);
clearAction("invite.action");
showBlahNotification(res || "success:invite.declined");
await loadInvites('received');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function revokeInvite(username) {
try {
showAction("action.invite.revoking", "invite.action");
let res = await fetchEncrypted("user/dm/invite/revoke", username);
clearAction("invite.action");
showBlahNotification(res || "success:invite.revoked");
await loadInvites('sent');
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
function switchNotifisTab(tab) {
if (!document.getElementById(`tab-notifis-${tab}`).classList.contains('tab-inactive')) return;
document.getElementById('tab-notifis-all').classList.add('tab-inactive');
document.getElementById('tab-notifis-unread').classList.add('tab-inactive');
document.getElementById(`tab-notifis-${tab}`).classList.remove('tab-inactive');
}
async function gotoInbox() {
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarInboxIndicator);
await switchRoomsBar("title.inbox", inboxRoomBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
gotoInvites();
}
}
async function gotoInvites() {
setActiveRoombarItem('collapse-invites');
await switchRoomContent("title.invites", invitesScreen, false);
switchInvitesTab('received');
}
async function gotoNotifis() {
setActiveRoombarItem('collapse-notifis');
await switchRoomContent("title.notifications", notifisScreen, false);
switchNotifisTab('all');
}
function gotoCreateSpace() {
fixedContextMenu.classList.remove("show");
if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar
switchRoomContent("title.create.space", createSpaceScreen, false);
}
function gotoJoinSpace() {
fixedContextMenu.classList.remove("show");
if (roomsBarContainer) roomsBarContainer.style.display = "none"; //hide roombar
switchRoomContent("title.join.space", joinSpaceScreen, false);
}
async function gotoHome(splash = true) {
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
switchRoomContent("title.splash", splashScreen, false);
}
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
await refreshDms(210);
}
function setActiveSidebarIndicator(element, isTemporary = false) {
let indicators = document.getElementsByTagName("indicator");
for (let i = 0; i < indicators.length; i++) {
indicators[i].classList.remove("active");
}
if (element) {
element.classList.add("active");
}
if (!isTemporary && element) {
currentMainIndicator = element;
}
}
function mobileNavBack() {
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
mainScreen.classList.add('mobile-content');
setActiveSidebarIndicator(currentMainIndicator);
} else {
setActiveRoombarItem(null);
if (roomsBarContainer) {
roomsBarContainer.style.display = ""; //restore roombar on mobile
void roomsBarContainer.offsetWidth;
}
mainScreen.classList.remove('mobile-content');
setActiveSidebarIndicator(currentMainIndicator);
}
}
function mobileNavDetails() {
mainScreen.classList.add('mobile-details');
}
let touchStartX = 0;
let touchEndX = 0;
let touchStartY = 0;
let touchEndY = 0;
let touchMoved = false;
let pressDate;
document.addEventListener('touchstart', e => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
touchMoved = false;
activeTouchButton = e.target.closest('button, input, textarea');
if (activeTouchButton) {
activeTouchButton.classList.add('is-pressed');
pressDate = Date.now();
}
}, {passive: true});
document.addEventListener('touchmove', e => {
if (!touchMoved) {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
let diffX = Math.abs(e.changedTouches[0].screenX - touchStartX);
let diffY = Math.abs(e.changedTouches[0].screenY - touchStartY);
if (diffX > rem || diffY > rem) {
touchMoved = true;
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
document.activeElement.blur();
}
if (activeTouchButton) {
activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null;
}
}
}
}, {passive: true});
document.addEventListener('touchend', async (e) => {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
isAdjusting = false; //resize scroll fix
handleMobileSwipe();
if (activeTouchButton) {
if (!touchMoved) {
if (activeTouchButton.tagName !== 'INPUT' && activeTouchButton.tagName !== 'TEXTAREA') {
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
document.activeElement.blur();
}
if (e.cancelable) {
e.preventDefault();
}
activeTouchButton.click();
} else {
activeTouchButton.focus();
}
}
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
if (elapsed < 100) {
const remaining = 100 - elapsed;
await delay(remaining);
}
activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null;
}
});
document.addEventListener('touchcancel', () => {
if (activeTouchButton) {
activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null;
}
});
document.addEventListener('contextmenu', e => {
if (e.target.closest('button')) {
e.preventDefault();
}
});
function handleMobileSwipe() {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) return;
let diffX = touchEndX - touchStartX;
let diffY = touchEndY - touchStartY;
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) {
if (diffX > 0) {
mobileNavBack();
} else {
if (mainScreen.classList.contains('mobile-content') && roomDetailsBar.style.display !== 'none') {
mobileNavDetails();
}
}
}
}
async function renderDms(res) {
let container = document.getElementById("dms-list");
if (!container) return;
if (res && res !== "") {
let parsed = JSON.parse(res);
let dms = parsed.dms || {};
let dmEntries = Object.entries(dms);
//Sort by timestamp descending
dmEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0));
let html = "";
let count = 0;
for (let [dmId, dmData] of dmEntries) {
let parts = dmId.split('_');
let myId = `${id};${host}`;
let targetId = parts[0] === myId ? parts[1] : parts[0];
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = targetUsername.split(':')[0];
html += `
`;
count++;
}
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
} else {
container.innerHTML = `
desc.no.dms
`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
} else {
container.innerHTML = `
desc.no.dms
`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
}
var currentDmId = null;
var currentDmKey = null; // AES key for encrypting/decrypting messages
var dmUsernameCache = {};
var dmPfpCache = {};
var loadedMessages = {};
var oldestLoadedMsgId = null;
var isLoadingOlderMessages = false;
function escapeHtml(unsafe) {
if (!unsafe) return "";
return unsafe
.replaceAll(/&/g, "&")
.replaceAll(//g, ">")
.replaceAll(/"/g, """)
.replaceAll(/'/g, "'");
}
async function openDm(dmId, username, targetId) {
try {
showAction("action.dm.opening", "dmopen");
currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) {
throw new Error("Missing DM room key");
}
currentDmId = dmId;
loadedMessages = {};
oldestLoadedMsgId = null;
isLoadingOlderMessages = false;
let pfp = await getAvatarUrl(targetId, username);
let iconHtml = ``;
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let msgContainer = document.getElementById("chat-messages");
if (msgContainer) {
msgContainer.innerHTML = `
desc.messages.loading
`;
let blahTags = msgContainer.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
await loadDmMessages(dmId);
setupChatScrollListener();
if (dmMessagePollInterval) clearInterval(dmMessagePollInterval);
dmMessagePollInterval = setInterval(() => {
if (currentDmId === dmId) {
loadDmMessages(dmId);
} else {
clearInterval(dmMessagePollInterval);
}
}, 15000);
setActiveRoombarItem(`dm-btn-${dmId}`);
clearAction("dmopen");
} catch (e) {
clearAction("dmopen");
console.error(e);
if (e.message === "error:keys.local.server.mismatch" || e.message === "error:keys.server.decrypt.failed") {
showBlahNotification(e.message);
} else {
showBlahNotification("error:dm.open.failed");
}
}
}
async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
try {
let payload = JSON.stringify({string1: dmId, string2: "50", string3: startOffset});
let res = await fetchEncrypted("dm/messages/get", payload);
if (res && res.startsWith("error:")) {
showBlahNotification(res);
return;
}
let messages = JSON.parse(res);
let ids = Object.keys(messages).map(id => parseInt(id));
if (ids.length > 0) {
let minId = Math.min(...ids);
if (oldestLoadedMsgId === null || minId < oldestLoadedMsgId) {
oldestLoadedMsgId = minId;
}
}
if (ids.length < 50 && startOffset !== "") {
oldestLoadedMsgId = 0;
}
Object.assign(loadedMessages, messages);
await renderMessages(loadedMessages, isPrepend);
} catch (e) {
console.error(e);
showBlahNotification("error:dm.messages.fetch.failed");
}
}
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 = "";
let lastAuthor = null;
for (let [msgId, msg] of entries) {
let content = msg.content;
let reactions = msg.reactions || "";
let decrypted = false;
if (msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
if (reactions) reactions = await decryptAesGcmFromBase64(reactions, dmKeyBytes);
decrypted = true;
} catch (e) {
content = `error:messages.decrypt.failed`;
decrypted = false;
}
} else {
decrypted = true;
}
if (decrypted) {
content = escapeHtml(content);
}
content = content.replace(/\{blah\((.*?)\)\}/g, (match, p1) => {
return processBlah(p1);
});
// Also still process the whole thing in case it's just "dm.begin.notice" without {blah()}
content = processBlah(content);
if (msg.type === "larp.info") {
html += `
${content}
`;
lastAuthor = null;
} else {
let showAvatar = lastAuthor !== msg.author;
lastAuthor = msg.author;
let authorName = "Unknown";
let pfp = "assets/default_avatar.png";
if (msg.author !== "0") {
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] && !dmPfpCache[msg.author]) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
}
if (dmUsernameCache[msg.author]) {
authorName = dmUsernameCache[msg.author].split(':')[0];
pfp = dmPfpCache[msg.author];
}
}
let date = new Date(parseInt(msg.timestamp));
let timeStr = date.toLocaleString();
let extraClass = showAvatar ? "with-avatar" : "";
let repliedHtml = "";
if (msg.responded && msg.responded !== "") {
let respondedId = msg.responded;
if (typeof respondedId === 'number') respondedId = respondedId.toString();
let respondedAuthorId = null;
if (typeof respondedId === 'string' && respondedId.includes(':')) {
let parts = respondedId.split(':');
respondedId = parts[0];
respondedAuthorId = parts[1];
}
let replyAuthor = processBlah('unknown.author');
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 = processBlah('unknown.author');
setTimeout(async () => {
try {
let aId = respondedAuthorId;
if (!aId) {
let payload = JSON.stringify({
string1: currentDmId,
string2: "1",
string3: respondedId
});
let res = await fetchEncrypted("dm/messages/get", payload);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
if (msgs[respondedId] && msgs[respondedId].author !== "0") {
aId = msgs[respondedId].author;
}
}
}
if (aId) {
let fname = dmUsernameCache[aId];
if (!fname) {
fname = await fetchAsync(`${url}/idtoname?id=${aId}`);
if (fname && !fname.startsWith("error:")) dmUsernameCache[aId] = fname;
}
if (fname && !fname.startsWith("error:")) {
let sName = fname.split(':')[0];
window.replyAuthorCache[respondedId] = sName;
let el = document.getElementById(randId);
if (el) {
el.innerHTML = ` ${processBlah('title.replied.to')} ${sName}`;
}
}
}
} catch (e) {
}
}, 10);
}
let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `
${processBlah('title.replied.to')} ${replyAuthor}
`;
}
let reactionsHtml = "";
if (reactions) {
try {
let rxArr = JSON.parse(reactions);
if (rxArr.length > 0) {
let rxCounts = {};
for (let rx of rxArr) {
let parts = rx.split(':');
if (parts.length < 2) continue;
let rxVal = parts.slice(1).join(':');
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false};
rxCounts[rxVal].count++;
if (parts[0] === id) rxCounts[rxVal].me = true;
}
let rxKeys = Object.keys(rxCounts);
if (rxKeys.length > 0) {
reactionsHtml = `
`;
for (let rxVal of rxKeys) {
let style = rxCounts[rxVal].me ? "background: rgba(255, 255, 255, 0.12); border-color: var(--text-color);" : "background: rgba(255, 255, 255, 0.06);";
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal);
reactionsHtml += `${processBlah(escapedRxVal)} ${rxCounts[rxVal].count > 1 ? rxCounts[rxVal].count : ""}`;
}
reactionsHtml += `
`;
}
}
} catch (e) {
}
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `` : ``;
html += `
${showAvatar ? `` : ``}
${showAvatar ? `
${authorName}${timeStr}
` : ""}
${repliedHtml}
${redactedIcon}${content}
${reactionsHtml}
`;
}
}
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
let wasAtBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < (1.5 * rem);
if (window.forceScrollToBottom) {
wasAtBottom = true;
window.forceScrollToBottom = false;
}
let oldScrollTop = container.scrollTop;
let oldScrollHeight = container.scrollHeight;
container.innerHTML = html;
if (wasAtBottom) {
container.scrollTop = container.scrollHeight;
} else if (isPrepend) {
container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);
} else {
container.scrollTop = oldScrollTop;
}
}
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) {
//chat scroll fix
ignoreScroll = true;
container.scrollTop = container.scrollHeight - container.clientHeight - lastChatScroll;
//context menu fix
let menuRect = fixedContextMenu.getBoundingClientRect();
if (menuRect.bottom > window.innerHeight) {
fixedContextMenu.style.top = `${Math.max(10, window.innerHeight - menuRect.height - 10)}px`;
}
requestAnimationFrame(() => {
ignoreScroll = false;
});
await delay(1);
}
});
function setupChatScrollListener() {
let container = document.getElementById("chat-messages");
if (!container) return;
container.onscroll = async () => {
if (!ignoreScroll) {
isAdjusting = false;
lastChatScroll = container.scrollHeight - container.scrollTop - container.clientHeight;
}
if (container.scrollTop === 0 && !isLoadingOlderMessages && oldestLoadedMsgId !== null && oldestLoadedMsgId > 0) {
isLoadingOlderMessages = true;
showAction("info.messages.loading.older", "messages.loading.older");
try {
await loadDmMessages(currentDmId, (oldestLoadedMsgId - 1).toString(), true);
isLoadingOlderMessages = false;
} catch (e) {
}
clearAction("messages.loading.older");
}
};
}
let replyingToMsgId = null;
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
if (!content) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
if (container) {
let html = `