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

1111 lines
36 KiB
JavaScript

import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
publishRawEvent,
disconnect,
getVersion,
updateVersionDisplay,
queryCache,
fetchCachedProfile,
fetchEventsFromAllRelays,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { htmlFormatText } from './js/utilities.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 { mountComposer } from './js/post-composer.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 divThreadHeader = document.getElementById('divThreadHeader');
const divThreadMessages = document.getElementById('divThreadMessages');
const divReplyInput = document.getElementById('divReplyInput');
const selSendMode = document.getElementById('selSendMode');
const seenEventIds = new Set();
const seenRumorIds = new Set();
const conversations = new Map();
const profileCacheApi = createProfileCache({
fetchCachedProfile,
fetchEventsFromAllRelays,
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;
let isLoadingOlderMessages = false;
let composer = null;
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 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 collected = new Set();
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) {
try {
const contactsByRelay = await fetchEventsFromAllRelays({ kinds: [3], authors: [currentPubkey], limit: 5 });
const contactEvents = Object.values(contactsByRelay || {}).flat();
if (contactEvents.length > 0) {
latest = contactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
}
} catch (_) {
// Ignore relay failure.
}
}
const tags = Array.isArray(latest?.tags) ? latest.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);
}
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);
renderConversationList();
renderThread(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() {
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;
}
function renderMessageContent(content) {
const rawText = String(content || '');
if (!rawText) return '';
const formattedText = htmlFormatText(rawText);
let rendered = '';
if (window.marked?.parse) {
try {
rendered = window.marked.parse(formattedText) || '';
} catch (_) {
rendered = '';
}
}
if (!rendered) {
return formattedText;
}
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 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
});
}
return conversations.get(pubkey);
}
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) {
if (!pubkey) return;
await profileCacheApi.fetchProfile(pubkey, { logPrefix: '[msg.html]' });
renderConversationList();
renderThread();
}
async function decryptKind4Content(event, counterpartyPubkey) {
if (event._decodedKind4) return event._decodedKind4;
if (window.nostr?.nip04?.decrypt) {
try {
const plaintext = 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 = 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;
}
async function decryptGiftWrapContent(event) {
if (!window.nostr?.nip44?.decrypt) {
throw new Error('NIP-44 decrypt unavailable');
}
const sealedJson = 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 = 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) {
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);
const decoded = await decryptKind4Content(event, counterpartyPubkey);
const conv = ensureConversation(counterpartyPubkey);
conv.messages.push({
id: event.id,
created_at: event.created_at || 0,
outgoing,
content: decoded.plaintext,
protocol: decoded.protocol
});
if ((event.created_at || 0) >= conv.lastAt) {
conv.lastAt = event.created_at || 0;
conv.preview = (decoded.plaintext || '').replace(/\s+/g, ' ').trim();
}
renderConversationList();
if (selectedConversationPubkey === counterpartyPubkey) {
renderThread(true);
}
}
async function processGiftWrapEvent(event) {
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);
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;
}
if (!rumor || !rumor.kind) return;
if (rumor.id && seenRumorIds.has(rumor.id)) return;
if (rumor.id) seenRumorIds.add(rumor.id);
const { counterpartyPubkey, outgoing } = getRumorCounterparty(rumor);
if (!counterpartyPubkey) return;
ensureProfileName(counterpartyPubkey);
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();
}
renderConversationList();
if (selectedConversationPubkey === counterpartyPubkey) {
renderThread(true);
}
}
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', () => {
pendingConversationPubkey = null;
setSelectedConversationPubkey(conv.pubkey);
const selectedConv = conversations.get(conv.pubkey);
if (selectedConv) {
selectedConv.renderCount = THREAD_INITIAL_RENDER_LIMIT;
}
renderConversationList();
renderThread(true);
if (isNavOpen) closeNav();
});
divConversationsList.appendChild(item);
}
composer?.setDisabled(!selectedConversationPubkey);
}
function renderThread(scrollToBottom = false) {
if (!selectedConversationPubkey || !conversations.has(selectedConversationPubkey)) {
renderConversationStarter();
divThreadMessages.innerHTML = '<div class="msgEmptyState">Open a conversation to view messages</div>';
composer?.setDisabled(true);
return;
}
const conv = conversations.get(selectedConversationPubkey);
const displayName = getConversationName(selectedConversationPubkey);
const avatarUrl = getConversationAvatar(selectedConversationPubkey);
const escapedName = escapeHtml(displayName);
if (avatarUrl) {
const safeAvatar = escapeHtml(avatarUrl);
divThreadHeader.innerHTML = `<div class="msgThreadHeaderRow"><img class="msgThreadHeaderAvatar" src="${safeAvatar}" alt="" onerror="this.style.visibility='hidden'" /><div class="msgThreadHeaderName">${escapedName}</div></div>`;
} else {
divThreadHeader.innerHTML = `<div class="msgThreadHeaderRow"><div class="msgThreadHeaderAvatar"></div><div class="msgThreadHeaderName">${escapedName}</div></div>`;
}
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);
divThreadMessages.innerHTML = '';
for (const msg of visibleMessages) {
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';
content.innerHTML = renderMessageContent(msg.content || '');
hydrateNostrEntities(content);
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);
divThreadMessages.appendChild(row);
}
composer?.setDisabled(false);
if (scrollToBottom) {
scrollThreadToBottom();
}
}
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);
renderThread(false);
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(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 = '50vw';
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: 500 };
const outgoingKind4 = { kinds: [4], authors: [currentPubkey], limit: 500 };
const incomingGiftWrap = { kinds: [1059], '#p': [currentPubkey], limit: 500 };
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);
} else if (event.kind === 1059) {
await processGiftWrapEvent(event);
}
}
} catch (error) {
console.warn('[msg.html] Failed to load cached direct messages:', error?.message || error);
}
}
async function startMessageSubscriptions() {
subscribe(
{ kinds: [4], '#p': [currentPubkey], limit: 500 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
subscribe(
{ kinds: [4], authors: [currentPubkey], limit: 500 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
subscribe(
{ kinds: [1059], '#p': [currentPubkey], limit: 500 },
{ 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();
composer = mountComposer(divReplyInput, {
layout: 'inline',
showPreview: true,
showUploadIcon: true,
submitOnEnter: false,
alwaysShowSendButton: true,
disabled: true,
onSubmit: async (plaintext) => {
const sent = await sendReply(plaintext);
if (sent) composer?.clear();
}
});
divThreadMessages.addEventListener('scroll', () => {
if (divThreadMessages.scrollTop <= 50) {
loadOlderMessagesForSelectedConversation();
}
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await loadFollowedPubkeys();
console.log('[msg.html] Authenticated as:', currentPubkey);
if (pendingConversationPubkey) {
ensureConversation(pendingConversationPubkey);
ensureProfileName(pendingConversationPubkey);
setSelectedConversationPubkey(pendingConversationPubkey);
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
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();
renderThread(true);
scrollThreadToBottom();
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>`;
}
})();