Files
client/build/.tmp-msg-inline-check.mjs
2026-04-17 16:52:51 -04:00

1762 lines
59 KiB
JavaScript

import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
publishRawEvent,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
fetchCachedProfile,
ndkFetchEvents,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { htmlFormatText, sanitizeInboundText } from './js/utilities.mjs';
import { mountDotMenu } from './js/dot-menu.mjs';
import { hydrateNostrEntities } from './js/post-interactions.mjs';
import {
initFooterRelayStatus,
updateFooterRelayStatus,
initSidenavRelaySection,
updateSidenavRelaySection,
setRelayActivityState
} from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { mountMessagingWindow } from './js/messaging-ui.mjs';
import { createProfileCache } from './js/profile-cache.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[msg.html ${VERSION}] Loading...`);
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divConversationsList = document.getElementById('divConversationsList');
const divThreadPaneHost = document.getElementById('divThreadPaneHost');
let divThreadHeader = null;
let divThreadMessages = null;
const selSendMode = document.getElementById('selSendMode');
const seenEventIds = new Set();
const seenRumorIds = new Set();
const conversations = new Map();
const pendingGiftWrapEvents = [];
const profileCacheApi = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache
});
let selectedConversationPubkey = null;
let sendMode = localStorage.getItem('msgSendMode') || 'kind4-nip04';
let followedPubkeys = [];
const THREAD_INITIAL_RENDER_LIMIT = 20;
const THREAD_PAGE_SIZE = 20;
const THREAD_EAGER_DECRYPT_COUNT = 10;
const INITIAL_DM_SYNC_LIMIT = 10;
const CONVERSATION_HYDRATE_LIMIT = 120;
let isLoadingOlderMessages = false;
let composer = null;
let messagingUi = null;
let threadRenderVersion = 0;
let suppressUiRefresh = false;
let isDrainingGiftWrapQueue = false;
let giftWrapDrainScheduled = false;
let renderThreadInFlightPromise = null;
let renderThreadQueued = false;
const inFlightKind4Decrypts = new Map();
const MSG_PERF_REPORT_LIMIT = 30;
const msgPerfReports = [];
const messageRawViewById = new Map();
function perfNowMs() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function startMsgPerfReport(label, meta = {}) {
const now = perfNowMs();
return {
label,
meta,
startedAt: now,
lastMarkAt: now,
steps: []
};
}
function markMsgPerf(report, step, details = {}) {
if (!report) return;
const now = perfNowMs();
report.steps.push({
step,
deltaMs: now - report.lastMarkAt,
totalMs: now - report.startedAt,
...details
});
report.lastMarkAt = now;
}
function finalizeMsgPerfReport(report, { log = true } = {}) {
if (!report) return;
const completedAt = perfNowMs();
report.totalMs = completedAt - report.startedAt;
report.completedAtUnixMs = Date.now();
msgPerfReports.push(report);
while (msgPerfReports.length > MSG_PERF_REPORT_LIMIT) {
msgPerfReports.shift();
}
window.__msgPerfReports = msgPerfReports;
if (!log) return;
console.groupCollapsed(`[msg.html][perf] ${report.label} ${report.totalMs.toFixed(1)}ms`);
console.table(report.steps.map((step) => ({
step: step.step,
deltaMs: Number(step.deltaMs.toFixed(1)),
totalMs: Number(step.totalMs.toFixed(1)),
count: step.count ?? '',
avgMs: step.avgMs ?? '',
note: step.note ?? ''
})));
console.log('meta', report.meta);
console.groupEnd();
}
function shortKey(pubkey) {
if (!pubkey) return '';
return `${pubkey.slice(0, 8)}`;
}
function normalizeConversationTarget(value) {
const rawValue = String(value || '').trim();
if (!rawValue) return null;
const raw = rawValue.startsWith('nostr:') ? rawValue.slice(6) : rawValue;
if (/^[a-f0-9]{64}$/i.test(raw)) {
return raw.toLowerCase();
}
if (raw.startsWith('npub1') && window.NostrTools?.nip19?.decode) {
try {
const decoded = window.NostrTools.nip19.decode(raw);
if (decoded?.type === 'npub' && typeof decoded.data === 'string' && /^[a-f0-9]{64}$/i.test(decoded.data)) {
return decoded.data.toLowerCase();
}
} catch (_) {
// Ignore invalid npub.
}
}
return null;
}
function encodeConversationTarget(pubkey) {
if (!pubkey) return '';
if (window.NostrTools?.nip19?.npubEncode) {
try {
return window.NostrTools.nip19.npubEncode(pubkey);
} catch (_) {
// Fallback to hex pubkey.
}
}
return pubkey;
}
function syncConversationUrl(pubkey) {
const url = new URL(window.location.href);
if (pubkey) {
url.searchParams.set('to', encodeConversationTarget(pubkey));
} else {
url.searchParams.delete('to');
}
window.history.replaceState(null, '', url);
}
function getConversationTargetFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const rawTarget = params.get('to') || '';
return normalizeConversationTarget(rawTarget);
}
let pendingConversationPubkey = getConversationTargetFromUrl();
function setSelectedConversationPubkey(pubkey, { syncUrl = true } = {}) {
const normalized = normalizeConversationTarget(pubkey);
selectedConversationPubkey = normalized;
if (syncUrl) {
syncConversationUrl(normalized);
}
}
function setThreadLoadingStatus(statusText, { pubkey = selectedConversationPubkey } = {}) {
const normalized = normalizeConversationTarget(pubkey);
if (normalized && selectedConversationPubkey && normalized !== selectedConversationPubkey) return;
const displayName = normalized ? getConversationName(normalized) : 'Conversation';
const avatarUrl = normalized ? getConversationAvatar(normalized) : '';
if (messagingUi) {
messagingUi.setHeader({ name: displayName, avatarUrl });
}
if (divThreadMessages) {
divThreadMessages.innerHTML = `<div class="msgEmptyState">${escapeHtml(statusText || 'Loading conversation…')}</div>`;
}
}
function clearThreadForConversationSwitch(pubkey = null) {
threadRenderVersion += 1;
setThreadLoadingStatus('Loading conversation…', { pubkey });
composer?.setDisabled(true);
}
function setThreadProgressBanner(statusText = '') {
if (!divThreadMessages) return;
let banner = divThreadMessages.querySelector('.msgProgressBanner');
if (!statusText) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.className = 'msgProgressBanner';
banner.style.fontSize = '12px';
banner.style.opacity = '0.8';
banner.style.padding = '4px 8px';
banner.style.margin = '0 0 8px 0';
banner.style.borderRadius = '8px';
banner.style.background = 'rgba(0, 0, 0, 0.18)';
divThreadMessages.prepend(banner);
}
banner.textContent = String(statusText || '');
}
function getFollowSearchMatches(query = '') {
const q = String(query || '').trim().toLowerCase();
const candidates = followedPubkeys.slice(0, 200);
return candidates
.map((pubkey) => ({
pubkey,
name: getConversationName(pubkey),
key: shortKey(pubkey),
avatar: getConversationAvatar(pubkey)
}))
.filter((entry) => {
if (!q) return true;
return entry.name.toLowerCase().includes(q) || entry.key.toLowerCase().includes(q);
})
.slice(0, 8);
}
async function resolveNip05ToPubkey(nip05) {
return await profileCacheApi.resolveNip05ToPubkey(nip05, {
queryLimit: 800,
relayLimit: 300,
queryCache
});
}
async function resolveConversationInput(value) {
const raw = String(value || '').trim();
if (!raw) return null;
if (raw.startsWith('@')) {
const matches = getFollowSearchMatches(raw.slice(1));
return matches[0]?.pubkey || null;
}
const normalized = normalizeConversationTarget(raw);
if (normalized) return normalized;
if (raw.includes('@')) {
return await resolveNip05ToPubkey(raw);
}
return null;
}
async function loadFollowedPubkeys() {
const applyLatestContacts = async (latestEvent) => {
const collected = new Set();
const tags = Array.isArray(latestEvent?.tags) ? latestEvent.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === 'p' && /^[a-f0-9]{64}$/i.test(tag[1] || '')) {
collected.add(String(tag[1]).toLowerCase());
}
}
followedPubkeys = Array.from(collected);
if (followedPubkeys.length > 0) {
try {
const cachedProfiles = await queryCache({
kinds: [0],
authors: followedPubkeys.slice(0, 200),
limit: 500
});
profileCacheApi.prewarmProfileCache(cachedProfiles || [], { logPrefix: '[msg.html]' });
} catch (_) {
// Ignore cache prewarm failure.
}
}
};
let latest = null;
try {
const cachedContacts = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 5 });
if (Array.isArray(cachedContacts) && cachedContacts.length > 0) {
latest = cachedContacts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
}
} catch (_) {
// Ignore cache failure.
}
if (latest) {
await applyLatestContacts(latest);
}
if (!latest) {
void ndkFetchEvents({ kinds: [3], authors: [currentPubkey], limit: 5 }).then(async (contactEvents) => {
if (Array.isArray(contactEvents) && contactEvents.length > 0) {
const relayLatest = contactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
await applyLatestContacts(relayLatest);
}
}).catch(() => {
// Ignore relay failure.
});
}
}
function renderConversationStarter(errorText = '') {
divThreadHeader.innerHTML = `
<div class="msgThreadStarter">
<div class="msgThreadStarterRow">
<input id="inpStartConversation" type="text" placeholder="npub / hex / nip05 / @follow" autocomplete="off" />
<button id="btnStartConversation" type="button">Open</button>
</div>
<div id="divStartConversationHints" class="msgStarterHints"></div>
<div id="divStartConversationError" class="msgStarterError">${escapeHtml(errorText || '')}</div>
</div>
`;
const input = document.getElementById('inpStartConversation');
const btn = document.getElementById('btnStartConversation');
const hints = document.getElementById('divStartConversationHints');
const renderHints = () => {
if (!hints || !input) return;
const raw = String(input.value || '');
if (!raw.startsWith('@')) {
hints.innerHTML = '';
return;
}
const matches = getFollowSearchMatches(raw.slice(1));
hints.innerHTML = matches.map((entry) => (`
<button type="button" class="msgStarterHint" data-pubkey="${entry.pubkey}">
${entry.avatar ? `<img src="${escapeHtml(entry.avatar)}" alt="" class="msgStarterHintAvatar" />` : '<span class="msgStarterHintAvatar"></span>'}
<span>@${escapeHtml(entry.name)}</span>
</button>
`)).join('');
};
const submit = async (preferredPubkey = null) => {
const target = preferredPubkey || input?.value || '';
const pubkey = await resolveConversationInput(target);
if (!pubkey) {
renderConversationStarter('Could not resolve that target.');
return;
}
pendingConversationPubkey = null;
ensureConversation(pubkey);
ensureProfileName(pubkey);
setSelectedConversationPubkey(pubkey);
clearThreadForConversationSwitch(pubkey);
if (isNavOpen) closeNav();
renderConversationList();
await hydrateConversationHistory(pubkey, { limit: CONVERSATION_HYDRATE_LIMIT });
await renderThread();
scrollThreadToBottom(true);
};
if (input) {
input.addEventListener('input', renderHints);
input.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await submit();
}
});
input.focus();
}
if (btn) {
btn.addEventListener('click', async () => {
await submit();
});
}
if (hints) {
hints.addEventListener('click', async (event) => {
const hint = event.target.closest('.msgStarterHint');
if (!hint) return;
event.preventDefault();
const pubkey = hint.getAttribute('data-pubkey');
await submit(pubkey);
});
}
renderHints();
}
function scrollThreadToBottom(force = false) {
const threshold = 100;
const isAtBottom = divThreadMessages.scrollHeight - divThreadMessages.scrollTop - divThreadMessages.clientHeight <= threshold;
if (force || isAtBottom) {
window.requestAnimationFrame(() => {
divThreadMessages.scrollTop = divThreadMessages.scrollHeight;
});
}
}
function formatTime(tsSec) {
if (!tsSec) return '';
try {
return new Date(tsSec * 1000).toLocaleString();
} catch {
return String(tsSec);
}
}
function configureMarkdownForChat() {
if (!window.marked) return;
marked.setOptions({
gfm: true,
breaks: true,
silent: true
});
}
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = String(value || '');
return span.innerHTML;
}
const CASHU_TOKEN_REGEX = /(cashu[AB][A-Za-z0-9+/_=-]+)/g;
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 (!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 = '';
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 syncMessageCashuBlock(bubbleEl, messageText) {
if (!bubbleEl) return;
bubbleEl.querySelectorAll('.msgBubbleCashu').forEach((el) => el.remove());
const cashuTokens = findCashuTokens(messageText);
if (cashuTokens.length === 0) return;
const cashuBlock = document.createElement('div');
cashuBlock.className = 'msgBubbleCashu';
for (const token of cashuTokens) {
const tokenLabel = document.createElement('div');
tokenLabel.className = 'msgBubbleCashuToken';
tokenLabel.textContent = token;
cashuBlock.appendChild(tokenLabel);
const qrWrap = document.createElement('div');
qrWrap.className = 'msgBubbleCashuQr';
qrWrap.title = 'Cashu token QR';
renderCashuQrCode(qrWrap, token);
cashuBlock.appendChild(qrWrap);
}
bubbleEl.appendChild(cashuBlock);
}
function getMessageViewKey(msg, index = 0) {
if (msg?.id) return `id:${msg.id}`;
return `fallback:${msg?.created_at || 0}:${msg?.outgoing ? 'out' : 'in'}:${index}`;
}
function isMessageRawView(msg, index = 0) {
return messageRawViewById.get(getMessageViewKey(msg, index)) === true;
}
function setMessageRawView(msg, isRaw, index = 0) {
messageRawViewById.set(getMessageViewKey(msg, index), !!isRaw);
}
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 renderMessageContent(content) {
const rawText = sanitizeInboundText(content);
if (!rawText) return '';
let rendered = '';
if (window.marked?.parse) {
try {
rendered = window.marked.parse(rawText) || '';
} catch (_) {
rendered = '';
}
}
if (!rendered) {
return htmlFormatText(rawText);
}
if (window.DOMPurify?.sanitize) {
rendered = window.DOMPurify.sanitize(rendered, {
ALLOWED_TAGS: [
'a', 'p', 'br', 'em', 'strong', 'b', 'i', 'del', 's',
'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'img'
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'class', 'target', 'rel', 'data-entity', 'data-pubkey', 'data-event-id'],
ALLOW_DATA_ATTR: true
});
}
const wrap = document.createElement('div');
wrap.innerHTML = rendered;
wrap.querySelectorAll('a').forEach((link) => {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
});
return wrap.innerHTML;
}
function renderBubbleBody(contentEl, msg, index = 0) {
const displayText = msg?.needsKind4Decrypt ? '🔒 Encrypted message' : (msg?.content || '');
if (isMessageRawView(msg, index)) {
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = displayText;
} else {
contentEl.style.whiteSpace = '';
contentEl.innerHTML = renderMessageContent(displayText);
hydrateNostrEntities(contentEl);
}
return displayText;
}
function mountBubbleDotMenu(rowEl, contentEl, msg, index = 0) {
if (!rowEl || !contentEl || !msg) return;
const menuHost = document.createElement('div');
menuHost.className = 'msgBubbleMenuHost';
rowEl.appendChild(menuHost);
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 () => {
const plain = msg?.needsKind4Decrypt ? '🔒 Encrypted message' : (msg?.content || '');
await copyTextToClipboard(plain);
}
},
{
label: isMessageRawView(msg, index) ? 'Toggle formatting (show rendered)' : 'Toggle formatting (show raw)',
onClick: () => {
const nextRaw = !isMessageRawView(msg, index);
setMessageRawView(msg, nextRaw, index);
renderBubbleBody(contentEl, msg, index);
}
}
]
});
}
function initSendModeUI() {
if (!selSendMode) return;
selSendMode.value = sendMode;
selSendMode.addEventListener('change', () => {
sendMode = selSendMode.value || 'kind4-nip04';
localStorage.setItem('msgSendMode', sendMode);
});
}
function ensureConversation(pubkey) {
if (!conversations.has(pubkey)) {
conversations.set(pubkey, {
pubkey,
messages: [],
lastAt: 0,
preview: '',
renderCount: THREAD_INITIAL_RENDER_LIMIT,
hydrated: false
});
}
return conversations.get(pubkey);
}
async function hydrateConversationHistory(pubkey, { limit = CONVERSATION_HYDRATE_LIMIT } = {}) {
const normalized = normalizeConversationTarget(pubkey);
if (!normalized || !currentPubkey) return;
const conv = ensureConversation(normalized);
if (conv.hydrated) return;
const perfReport = startMsgPerfReport('hydrateConversationHistory', {
pubkey: shortKey(normalized),
limit
});
const incomingFilter = { kinds: [4], authors: [normalized], '#p': [currentPubkey], limit };
const outgoingFilter = { kinds: [4], authors: [currentPubkey], '#p': [normalized], limit };
setThreadLoadingStatus('Fetching cached messages…', { pubkey: normalized });
const [incomingCached, outgoingCached] = await Promise.all([
queryCache(incomingFilter).catch(() => []),
queryCache(outgoingFilter).catch(() => [])
]);
markMsgPerf(perfReport, 'cache-query', {
count: (incomingCached?.length || 0) + (outgoingCached?.length || 0)
});
const cachedMerged = [
...(incomingCached || []),
...(outgoingCached || [])
];
const cachedSorted = cachedMerged.sort((a, b) => (a?.created_at || 0) - (b?.created_at || 0));
if (cachedSorted.length > 0) {
setThreadLoadingStatus(`Processing ${cachedSorted.length} cached messages…`, { pubkey: normalized });
}
let processedCached = 0;
for (const evt of cachedSorted) {
if (evt?.kind === 4) {
await processKind4Event(evt, { render: false });
processedCached += 1;
if (processedCached % 50 === 0) {
setThreadLoadingStatus(`Processing cached messages… ${processedCached}/${cachedSorted.length}`, { pubkey: normalized });
}
}
}
markMsgPerf(perfReport, 'cache-process-kind4', { count: processedCached });
conv.hydrated = true;
markMsgPerf(perfReport, 'cache-hydrated-ready');
finalizeMsgPerfReport(perfReport);
void (async () => {
const relayPerfReport = startMsgPerfReport('hydrateConversationHistory.relay', {
pubkey: shortKey(normalized),
limit
});
try {
const [incomingFresh, outgoingFresh] = await Promise.all([
ndkFetchEvents(incomingFilter).catch(() => []),
ndkFetchEvents(outgoingFilter).catch(() => [])
]);
markMsgPerf(relayPerfReport, 'relay-query', {
count: (incomingFresh?.length || 0) + (outgoingFresh?.length || 0)
});
const relayMerged = [
...(incomingFresh || []),
...(outgoingFresh || [])
];
const relaySorted = relayMerged.sort((a, b) => (a?.created_at || 0) - (b?.created_at || 0));
if (relaySorted.length === 0) {
markMsgPerf(relayPerfReport, 'relay-empty');
return;
}
if (selectedConversationPubkey === normalized) {
setThreadProgressBanner(`Hydrating relay messages… 0/${relaySorted.length}`);
}
let processedRelay = 0;
for (const evt of relaySorted) {
if (evt?.kind !== 4) continue;
await processKind4Event(evt, { render: false, renderList: false });
processedRelay += 1;
if (selectedConversationPubkey === normalized && (processedRelay % 50 === 0 || processedRelay === relaySorted.length)) {
setThreadProgressBanner(`Hydrating relay messages… ${processedRelay}/${relaySorted.length}`);
}
}
markMsgPerf(relayPerfReport, 'relay-process-kind4', { count: processedRelay });
if (processedRelay > 0) {
renderConversationList();
if (selectedConversationPubkey === normalized) {
await renderThread();
scrollThreadToBottom(true);
}
markMsgPerf(relayPerfReport, 'relay-rerender');
}
} catch {
markMsgPerf(relayPerfReport, 'relay-error', { note: 'fetch/process failure' });
} finally {
if (selectedConversationPubkey === normalized) {
setThreadProgressBanner('');
}
finalizeMsgPerfReport(relayPerfReport);
}
})();
}
function getPTagPubkey(tags) {
if (!Array.isArray(tags)) return null;
const pTag = tags.find(tag => Array.isArray(tag) && tag[0] === 'p' && tag[1]);
return pTag ? pTag[1] : null;
}
async function ensureProfileName(pubkey, { render = true } = {}) {
if (!pubkey) return;
await profileCacheApi.fetchProfile(pubkey, { logPrefix: '[msg.html]' });
if (!render || suppressUiRefresh) return;
renderConversationList();
if (selectedConversationPubkey && conversations.has(selectedConversationPubkey)) {
renderThread();
}
}
async function decryptKind4Content(event, counterpartyPubkey) {
if (event._decodedKind4) return event._decodedKind4;
const decryptKey = `${event?.id || event?.sig || 'unknown'}:${counterpartyPubkey || ''}`;
const inFlight = inFlightKind4Decrypts.get(decryptKey);
if (inFlight) return inFlight;
const decryptPromise = (async () => {
if (window.nostr?.nip04?.decrypt) {
try {
const plaintext = sanitizeInboundText(await window.nostr.nip04.decrypt(counterpartyPubkey, event.content || ''));
const decoded = {
plaintext: plaintext ?? '',
protocol: 'kind4-nip04'
};
event._decodedKind4 = decoded;
return decoded;
} catch (_) {
// Fallback to NIP-44 for non-standard kind-4 payloads.
}
}
if (window.nostr?.nip44?.decrypt) {
try {
const plaintext = sanitizeInboundText(await window.nostr.nip44.decrypt(counterpartyPubkey, event.content || ''));
const decoded = {
plaintext: plaintext ?? '',
protocol: 'kind4-nip44'
};
event._decodedKind4 = decoded;
return decoded;
} catch (_) {
// Ignore and fall through.
}
}
const failed = {
plaintext: '[Unable to decrypt kind-4 message]',
protocol: 'kind4-unknown'
};
event._decodedKind4 = failed;
return failed;
})().finally(() => {
inFlightKind4Decrypts.delete(decryptKey);
});
inFlightKind4Decrypts.set(decryptKey, decryptPromise);
return decryptPromise;
}
async function decryptGiftWrapContent(event) {
if (!window.nostr?.nip44?.decrypt) {
throw new Error('NIP-44 decrypt unavailable');
}
const sealedJson = sanitizeInboundText(await window.nostr.nip44.decrypt(event.pubkey, event.content || ''));
let seal;
try {
seal = JSON.parse(sealedJson);
} catch {
throw new Error('Invalid giftwrap payload JSON');
}
if (!seal || seal.kind !== 13 || !seal.pubkey || !seal.content) {
throw new Error('Giftwrap did not contain valid kind-13 seal');
}
const rumorJson = sanitizeInboundText(await window.nostr.nip44.decrypt(seal.pubkey, seal.content || ''));
let rumor;
try {
rumor = JSON.parse(rumorJson);
} catch {
throw new Error('Invalid seal payload JSON');
}
return { seal, rumor };
}
function getRumorCounterparty(rumor) {
const pTags = Array.isArray(rumor?.tags)
? rumor.tags.filter(tag => Array.isArray(tag) && tag[0] === 'p' && tag[1]).map(tag => tag[1])
: [];
if (rumor?.pubkey === currentPubkey) {
const other = pTags.find(pk => pk !== currentPubkey) || pTags[0] || null;
return { counterpartyPubkey: other, outgoing: true };
}
if (pTags.includes(currentPubkey)) {
return { counterpartyPubkey: rumor.pubkey || null, outgoing: false };
}
return { counterpartyPubkey: null, outgoing: false };
}
async function processKind4Event(event, { render = true, renderList = true } = {}) {
if (!event || event.kind !== 4 || !event.id) return;
if (seenEventIds.has(event.id)) return;
let counterpartyPubkey = null;
let outgoing = false;
if (event.pubkey === currentPubkey) {
outgoing = true;
counterpartyPubkey = getPTagPubkey(event.tags);
} else {
const p = getPTagPubkey(event.tags);
if (p === currentPubkey) {
outgoing = false;
counterpartyPubkey = event.pubkey;
}
}
if (!counterpartyPubkey) return;
seenEventIds.add(event.id);
ensureProfileName(counterpartyPubkey, { render: !suppressUiRefresh });
const conv = ensureConversation(counterpartyPubkey);
conv.messages.push({
id: event.id,
created_at: event.created_at || 0,
outgoing,
content: '',
protocol: 'kind4-encrypted',
encryptedKind4Event: event,
needsKind4Decrypt: true,
counterpartyPubkey
});
if ((event.created_at || 0) >= conv.lastAt) {
conv.lastAt = event.created_at || 0;
conv.preview = '[Encrypted message]';
}
if (renderList && !suppressUiRefresh) {
renderConversationList();
}
if (render && !suppressUiRefresh && selectedConversationPubkey === counterpartyPubkey) {
renderThread();
scrollThreadToBottom(false);
}
}
async function materializeGiftWrapEvent(event) {
let rumor;
try {
const decoded = await decryptGiftWrapContent(event);
rumor = decoded.rumor;
} catch (error) {
console.warn('[msg.html] Failed to decode giftwrap:', event.id, error?.message || error);
return { changed: false, selectedChanged: false };
}
if (!rumor || !rumor.kind) return { changed: false, selectedChanged: false };
if (rumor.id && seenRumorIds.has(rumor.id)) return { changed: false, selectedChanged: false };
if (rumor.id) seenRumorIds.add(rumor.id);
const { counterpartyPubkey, outgoing } = getRumorCounterparty(rumor);
if (!counterpartyPubkey) return { changed: false, selectedChanged: false };
ensureProfileName(counterpartyPubkey, { render: !suppressUiRefresh });
let content = '';
let protocol = `nip17-kind${rumor.kind}`;
if (rumor.kind === 14) {
content = rumor.content || '';
} else if (rumor.kind === 15) {
const fileTypeTag = Array.isArray(rumor.tags)
? rumor.tags.find(tag => Array.isArray(tag) && tag[0] === 'file-type')
: null;
const fileType = fileTypeTag?.[1] || 'file';
content = `[File message: ${fileType}] ${rumor.content || ''}`;
} else {
content = `[Unsupported NIP-17 inner kind ${rumor.kind}]`;
}
const conv = ensureConversation(counterpartyPubkey);
conv.messages.push({
id: rumor.id || event.id,
created_at: rumor.created_at || event.created_at || 0,
outgoing,
content,
protocol
});
if ((rumor.created_at || event.created_at || 0) >= conv.lastAt) {
conv.lastAt = rumor.created_at || event.created_at || 0;
conv.preview = (content || '').replace(/\s+/g, ' ').trim();
}
return {
changed: true,
selectedChanged: selectedConversationPubkey === counterpartyPubkey
};
}
async function processPendingGiftWrapEvents({ renderList = true, maxEvents = Infinity } = {}) {
if (pendingGiftWrapEvents.length === 0) {
return { changed: false, selectedChanged: false, remaining: 0 };
}
let changed = false;
let selectedChanged = false;
let processed = 0;
while (pendingGiftWrapEvents.length > 0 && processed < maxEvents) {
const event = pendingGiftWrapEvents.shift();
const result = await materializeGiftWrapEvent(event);
changed = changed || !!result?.changed;
selectedChanged = selectedChanged || !!result?.selectedChanged;
processed += 1;
}
if (changed && renderList && !suppressUiRefresh) {
renderConversationList();
}
return { changed, selectedChanged, remaining: pendingGiftWrapEvents.length };
}
async function drainGiftWrapQueueInBackground() {
if (isDrainingGiftWrapQueue) return;
isDrainingGiftWrapQueue = true;
try {
const { changed, selectedChanged, remaining } = await processPendingGiftWrapEvents({
renderList: false,
maxEvents: 4
});
if (changed && !suppressUiRefresh) {
renderConversationList();
}
if (selectedChanged && !suppressUiRefresh && selectedConversationPubkey && conversations.has(selectedConversationPubkey)) {
await renderThread();
scrollThreadToBottom(true);
}
if (remaining > 0) {
scheduleGiftWrapDrain();
}
} finally {
isDrainingGiftWrapQueue = false;
}
}
function scheduleGiftWrapDrain() {
if (giftWrapDrainScheduled) return;
giftWrapDrainScheduled = true;
setTimeout(async () => {
giftWrapDrainScheduled = false;
await drainGiftWrapQueueInBackground();
}, 0);
}
async function processGiftWrapEvent(event, { deferProcessing = false } = {}) {
if (!event || event.kind !== 1059 || !event.id) return;
if (seenEventIds.has(event.id)) return;
const p = getPTagPubkey(event.tags);
if (p !== currentPubkey) return;
seenEventIds.add(event.id);
pendingGiftWrapEvents.push(event);
if (deferProcessing || suppressUiRefresh) {
scheduleGiftWrapDrain();
return;
}
const { selectedChanged } = await processPendingGiftWrapEvents();
if (selectedChanged) {
renderThread();
scrollThreadToBottom(false);
}
}
function getConversationName(pubkey) {
const profile = profileCacheApi.getCachedProfile(pubkey);
return profile?.display_name || profile?.name || shortKey(pubkey);
}
function getConversationAvatar(pubkey) {
const profile = profileCacheApi.getCachedProfile(pubkey);
return profile?.picture || '';
}
function renderConversationList() {
const list = Array.from(conversations.values())
.filter(conv => conv.messages.length > 0)
.sort((a, b) => b.lastAt - a.lastAt);
divConversationsList.innerHTML = '';
if (list.length === 0) {
const empty = document.createElement('div');
empty.className = 'msgEmptyState';
empty.textContent = 'No direct messages found yet';
divConversationsList.appendChild(empty);
composer?.setDisabled(true);
return;
}
if (pendingConversationPubkey && conversations.has(pendingConversationPubkey)) {
setSelectedConversationPubkey(pendingConversationPubkey);
pendingConversationPubkey = null;
}
if (selectedConversationPubkey && !conversations.has(selectedConversationPubkey)) {
setSelectedConversationPubkey(list[0].pubkey);
}
for (const conv of list) {
const item = document.createElement('div');
item.className = `msgConversationItem${conv.pubkey === selectedConversationPubkey ? ' active' : ''}`;
const name = document.createElement('div');
name.className = 'msgConversationName';
name.textContent = getConversationName(conv.pubkey);
item.appendChild(name);
item.addEventListener('click', async () => {
pendingConversationPubkey = null;
setSelectedConversationPubkey(conv.pubkey);
clearThreadForConversationSwitch(conv.pubkey);
if (isNavOpen) closeNav();
const selectedConv = conversations.get(conv.pubkey);
if (selectedConv) {
selectedConv.renderCount = THREAD_INITIAL_RENDER_LIMIT;
}
renderConversationList();
await hydrateConversationHistory(conv.pubkey, { limit: CONVERSATION_HYDRATE_LIMIT });
await renderThread();
scrollThreadToBottom(true);
});
divConversationsList.appendChild(item);
}
composer?.setDisabled(!selectedConversationPubkey);
}
async function renderThreadInner() {
const renderVersion = ++threadRenderVersion;
const renderPerfReport = startMsgPerfReport('renderThread', {
pubkey: shortKey(selectedConversationPubkey || ''),
renderVersion
});
if (!selectedConversationPubkey || !conversations.has(selectedConversationPubkey)) {
renderConversationStarter();
divThreadMessages.innerHTML = '<div class="msgEmptyState">Open a conversation to view messages</div>';
composer?.setDisabled(true);
markMsgPerf(renderPerfReport, 'empty-thread');
finalizeMsgPerfReport(renderPerfReport);
return;
}
await processPendingGiftWrapEvents();
markMsgPerf(renderPerfReport, 'giftwrap-processed');
const conv = conversations.get(selectedConversationPubkey);
const displayName = getConversationName(selectedConversationPubkey);
const avatarUrl = getConversationAvatar(selectedConversationPubkey);
if (messagingUi) {
messagingUi.setHeader({
name: displayName,
avatarUrl
});
}
const sortedMessages = [...conv.messages].sort((a, b) => a.created_at - b.created_at);
const totalMessages = sortedMessages.length;
const renderCount = Math.min(
totalMessages,
Math.max(conv.renderCount || THREAD_INITIAL_RENDER_LIMIT, THREAD_INITIAL_RENDER_LIMIT)
);
const startIndex = Math.max(0, totalMessages - renderCount);
const visibleMessages = sortedMessages.slice(startIndex);
markMsgPerf(renderPerfReport, 'prepare-visible-messages', {
count: visibleMessages.length
});
const eagerDecryptTargets = [];
for (let i = visibleMessages.length - 1; i >= 0 && eagerDecryptTargets.length < THREAD_EAGER_DECRYPT_COUNT; i -= 1) {
const msg = visibleMessages[i];
if (!msg?.needsKind4Decrypt || !msg.encryptedKind4Event || !msg.counterpartyPubkey) continue;
const alreadyDecoded = msg.encryptedKind4Event?._decodedKind4;
if (alreadyDecoded) {
msg.content = alreadyDecoded.plaintext || '';
msg.protocol = alreadyDecoded.protocol || msg.protocol;
msg.needsKind4Decrypt = false;
continue;
}
eagerDecryptTargets.push(msg);
}
eagerDecryptTargets.reverse();
let eagerDecryptTotalMs = 0;
if (eagerDecryptTargets.length > 0) {
setThreadLoadingStatus(`Decrypting recent messages… 0/${eagerDecryptTargets.length}`, { pubkey: selectedConversationPubkey });
}
for (let i = 0; i < eagerDecryptTargets.length; i += 1) {
const msg = eagerDecryptTargets[i];
setThreadLoadingStatus(`Decrypting recent messages… ${i + 1}/${eagerDecryptTargets.length}`, { pubkey: selectedConversationPubkey });
const decryptStart = perfNowMs();
const decoded = await decryptKind4Content(msg.encryptedKind4Event, msg.counterpartyPubkey);
eagerDecryptTotalMs += (perfNowMs() - decryptStart);
msg.content = decoded.plaintext || '';
msg.protocol = decoded.protocol || msg.protocol;
msg.needsKind4Decrypt = false;
}
markMsgPerf(renderPerfReport, 'eager-decrypt', {
count: eagerDecryptTargets.length,
avgMs: eagerDecryptTargets.length > 0
? Number((eagerDecryptTotalMs / eagerDecryptTargets.length).toFixed(1))
: 0
});
if (renderVersion !== threadRenderVersion) {
markMsgPerf(renderPerfReport, 'aborted', { note: 'render version changed before DOM paint' });
finalizeMsgPerfReport(renderPerfReport);
return;
}
if (eagerDecryptTargets.length > 0) {
setThreadLoadingStatus('Rendering messages…', { pubkey: selectedConversationPubkey });
}
divThreadMessages.innerHTML = '';
const messageDomById = new Map();
for (let i = 0; i < visibleMessages.length; i += 1) {
const msg = visibleMessages[i];
const row = document.createElement('div');
row.className = `msgBubbleRow ${msg.outgoing ? 'outgoing' : 'incoming'}`;
const bubble = document.createElement('div');
bubble.className = `msgBubble ${msg.outgoing ? 'outgoing' : 'incoming'}`;
const content = document.createElement('div');
content.className = 'msgBubbleContent';
const displayText = renderBubbleBody(content, msg, i);
const time = document.createElement('div');
time.className = 'msgBubbleTime';
const idPrefix = (msg.id || '').slice(0, 4);
time.textContent = `${idPrefix} ${formatTime(msg.created_at)}`.trim();
const meta = document.createElement('div');
meta.className = 'msgBubbleMeta';
meta.textContent = msg.protocol || 'unknown';
bubble.appendChild(content);
bubble.appendChild(time);
bubble.appendChild(meta);
row.appendChild(bubble);
mountBubbleDotMenu(row, content, msg, i);
divThreadMessages.appendChild(row);
syncMessageCashuBlock(bubble, displayText);
if (msg?.id) {
messageDomById.set(msg.id, { content, meta, bubble, row, index: i, msg });
}
}
markMsgPerf(renderPerfReport, 'dom-render', { count: visibleMessages.length });
composer?.setDisabled(false);
scrollThreadToBottom(true);
const lazyDecryptTargets = visibleMessages.filter((msg) => (
!!msg?.needsKind4Decrypt &&
!!msg.encryptedKind4Event &&
!!msg.counterpartyPubkey
));
markMsgPerf(renderPerfReport, 'lazy-targets', { count: lazyDecryptTargets.length });
finalizeMsgPerfReport(renderPerfReport);
if (lazyDecryptTargets.length > 0) {
(async () => {
const lazyPerfReport = startMsgPerfReport('renderThread.lazyDecrypt', {
pubkey: shortKey(selectedConversationPubkey || ''),
renderVersion,
count: lazyDecryptTargets.length
});
let lazyProcessed = 0;
let lazyDecryptTotalMs = 0;
for (const msg of lazyDecryptTargets) {
if (renderVersion !== threadRenderVersion) {
markMsgPerf(lazyPerfReport, 'aborted', { note: 'render version changed during lazy decrypt' });
finalizeMsgPerfReport(lazyPerfReport);
return;
}
try {
const lazyDecryptStart = perfNowMs();
const decoded = await decryptKind4Content(msg.encryptedKind4Event, msg.counterpartyPubkey);
lazyDecryptTotalMs += (perfNowMs() - lazyDecryptStart);
msg.content = decoded.plaintext || '';
msg.protocol = decoded.protocol || msg.protocol;
msg.needsKind4Decrypt = false;
} catch {
// Keep placeholder text if decrypt fails.
}
if (renderVersion !== threadRenderVersion) {
markMsgPerf(lazyPerfReport, 'aborted', { note: 'render version changed before lazy DOM update' });
finalizeMsgPerfReport(lazyPerfReport);
return;
}
const dom = messageDomById.get(msg.id);
if (!dom) continue;
const updatedDisplayText = renderBubbleBody(dom.content, msg, dom.index || 0);
syncMessageCashuBlock(dom.bubble, updatedDisplayText);
dom.meta.textContent = msg.protocol || 'unknown';
lazyProcessed += 1;
setThreadProgressBanner(`Decrypting older messages… ${lazyProcessed}/${lazyDecryptTargets.length}`);
scrollThreadToBottom(true);
}
markMsgPerf(lazyPerfReport, 'lazy-decrypt-complete', {
count: lazyProcessed,
avgMs: lazyProcessed > 0
? Number((lazyDecryptTotalMs / lazyProcessed).toFixed(1))
: 0
});
setThreadProgressBanner('');
finalizeMsgPerfReport(lazyPerfReport);
})();
}
}
async function renderThread() {
if (renderThreadInFlightPromise) {
renderThreadQueued = true;
return renderThreadInFlightPromise;
}
renderThreadInFlightPromise = (async () => {
do {
renderThreadQueued = false;
await renderThreadInner();
} while (renderThreadQueued);
})().finally(() => {
renderThreadInFlightPromise = null;
});
return renderThreadInFlightPromise;
}
async function loadOlderMessagesForSelectedConversation() {
if (isLoadingOlderMessages) return;
if (!selectedConversationPubkey || !conversations.has(selectedConversationPubkey)) return;
const conv = conversations.get(selectedConversationPubkey);
const totalMessages = conv.messages.length;
const currentRenderCount = Math.min(
totalMessages,
Math.max(conv.renderCount || THREAD_INITIAL_RENDER_LIMIT, THREAD_INITIAL_RENDER_LIMIT)
);
if (currentRenderCount >= totalMessages) return;
isLoadingOlderMessages = true;
const previousScrollHeight = divThreadMessages.scrollHeight;
const previousScrollTop = divThreadMessages.scrollTop;
conv.renderCount = Math.min(totalMessages, currentRenderCount + THREAD_PAGE_SIZE);
await renderThread();
const newScrollHeight = divThreadMessages.scrollHeight;
divThreadMessages.scrollTop = previousScrollTop + (newScrollHeight - previousScrollHeight);
isLoadingOlderMessages = false;
}
function randomPastTimestamp(maxSeconds = 172800) {
const now = Math.floor(Date.now() / 1000);
return now;
}
async function publishNip17GiftwrapMessage(recipientPubkey, plaintext) {
if (!window.nostr?.nip44?.encrypt || !window.nostr?.signEvent) {
throw new Error('NIP-17 send requires nip44.encrypt + signEvent support');
}
if (!window.NostrTools?.generateSecretKey || !window.NostrTools?.getPublicKey || !window.NostrTools?.finalizeEvent || !window.NostrTools?.nip44?.getConversationKey || !window.NostrTools?.nip44?.encrypt) {
throw new Error('NostrTools giftwrap helpers are unavailable');
}
const now = Math.floor(Date.now() / 1000);
const rumor = {
kind: 14,
created_at: now,
tags: [['p', recipientPubkey]],
content: plaintext,
pubkey: currentPubkey
};
if (window.NostrTools?.getEventHash) {
rumor.id = window.NostrTools.getEventHash(rumor);
}
const giftwrapRecipients = Array.from(new Set([recipientPubkey, currentPubkey]));
const wrappedEvents = [];
for (const targetPubkey of giftwrapRecipients) {
const sealCiphertext = await window.nostr.nip44.encrypt(targetPubkey, JSON.stringify(rumor));
const sealUnsigned = {
kind: 13,
created_at: randomPastTimestamp(),
tags: [],
content: sealCiphertext
};
const seal = await window.nostr.signEvent(sealUnsigned);
const wrapSecret = window.NostrTools.generateSecretKey();
const wrapPubkey = window.NostrTools.getPublicKey(wrapSecret);
const wrapConversationKey = window.NostrTools.nip44.getConversationKey(wrapSecret, targetPubkey);
const wrapContent = window.NostrTools.nip44.encrypt(JSON.stringify(seal), wrapConversationKey);
const giftwrapTemplate = {
kind: 1059,
created_at: randomPastTimestamp(),
tags: [['p', targetPubkey]],
content: wrapContent,
pubkey: wrapPubkey
};
wrappedEvents.push(window.NostrTools.finalizeEvent(giftwrapTemplate, wrapSecret));
}
if (rumor.id) {
seenRumorIds.add(rumor.id);
}
for (const wrappedEvent of wrappedEvents) {
await publishRawEvent(wrappedEvent);
}
const conv = ensureConversation(recipientPubkey);
conv.messages.push({
id: rumor.id || `local-${Date.now()}`,
created_at: rumor.created_at,
outgoing: true,
content: rumor.content,
protocol: 'nip17-kind1059'
});
if (rumor.created_at >= conv.lastAt) {
conv.lastAt = rumor.created_at;
conv.preview = (rumor.content || '').replace(/\s+/g, ' ').trim();
}
renderConversationList();
if (selectedConversationPubkey === recipientPubkey) {
renderThread();
scrollThreadToBottom(true);
}
}
async function sendReply(rawText = '') {
if (!selectedConversationPubkey) return false;
const plaintext = String(rawText || '').trim();
if (!plaintext) return false;
try {
if (sendMode === 'nip17-kind1059') {
await publishNip17GiftwrapMessage(selectedConversationPubkey, plaintext);
} else {
let ciphertext = '';
if (sendMode === 'kind4-nip44') {
if (!window.nostr?.nip44?.encrypt) {
console.error('[msg.html] NIP-44 encrypt unavailable');
return;
}
ciphertext = await window.nostr.nip44.encrypt(selectedConversationPubkey, plaintext);
} else {
if (!window.nostr?.nip04?.encrypt) {
console.error('[msg.html] NIP-04 encrypt unavailable');
return;
}
ciphertext = await window.nostr.nip04.encrypt(selectedConversationPubkey, plaintext);
}
await publishEvent({
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', selectedConversationPubkey]],
content: ciphertext
});
}
} catch (error) {
console.error('[msg.html] Failed to send message:', error);
return false;
}
return true;
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
const convCount = Array.from(conversations.values()).filter(c => c.messages.length > 0).length;
const selectedName = selectedConversationPubkey ? getConversationName(selectedConversationPubkey) : 'none';
divFooterCenter.textContent = `active: ${selectedName}`;
divFooterRight.textContent = `${convCount} conversations`;
} catch (error) {
console.error('[msg.html] Error updating footer:', error);
}
};
const Logout = async () => {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
};
async function loadCachedMessages() {
const incomingKind4 = { kinds: [4], '#p': [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT };
const outgoingKind4 = { kinds: [4], authors: [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT };
const incomingGiftWrap = { kinds: [1059], '#p': [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT };
suppressUiRefresh = true;
try {
const [incomingCached, outgoingCached, wrapCached] = await Promise.all([
queryCache(incomingKind4),
queryCache(outgoingKind4),
queryCache(incomingGiftWrap)
]);
const cachedEvents = [
...(incomingCached || []),
...(outgoingCached || []),
...(wrapCached || [])
];
cachedEvents.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
for (const event of cachedEvents) {
if (event.kind === 4) {
await processKind4Event(event, { render: false, renderList: false });
} else if (event.kind === 1059) {
await processGiftWrapEvent(event, { deferProcessing: true });
}
}
} catch (error) {
console.warn('[msg.html] Failed to load cached direct messages:', error?.message || error);
} finally {
suppressUiRefresh = false;
}
renderConversationList();
scheduleGiftWrapDrain();
}
async function startMessageSubscriptions() {
subscribe(
{ kinds: [4], '#p': [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
subscribe(
{ kinds: [4], authors: [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
subscribe(
{ kinds: [1059], '#p': [currentPubkey], limit: INITIAL_DM_SYNC_LIMIT },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
(async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
document.getElementById('logoutButton')?.addEventListener('click', async () => {
await Logout();
});
configureMarkdownForChat();
initSendModeUI();
messagingUi = mountMessagingWindow(divThreadPaneHost, {
headerName: 'Select a conversation',
emptyStateText: 'Open a conversation to view messages',
renderMessageContent,
hydrateEntities: hydrateNostrEntities,
composerOptions: {
disabled: true,
showPreview: true,
showUploadIcon: true,
submitOnEnter: false,
alwaysShowSendButton: true
},
onSubmit: async (plaintext) => {
return await sendReply(plaintext);
},
onLoadOlder: async () => {
await loadOlderMessagesForSelectedConversation();
}
});
const threadEls = messagingUi.getElements();
divThreadHeader = threadEls.headerEl;
divThreadMessages = threadEls.messagesEl;
composer = messagingUi.getComposer();
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await loadFollowedPubkeys();
console.log('[msg.html] Authenticated as:', currentPubkey);
if (pendingConversationPubkey) {
ensureProfileName(pendingConversationPubkey);
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
setRelayActivityState(relayUrl, activity);
}
});
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt?.kind === 4) {
await processKind4Event(evt);
} else if (evt?.kind === 1059) {
await processGiftWrapEvent(evt);
}
});
await loadCachedMessages();
renderConversationList();
if (selectedConversationPubkey) {
await hydrateConversationHistory(selectedConversationPubkey, { limit: CONVERSATION_HYDRATE_LIMIT });
}
renderThread();
scrollThreadToBottom(true);
await startMessageSubscriptions();
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
updateIntervalId = setInterval(UpdateFooter, 1000);
await updateVersionDisplay();
console.log('[msg.html] Initialization complete');
} catch (error) {
console.error('[msg.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px; color: var(--primary-color);">
<div style="font-size: 24px; margin-bottom: 20px; color: var(--accent-color);">❌ Error</div>
<div style="font-size: 16px;">${error.message}</div>
</div>`;
}
})();