1762 lines
59 KiB
JavaScript
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>`;
|
|
}
|
|
})();
|
|
|