Files
client/www/msg.html

2389 lines
75 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>MSG</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/messaging-ui.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
align-content: stretch !important;
overflow: hidden;
padding: 10px;
gap: 10px;
}
#divMsgLayout {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
height: 100%;
gap: 10px;
min-height: 0;
}
#divThreadPaneHost {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
overflow: hidden;
}
#divConversationsPane {
width: 100%;
min-width: 0;
max-width: none;
border-top: 1px solid var(--muted-color);
border-bottom: 1px solid var(--muted-color);
background: var(--secondary-color);
display: flex;
flex-direction: column;
min-height: 180px;
max-height: 45%;
overflow: hidden;
}
#divConversationsTitle {
padding: 10px;
border-bottom: 1px solid var(--muted-color);
color: var(--primary-color);
font-size: 90%;
font-weight: bold;
}
#divConversationsList {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 5px;
}
.msgConversationItem {
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
cursor: pointer;
background: var(--secondary-color);
color: var(--primary-color);
transition: border-color 0.2s, color 0.2s;
}
.msgConversationItem:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.msgConversationItem.active {
border-color: var(--primary-color);
}
.msgConversationRowTop {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.msgConversationName {
font-size: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.msgConversationTime {
font-size: 70%;
color: var(--muted-color);
white-space: nowrap;
}
.msgConversationPreview {
margin-top: 5px;
font-size: 75%;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#divThreadPane {
flex: 1;
height: 100%;
border: 2px solid var(--primary-color);
border-radius: 10px;
background: var(--secondary-color);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
#divThreadHeader {
padding: 10px;
border-bottom: 1px solid var(--muted-color);
font-size: 90%;
color: var(--primary-color);
min-height: 44px;
display: flex;
align-items: center;
}
.msgThreadHeaderRow {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.msgThreadHeaderAvatar {
width: 32px;
height: 32px;
border-radius: 5px;
border: 1px solid var(--muted-color);
object-fit: cover;
flex-shrink: 0;
background: var(--secondary-color);
filter: grayscale(var(--image-grayscale));
transition: filter 0.2s;
}
.msgThreadHeaderAvatar:hover {
filter: grayscale(var(--image-grayscale-hover));
}
.msgThreadStarter {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.msgThreadStarterRow {
width: 100%;
display: flex;
gap: 8px;
align-items: center;
}
#inpStartConversation {
flex: 1;
min-width: 0;
border: 1px solid var(--primary-color);
border-radius: 8px;
background: var(--secondary-color);
color: var(--primary-color);
padding: 8px;
font-size: 85%;
outline: none;
}
#inpStartConversation:focus {
border-color: var(--accent-color);
}
#btnStartConversation {
border: 1px solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 8px 12px;
cursor: pointer;
font-size: 85%;
}
#btnStartConversation:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
.msgStarterHints {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.msgStarterHint {
border: 1px solid var(--muted-color);
border-radius: 5px;
background: var(--secondary-color);
color: var(--primary-color);
font-size: 75%;
padding: 4px 7px;
cursor: pointer;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
align-items: center;
gap: 6px;
}
.msgStarterHintAvatar {
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
border-radius: 50%;
object-fit: cover;
overflow: hidden;
background: var(--secondary-color);
border: 1px solid var(--muted-color);
display: inline-block;
flex: 0 0 18px;
}
.msgStarterHint:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.msgStarterError {
font-size: 75%;
color: var(--accent-color);
min-height: 1em;
}
.msgThreadHeaderName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-color);
font-size: 95%;
}
#divThreadMessages {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.msg-thread-pane {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.msg-thread-messages {
min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
}
.msg-reply-box {
flex: 0 0 auto;
min-height: 0;
}
.msg-bubble-row {
position: relative;
min-width: 0;
}
.msg-bubble-menu-host {
position: static;
margin-left: auto;
z-index: 2;
}
.msg-bubble-menu-host .dot-menu-trigger {
width: 26px;
height: 26px;
font-size: 18px;
opacity: 1;
}
.msg-bubble,
.msg-bubble-content {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.msg-bubble-content pre {
max-width: 100%;
white-space: pre;
}
#divMsgSendOptions {
padding: 10px 20px 15px 20px;
border-top: 1px solid var(--muted-color);
display: flex;
flex-direction: column;
gap: 8px;
}
#lblSendMode {
font-size: 80%;
color: var(--muted-color);
}
#selSendMode {
width: 100%;
background: var(--secondary-color);
color: var(--primary-color);
border: 1px solid var(--primary-color);
border-radius: 5px;
padding: 6px;
font-size: 85%;
outline: none;
}
#selSendMode:focus {
border-color: var(--accent-color);
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">MSG</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divMsgLayout">
<div id="divThreadPaneHost"></div>
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divConversationsPane">
<div id="divConversationsTitle">Conversations</div>
<div id="divConversationsList"></div>
</div>
<div id="divFiles"></div>
<div id="divMsgSendOptions">
<label id="lblSendMode" for="selSendMode">Send mode</label>
<select id="selSendMode">
<option value="kind4-nip04">kind 4 (NIP-04)</option>
<option value="kind4-nip44">kind 4 (NIP-44 payload)</option>
<option value="nip17-kind1059">NIP-17 (kind 1059 giftwrap)</option>
</select>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
sendNip17Message,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
fetchCachedProfile,
ndkFetchEvents,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { 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, escapeHtml } 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 giftWrapFailureCounts = new Map();
const profileCacheApi = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache
});
let selectedConversationPubkey = null;
let sendMode = localStorage.getItem('msgSendMode') || 'kind4-nip04';
let followedPubkeys = [];
const dmInboxRelaysByPubkey = new Map(); // pubkey -> Array<relayUrl>
const dmInboxRelaySubscriptions = new Map(); // pubkey -> subscription handle
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 messageViewModeById = 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);
}
if (normalized) {
void ensureDmInboxRelaySubscription(normalized);
void refreshDmInboxRelaysForPubkey(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) {
if (!messagingUi) return;
messagingUi.scrollToBottom(force);
}
function configureMarkdownForChat() {
if (!window.marked) return;
marked.setOptions({
gfm: true,
breaks: true,
silent: true
});
}
function getMessageViewKey(msg, index = 0) {
if (msg?.id) return `id:${msg.id}`;
return `fallback:${msg?.created_at || 0}:${msg?.outgoing ? 'out' : 'in'}:${index}`;
}
function getMessageViewMode(msg, index = 0) {
const mode = messageViewModeById.get(getMessageViewKey(msg, index));
if (mode === 'raw' || mode === 'json') return mode;
return 'markdown';
}
function setMessageViewMode(msg, mode, index = 0) {
const normalized = mode === 'raw' || mode === 'json' ? mode : 'markdown';
messageViewModeById.set(getMessageViewKey(msg, index), normalized);
}
function formatJsonForDisplay(content) {
const raw = sanitizeInboundText(content);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed, null, 2);
} catch {
return null;
}
}
async function copyTextToClipboard(text = '') {
const plain = String(text || '');
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(plain);
return;
}
const ta = document.createElement('textarea');
ta.value = plain;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
function renderMessageContent(content) {
const rawText = sanitizeInboundText(content);
if (!rawText) return '';
let rendered = '';
if (window.marked?.parse) {
try {
rendered = window.marked.parse(rawText) || '';
} catch {
rendered = '';
}
}
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',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'class', 'target', 'rel', 'align', 'data-entity', 'data-pubkey', 'data-event-id'],
ALLOW_DATA_ATTR: true
});
}
const wrap = document.createElement('div');
wrap.innerHTML = rendered || escapeHtml(rawText).replace(/\n/g, '<br/>');
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 || '');
const viewMode = getMessageViewMode(msg, index);
if (viewMode === 'raw') {
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = displayText;
return displayText;
}
if (viewMode === 'json') {
const formattedJson = formatJsonForDisplay(displayText);
contentEl.innerHTML = '';
contentEl.style.whiteSpace = 'pre-wrap';
contentEl.textContent = formattedJson || displayText;
return displayText;
}
contentEl.style.whiteSpace = '';
contentEl.innerHTML = renderMessageContent(displayText);
return displayText;
}
function mountBubbleDotMenu(rowEl, contentEl, msg, index = 0, menuHost = null) {
if (!rowEl || !contentEl || !msg) return;
const host = menuHost || document.createElement('div');
if (!menuHost) {
host.className = 'msg-bubble-menu-host';
rowEl.appendChild(host);
} else {
host.innerHTML = '';
}
const idPrefix = (msg.id || '').slice(0, 8) || `#${index + 1}`;
mountDotMenu(host, {
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: 'View as markdown',
onClick: () => {
setMessageViewMode(msg, 'markdown', index);
renderBubbleBody(contentEl, msg, index);
}
},
{
label: 'View as raw',
onClick: () => {
setMessageViewMode(msg, 'raw', index);
renderBubbleBody(contentEl, msg, index);
}
},
{
label: 'View as pretty JSON',
onClick: () => {
setMessageViewMode(msg, 'json', 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 normalizeRelayUrlForDm(relayUrl) {
const raw = String(relayUrl || '').trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
if (!['ws:', 'wss:'].includes(parsed.protocol)) return null;
return parsed.toString();
} catch {
return null;
}
}
function parseDmInboxRelayTags(event) {
const relays = [];
const seen = new Set();
for (const tag of (event?.tags || [])) {
if (!Array.isArray(tag) || tag[0] !== 'relay' || !tag[1]) continue;
const normalized = normalizeRelayUrlForDm(tag[1]);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
relays.push(normalized);
}
return relays;
}
function getLatestEvent(events = []) {
const safe = Array.isArray(events) ? events.filter(Boolean) : [];
if (safe.length === 0) return null;
safe.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
return safe[0] || null;
}
function storeDmInboxRelaysForAuthor(event) {
const author = normalizeConversationTarget(event?.pubkey);
if (!author || event?.kind !== 10050) return;
dmInboxRelaysByPubkey.set(author, parseDmInboxRelayTags(event));
}
async function refreshDmInboxRelaysForPubkey(pubkey) {
const normalized = normalizeConversationTarget(pubkey);
if (!normalized) return [];
const filter = {
kinds: [10050],
authors: [normalized],
limit: 20
};
let latest = null;
try {
const cached = await queryCache(filter);
latest = getLatestEvent(cached);
} catch {
// Ignore cache miss failures.
}
try {
const fetched = await ndkFetchEvents(filter);
const fetchedLatest = getLatestEvent(fetched);
if (fetchedLatest && (!latest || (fetchedLatest.created_at || 0) >= (latest.created_at || 0))) {
latest = fetchedLatest;
}
} catch {
// Ignore relay fetch failures.
}
if (latest) {
storeDmInboxRelaysForAuthor(latest);
return dmInboxRelaysByPubkey.get(normalized) || [];
}
if (!dmInboxRelaysByPubkey.has(normalized)) {
dmInboxRelaysByPubkey.set(normalized, []);
}
return dmInboxRelaysByPubkey.get(normalized) || [];
}
async function fetchKind10002RelayUrls(pubkey) {
const normalized = normalizeConversationTarget(pubkey);
if (!normalized) return [];
const filter = {
kinds: [10002],
authors: [normalized],
limit: 20
};
let latest = null;
try {
const cached = await queryCache(filter);
latest = getLatestEvent(cached);
} catch {
// Ignore cache errors.
}
try {
const fetched = await ndkFetchEvents(filter);
const fetchedLatest = getLatestEvent(fetched);
if (fetchedLatest && (!latest || (fetchedLatest.created_at || 0) >= (latest.created_at || 0))) {
latest = fetchedLatest;
}
} catch {
// Ignore fetch errors.
}
if (!latest) return [];
const relays = [];
const seen = new Set();
for (const tag of (latest.tags || [])) {
if (!Array.isArray(tag) || tag[0] !== 'r' || !tag[1]) continue;
const normalizedRelay = normalizeRelayUrlForDm(tag[1]);
if (!normalizedRelay || seen.has(normalizedRelay)) continue;
seen.add(normalizedRelay);
relays.push(normalizedRelay);
}
return relays.slice(0, 3);
}
async function getDmInboxRelayUrlsForPubkey(pubkey) {
const normalized = normalizeConversationTarget(pubkey);
if (!normalized) return [];
if (dmInboxRelaysByPubkey.has(normalized)) {
const known = dmInboxRelaysByPubkey.get(normalized) || [];
if (known.length > 0) return known;
}
const dmInbox = await refreshDmInboxRelaysForPubkey(normalized);
if (dmInbox.length > 0) return dmInbox;
return await fetchKind10002RelayUrls(normalized);
}
function ensureDmInboxRelaySubscription(pubkey) {
const normalized = normalizeConversationTarget(pubkey);
if (!normalized) return null;
if (dmInboxRelaySubscriptions.has(normalized)) {
return dmInboxRelaySubscriptions.get(normalized);
}
const sub = subscribe(
{ kinds: [10050], authors: [normalized], limit: 20 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
dmInboxRelaySubscriptions.set(normalized, sub);
return sub;
}
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) {
console.error('[msg.html] Giftwrap decrypt unavailable:', {
eventId: event?.id,
eventPubkey: shortKey(event?.pubkey || '')
});
throw new Error('NIP-44 decrypt unavailable');
}
let sealedJson;
try {
sealedJson = sanitizeInboundText(await window.nostr.nip44.decrypt(event.pubkey, event.content || ''));
} catch (error) {
console.error('[msg.html] Giftwrap outer decrypt failed:', {
eventId: event?.id,
eventPubkey: shortKey(event?.pubkey || ''),
error: error?.message || error
});
throw error;
}
let seal;
try {
seal = JSON.parse(sealedJson);
} catch (error) {
console.error('[msg.html] Giftwrap outer JSON parse failed:', {
eventId: event?.id,
sealedPreview: String(sealedJson || '').slice(0, 160),
error: error?.message || error
});
throw new Error('Invalid giftwrap payload JSON');
}
if (!seal || seal.kind !== 13 || !seal.pubkey || !seal.content) {
console.error('[msg.html] Giftwrap seal validation failed:', {
eventId: event?.id,
sealKind: seal?.kind,
sealPubkey: shortKey(seal?.pubkey || ''),
hasContent: !!seal?.content
});
throw new Error('Giftwrap did not contain valid kind-13 seal');
}
let rumorJson;
try {
rumorJson = sanitizeInboundText(await window.nostr.nip44.decrypt(seal.pubkey, seal.content || ''));
} catch (error) {
console.error('[msg.html] Giftwrap inner decrypt failed:', {
eventId: event?.id,
sealPubkey: shortKey(seal?.pubkey || ''),
error: error?.message || error
});
throw error;
}
let rumor;
try {
rumor = JSON.parse(rumorJson);
} catch (error) {
console.error('[msg.html] Giftwrap rumor JSON parse failed:', {
eventId: event?.id,
rumorPreview: String(rumorJson || '').slice(0, 160),
error: error?.message || error
});
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:', {
eventId: event?.id,
eventCreatedAt: event?.created_at,
eventPubkey: shortKey(event?.pubkey || ''),
error: error?.message || error
});
return { changed: false, selectedChanged: false, retryable: true, failureReason: error?.message || String(error) };
}
if (!rumor || !rumor.kind) {
console.warn('[msg.html] Giftwrap rumor missing kind:', {
eventId: event?.id,
rumorId: rumor?.id,
rumorKind: 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) {
console.warn('[msg.html] Giftwrap rumor not addressed to current user context:', {
eventId: event?.id,
rumorId: rumor?.id,
rumorPubkey: shortKey(rumor?.pubkey || ''),
currentPubkey: shortKey(currentPubkey || ''),
rumorPTags: Array.isArray(rumor?.tags)
? rumor.tags.filter(tag => Array.isArray(tag) && tag[0] === 'p').map(tag => shortKey(tag[1] || ''))
: []
});
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();
}
giftWrapFailureCounts.delete(event.id);
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;
const retryQueue = [];
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 (result?.retryable && event?.id) {
const failureCount = (giftWrapFailureCounts.get(event.id) || 0) + 1;
giftWrapFailureCounts.set(event.id, failureCount);
if (failureCount <= 3) {
console.warn('[msg.html] Scheduling giftwrap retry after decode failure:', {
eventId: event.id,
failureCount,
reason: result?.failureReason || 'unknown'
});
retryQueue.push(event);
} else {
console.error('[msg.html] Dropping giftwrap after repeated decode failures:', {
eventId: event.id,
failureCount,
reason: result?.failureReason || 'unknown'
});
}
}
}
if (retryQueue.length > 0) {
pendingGiftWrapEvents.push(...retryQueue);
}
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);
}
function processDmInboxRelayEvent(event) {
if (!event || event.kind !== 10050 || !event.pubkey) return;
storeDmInboxRelaysForAuthor(event);
}
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) {
console.debug('[msg.html] Ignoring giftwrap not addressed to current user:', {
eventId: event.id,
tagP: shortKey(p || ''),
currentPubkey: shortKey(currentPubkey || '')
});
return;
}
seenEventIds.add(event.id);
pendingGiftWrapEvents.push(event);
console.debug('[msg.html] Queued giftwrap event:', {
eventId: event.id,
queueSize: pendingGiftWrapEvents.length,
deferProcessing,
suppressUiRefresh
});
if (deferProcessing || suppressUiRefresh) {
scheduleGiftWrapDrain();
return;
}
const { changed, selectedChanged, remaining } = await processPendingGiftWrapEvents();
if (remaining > 0) {
scheduleGiftWrapDrain();
}
if (changed && !selectedChanged && !suppressUiRefresh) {
renderConversationList();
}
if (selectedChanged) {
await 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();
messagingUi?.setMessages([]);
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) {
setThreadProgressBanner(`Decrypting recent messages… 0/${eagerDecryptTargets.length}`);
}
for (let i = 0; i < eagerDecryptTargets.length; i += 1) {
const msg = eagerDecryptTargets[i];
setThreadProgressBanner(`Decrypting recent messages… ${i + 1}/${eagerDecryptTargets.length}`);
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) {
setThreadProgressBanner('Rendering messages…');
}
const visibleMessagesWithSenderMeta = visibleMessages.map((msg) => ({
...msg,
senderName: msg?.outgoing ? 'You' : getConversationName(selectedConversationPubkey),
senderAvatarUrl: msg?.outgoing ? getConversationAvatar(currentPubkey) : getConversationAvatar(selectedConversationPubkey)
}));
messagingUi?.setMessages(visibleMessagesWithSenderMeta);
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) {
setThreadProgressBanner('');
}
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;
}
if (msg?.id && messagingUi?.updateMessage(msg.id, {
content: msg.content,
protocol: msg.protocol,
needsKind4Decrypt: msg.needsKind4Decrypt
})) {
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;
}
async function publishNip17GiftwrapMessage(recipientPubkey, plaintext) {
const normalizedRecipient = normalizeConversationTarget(recipientPubkey);
const messageText = String(plaintext || '');
if (!normalizedRecipient) {
throw new Error('Invalid recipient pubkey for NIP-17 send');
}
if (!messageText.trim()) {
throw new Error('NIP-17 message is empty');
}
const sendResult = await sendNip17Message(normalizedRecipient, messageText);
const success = Boolean(sendResult?.success);
console.log('[msg.html] NIP-17 send result:', {
success,
eventId: sendResult?.eventId || null,
relayResults: sendResult?.relayResults || null,
debug: sendResult?.debug || null
});
if (!success) {
const failedRelays = Array.isArray(sendResult?.relayResults?.failed)
? sendResult.relayResults.failed
: [];
throw new Error(`NIP-17 publish failed (${failedRelays.length} failed relays)`);
}
// Do not add an optimistic local echo for NIP-17.
// The incoming kind-1059 event is decrypted into a kind-14 rumor and
// rendered by the normal receive pipeline, which avoids duplicate rows.
}
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' }
);
ensureDmInboxRelaySubscription(currentPubkey);
await refreshDmInboxRelaysForPubkey(currentPubkey);
if (selectedConversationPubkey) {
ensureDmInboxRelaySubscription(selectedConversationPubkey);
await refreshDmInboxRelaysForPubkey(selectedConversationPubkey);
}
}
(async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', async () => {
await Logout();
});
configureMarkdownForChat();
initSendModeUI();
messagingUi = mountMessagingWindow(divThreadPaneHost, {
headerName: 'Select a conversation',
emptyStateText: 'Open a conversation to view messages',
renderBubbleBody,
onBubbleRendered: (rowEl, bubbleEl, contentEl, msg, index, menuHost) => {
hydrateNostrEntities(contentEl);
mountBubbleDotMenu(rowEl, contentEl, msg, index, menuHost);
},
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 ensureProfileName(currentPubkey, { render: false });
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);
} else if (evt?.kind === 10050) {
processDmInboxRelayEvent(evt);
}
});
await loadCachedMessages();
renderConversationList();
if (selectedConversationPubkey) {
await hydrateConversationHistory(selectedConversationPubkey, { limit: CONVERSATION_HYDRATE_LIMIT });
}
renderThread();
scrollThreadToBottom(true);
await startMessageSubscriptions();
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>`;
}
})();
</script>
</body>
</html>