612 lines
19 KiB
JavaScript
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;
|