Files
client/www/js/messaging-ui.mjs
2026-04-17 16:52:51 -04:00

612 lines
19 KiB
JavaScript

import { mountComposer } from './post-composer.mjs';
import { mountDotMenu } from './dot-menu.mjs';
function escapeHtml(value = '') {
const span = document.createElement('span');
span.textContent = String(value || '');
return span.innerHTML;
}
function formatTime(tsSec) {
if (!tsSec) return '';
try {
return new Date(Number(tsSec) * 1000).toLocaleString();
} catch {
return String(tsSec);
}
}
function escapeCssAttr(value = '') {
const raw = String(value || '');
if (window.CSS?.escape) return window.CSS.escape(raw);
return raw.replace(/["\\]/g, '\\$&');
}
function getMessageViewKey(msg, index = 0) {
if (msg?.id) return `id:${msg.id}`;
return `fallback:${msg?.created_at || 0}:${msg?.outgoing ? 'out' : 'in'}:${index}`;
}
function getMessageViewMode(messageViewModeById, msg, index = 0) {
const mode = messageViewModeById.get(getMessageViewKey(msg, index));
if (mode === 'raw' || mode === 'json') return mode;
return 'markdown';
}
function setMessageViewMode(messageViewModeById, msg, mode, index = 0) {
const normalized = mode === 'raw' || mode === 'json' ? mode : 'markdown';
messageViewModeById.set(getMessageViewKey(msg, index), normalized);
}
function formatJsonForDisplay(content) {
const raw = String(content || '');
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed, null, 2);
} catch {
return null;
}
}
async function copyTextToClipboard(text = '') {
const plain = String(text || '');
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(plain);
return;
}
const ta = document.createElement('textarea');
ta.value = plain;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
function normalizeAttachments(attachments) {
if (!Array.isArray(attachments)) return [];
return attachments
.map((att) => {
const dataUrl = String(att?.dataUrl || '').trim();
if (!dataUrl.startsWith('data:image/')) return null;
const name = String(att?.name || '').trim() || 'image';
return {
name,
dataUrl
};
})
.filter(Boolean);
}
const CASHU_TOKEN_REGEX = /(cashu[AB][A-Za-z0-9+/_=-]+)/g;
const CASHU_MIN_BODY_LENGTH = 16;
function isLikelyCashuToken(token) {
const value = String(token || '').trim();
if (!value.startsWith('cashuA') && !value.startsWith('cashuB')) return false;
const body = value.slice(6);
if (body.length < CASHU_MIN_BODY_LENGTH) return false;
// Prevent header-only mentions like "cashuA/cashuB" from rendering as tokens.
if (!/^[A-Za-z0-9]/.test(body)) return false;
return true;
}
function findCashuTokens(rawText) {
const text = String(rawText || '');
if (!text) return [];
const matches = text.match(CASHU_TOKEN_REGEX) || [];
const deduped = [];
for (const token of matches) {
if (!isLikelyCashuToken(token)) continue;
if (!deduped.includes(token)) deduped.push(token);
}
return deduped;
}
function renderCashuQrCode(container, token) {
if (!container) return;
const value = String(token || '').trim();
if (!value) {
container.innerHTML = '';
return;
}
if (typeof qrcode !== 'function') {
container.innerHTML = '';
return;
}
try {
container.innerHTML = '';
// Use byte mode for cashu tokens so long payloads are encoded correctly.
const qr = qrcode(0, 'L');
qr.addData(value, 'Byte');
qr.make();
const svgMarkup = qr.createSvgTag(4, 1);
container.innerHTML = svgMarkup;
const svg = container.querySelector('svg');
if (svg) {
svg.removeAttribute('width');
svg.removeAttribute('height');
}
} catch {
container.innerHTML = '';
}
}
function defaultRenderMessageContent(rawText) {
const text = String(rawText || '');
if (!text) return '';
if (window.marked?.parse) {
try {
const rendered = window.marked.parse(text) || '';
if (window.DOMPurify?.sanitize) {
return window.DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: [
'a', 'p', 'br', 'em', 'strong', 'b', 'i', 'del', 's',
'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'class', 'target', 'rel', 'align'],
ALLOW_DATA_ATTR: true
});
}
return rendered;
} catch {
// Fallback to text rendering.
}
}
return escapeHtml(text).replace(/\n/g, '<br/>');
}
function normalizeMessage(input = {}) {
const source = input && typeof input === 'object' ? input : {};
const roleRaw = String(source?.role || '').trim().toLowerCase();
const outgoingByRole = roleRaw === 'user';
const outgoing = typeof source?.outgoing === 'boolean' ? source.outgoing : outgoingByRole;
let rowKind = outgoing ? 'outgoing' : 'incoming';
if (roleRaw === 'system') rowKind = 'system';
return {
...source,
id: String(source?.id || `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`),
content: String(source?.content || ''),
created_at: Number(source?.created_at || 0),
outgoing,
role: roleRaw,
rowKind,
protocol: String(source?.protocol || ''),
attachments: normalizeAttachments(source?.attachments)
};
}
export function mountMessagingWindow(hostEl, options = {}) {
if (!hostEl) throw new Error('mountMessagingWindow requires a host element');
const onSubmit = typeof options.onSubmit === 'function' ? options.onSubmit : null;
const onLoadOlder = typeof options.onLoadOlder === 'function' ? options.onLoadOlder : null;
const renderMessageContent = typeof options.renderMessageContent === 'function'
? options.renderMessageContent
: defaultRenderMessageContent;
const renderBubbleBody = typeof options.renderBubbleBody === 'function'
? options.renderBubbleBody
: null;
const onBubbleRendered = typeof options.onBubbleRendered === 'function'
? options.onBubbleRendered
: null;
const scrollThreshold = Math.max(0, Number(options.scrollThreshold ?? 100));
const loadOlderThreshold = Math.max(0, Number(options.loadOlderThreshold ?? 50));
const emptyStateText = String(options.emptyStateText || 'No messages yet');
const enableViewModeDotMenu = options.enableViewModeDotMenu !== false;
hostEl.innerHTML = '';
const paneEl = document.createElement('div');
paneEl.className = 'msg-thread-pane';
const headerEl = document.createElement('div');
headerEl.className = 'msg-thread-header';
const messagesEl = document.createElement('div');
messagesEl.className = 'msg-thread-messages';
const replyBoxEl = document.createElement('div');
replyBoxEl.className = 'msg-reply-box';
const replyInputEl = document.createElement('div');
replyInputEl.className = 'msg-reply-input';
replyInputEl.setAttribute('contenteditable', 'true');
replyBoxEl.appendChild(replyInputEl);
paneEl.appendChild(headerEl);
paneEl.appendChild(messagesEl);
paneEl.appendChild(replyBoxEl);
hostEl.appendChild(paneEl);
let messages = [];
let isLoadingOlder = false;
const messageViewModeById = new Map();
function renderHeader({ name = '', avatarUrl = '' } = {}) {
const safeName = escapeHtml(name || 'Conversation');
const safeAvatar = escapeHtml(avatarUrl || '');
if (safeAvatar) {
headerEl.innerHTML = `
<div class="msg-thread-header-row">
<img class="msg-thread-header-avatar" src="${safeAvatar}" alt="" onerror="this.style.visibility='hidden'" />
<div class="msg-thread-header-name">${safeName}</div>
</div>
`;
return;
}
headerEl.innerHTML = `
<div class="msg-thread-header-row">
<div class="msg-thread-header-avatar"></div>
<div class="msg-thread-header-name">${safeName}</div>
</div>
`;
}
function renderEmptyState() {
messagesEl.innerHTML = `<div class="msg-empty-state">${escapeHtml(emptyStateText)}</div>`;
}
function renderBubbleContent(contentEl, msg, index) {
let displayText = msg.content || '';
if (renderBubbleBody) {
const maybeDisplayText = renderBubbleBody(contentEl, msg, index);
if (typeof maybeDisplayText === 'string') {
displayText = maybeDisplayText;
} else if (!contentEl.innerHTML && !contentEl.textContent) {
contentEl.innerHTML = renderMessageContent(msg.content || '');
}
} else {
const viewMode = getMessageViewMode(messageViewModeById, msg, index);
if (viewMode === 'raw') {
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = String(msg.content || '');
} else if (viewMode === 'json') {
const formattedJson = formatJsonForDisplay(msg.content || '');
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = formattedJson || String(msg.content || '');
} else {
contentEl.style.whiteSpace = '';
contentEl.innerHTML = renderMessageContent(msg.content || '');
}
}
if (options.hydrateEntities && typeof options.hydrateEntities === 'function') {
options.hydrateEntities(contentEl);
}
return displayText;
}
function mountViewModeDotMenu(menuHost, contentEl, msg, index) {
if (!enableViewModeDotMenu || renderBubbleBody || typeof onBubbleRendered === 'function') return;
if (!menuHost || !contentEl || !msg) return;
menuHost.innerHTML = '';
const idPrefix = (msg.id || '').slice(0, 8) || `#${index + 1}`;
mountDotMenu(menuHost, {
triggerLabel: '⋯',
ariaLabel: `Message options for ${msg.outgoing ? 'sent' : 'received'} message ${idPrefix}`,
position: 'right',
items: [
{
label: 'Copy message',
onClick: async () => {
await copyTextToClipboard(msg?.content || '');
}
},
{
label: 'View as markdown',
onClick: () => {
setMessageViewMode(messageViewModeById, msg, 'markdown', index);
renderBubbleContent(contentEl, msg, index);
}
},
{
label: 'View as raw',
onClick: () => {
setMessageViewMode(messageViewModeById, msg, 'raw', index);
renderBubbleContent(contentEl, msg, index);
}
},
{
label: 'View as pretty JSON',
onClick: () => {
setMessageViewMode(messageViewModeById, msg, 'json', index);
renderBubbleContent(contentEl, msg, index);
}
}
]
});
}
function renderBubbleBodyElements(bubble, msg, index) {
bubble.innerHTML = '';
const headerEl = document.createElement('div');
headerEl.className = 'msg-bubble-header';
const headerMainEl = document.createElement('div');
headerMainEl.className = 'msg-bubble-header-main';
const avatarWrapEl = document.createElement('div');
avatarWrapEl.className = 'msg-bubble-avatar-wrap';
const senderAvatar = String(msg?.senderAvatarUrl || '').trim();
if (senderAvatar) {
const avatarImg = document.createElement('img');
avatarImg.className = 'msg-bubble-avatar';
avatarImg.src = senderAvatar;
avatarImg.alt = '';
avatarImg.loading = 'lazy';
avatarImg.referrerPolicy = 'no-referrer';
avatarImg.onerror = () => {
avatarImg.remove();
};
avatarWrapEl.appendChild(avatarImg);
}
const senderMetaEl = document.createElement('div');
senderMetaEl.className = 'msg-bubble-sender-meta';
const senderNameEl = document.createElement('div');
senderNameEl.className = 'msg-bubble-sender-name';
senderNameEl.textContent = String(msg?.senderName || (msg?.outgoing ? 'You' : 'Sender'));
const idPrefix = (msg.id || '').slice(0, 4);
const timeText = msg.created_at ? `${idPrefix} ${formatTime(msg.created_at)}`.trim() : '';
const protocolText = String(msg.protocol || '').trim();
const metaParts = [timeText, protocolText].filter(Boolean);
const headerMetaEl = document.createElement('div');
headerMetaEl.className = 'msg-bubble-header-meta';
headerMetaEl.textContent = metaParts.join(' · ');
senderMetaEl.appendChild(senderNameEl);
if (headerMetaEl.textContent) {
senderMetaEl.appendChild(headerMetaEl);
}
headerMainEl.appendChild(avatarWrapEl);
headerMainEl.appendChild(senderMetaEl);
const menuHost = document.createElement('div');
menuHost.className = 'msg-bubble-menu-host';
headerEl.appendChild(headerMainEl);
headerEl.appendChild(menuHost);
bubble.appendChild(headerEl);
const contentEl = document.createElement('div');
contentEl.className = 'msg-bubble-content';
const displayText = renderBubbleContent(contentEl, msg, index);
bubble.appendChild(contentEl);
const cashuTokens = findCashuTokens(displayText);
if (cashuTokens.length > 0) {
const cashuBlock = document.createElement('div');
cashuBlock.className = 'msg-bubble-cashu';
for (const token of cashuTokens) {
const tokenLabel = document.createElement('div');
tokenLabel.className = 'msg-bubble-cashu-token';
tokenLabel.textContent = token;
cashuBlock.appendChild(tokenLabel);
const qrWrap = document.createElement('div');
qrWrap.className = 'msg-bubble-cashu-qr';
qrWrap.title = 'Cashu token QR';
renderCashuQrCode(qrWrap, token);
cashuBlock.appendChild(qrWrap);
}
bubble.appendChild(cashuBlock);
}
if (msg.attachments.length > 0) {
const attachmentsEl = document.createElement('div');
attachmentsEl.className = 'msg-bubble-attachments';
for (const attachment of msg.attachments) {
const imageEl = document.createElement('img');
imageEl.className = 'msg-bubble-attachment-img';
imageEl.src = attachment.dataUrl;
imageEl.alt = attachment.name;
attachmentsEl.appendChild(imageEl);
}
bubble.appendChild(attachmentsEl);
}
return { contentEl, menuHost };
}
function renderMessages() {
if (!Array.isArray(messages) || messages.length === 0) {
renderEmptyState();
return;
}
messagesEl.innerHTML = '';
for (let index = 0; index < messages.length; index += 1) {
const rawMsg = messages[index];
const msg = normalizeMessage(rawMsg);
const row = document.createElement('div');
row.className = `msg-bubble-row ${msg.rowKind}`;
row.dataset.msgId = msg.id;
const bubble = document.createElement('div');
bubble.className = `msg-bubble ${msg.rowKind}`;
bubble.dataset.msgId = msg.id;
const { contentEl, menuHost } = renderBubbleBodyElements(bubble, msg, index);
row.appendChild(bubble);
messagesEl.appendChild(row);
if (onBubbleRendered) {
onBubbleRendered(row, bubble, contentEl, msg, index, menuHost);
} else {
mountViewModeDotMenu(menuHost, contentEl, msg, index);
}
}
}
function scrollToBottom(force = false) {
const isNearBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight <= scrollThreshold;
if (!force && !isNearBottom) return;
window.requestAnimationFrame(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
async function handleLoadOlder() {
if (!onLoadOlder || isLoadingOlder) return;
if (messagesEl.scrollTop > loadOlderThreshold) return;
isLoadingOlder = true;
try {
await onLoadOlder();
} finally {
isLoadingOlder = false;
}
}
messagesEl.addEventListener('scroll', handleLoadOlder);
const composer = mountComposer(replyInputEl, {
layout: 'inline',
showPreview: true,
showUploadIcon: true,
submitOnEnter: false,
alwaysShowSendButton: true,
clearBeforeSubmit: true,
...(options.composerOptions || {}),
onSubmit: async (text, payload = {}) => {
if (!onSubmit) return false;
return await onSubmit(text, payload);
}
});
renderHeader({ name: String(options.headerName || 'Conversation') });
renderMessages();
return {
setHeader(data = {}) {
renderHeader(data);
},
setMessages(nextMessages = []) {
messages = Array.isArray(nextMessages) ? nextMessages.slice() : [];
renderMessages();
scrollToBottom(true);
},
appendMessage(message) {
messages.push(message);
renderMessages();
scrollToBottom(false);
},
prependMessages(olderMessages = []) {
const previousScrollHeight = messagesEl.scrollHeight;
const previousScrollTop = messagesEl.scrollTop;
messages = [...(Array.isArray(olderMessages) ? olderMessages : []), ...messages];
renderMessages();
const nextScrollHeight = messagesEl.scrollHeight;
messagesEl.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
},
scrollToBottom(force = true) {
scrollToBottom(force);
},
updateMessage(id, patch = {}) {
const targetId = String(id || '').trim();
if (!targetId) return false;
const messageIndex = messages.findIndex((item) => String(item?.id || '') === targetId);
if (messageIndex < 0) return false;
const merged = { ...(messages[messageIndex] || {}), ...(patch || {}) };
messages[messageIndex] = merged;
const selector = `.msg-bubble-row[data-msg-id="${escapeCssAttr(targetId)}"]`;
const row = messagesEl.querySelector(selector);
if (!row) {
renderMessages();
return true;
}
const msg = normalizeMessage(merged);
row.className = `msg-bubble-row ${msg.rowKind}`;
row.dataset.msgId = msg.id;
const bubble = row.querySelector('.msg-bubble') || document.createElement('div');
bubble.className = `msg-bubble ${msg.rowKind}`;
bubble.dataset.msgId = msg.id;
if (!bubble.parentElement) row.appendChild(bubble);
// Clean up any legacy row-level menu hosts from previous layouts.
row.querySelectorAll(':scope > .msg-bubble-menu-host').forEach((menuHost) => menuHost.remove());
const { contentEl, menuHost } = renderBubbleBodyElements(bubble, msg, messageIndex);
if (onBubbleRendered) {
onBubbleRendered(row, bubble, contentEl, msg, messageIndex, menuHost);
} else {
mountViewModeDotMenu(menuHost, contentEl, msg, messageIndex);
}
return true;
},
setDisabled(disabled) {
composer.setDisabled(Boolean(disabled));
},
getComposer() {
return composer;
},
getElements() {
return {
paneEl,
headerEl,
messagesEl,
replyBoxEl,
replyInputEl
};
},
destroy() {
messagesEl.removeEventListener('scroll', handleLoadOlder);
composer?.destroy?.();
hostEl.innerHTML = '';
}
};
}
export {
escapeHtml,
formatTime,
isLikelyCashuToken,
findCashuTokens,
renderCashuQrCode
};
export default mountMessagingWindow;