1126 lines
36 KiB
JavaScript
1126 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;
|
|
let threadRenderVersion = 0;
|
|
|
|
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 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]';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function renderThread(scrollToBottom = false) {
|
|
const renderVersion = ++threadRenderVersion;
|
|
|
|
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);
|
|
|
|
for (const msg of visibleMessages) {
|
|
if (!msg?.needsKind4Decrypt || !msg.encryptedKind4Event || !msg.counterpartyPubkey) continue;
|
|
const decoded = await decryptKind4Content(msg.encryptedKind4Event, msg.counterpartyPubkey);
|
|
msg.content = decoded.plaintext || '';
|
|
msg.protocol = decoded.protocol || msg.protocol;
|
|
msg.needsKind4Decrypt = false;
|
|
}
|
|
|
|
if (renderVersion !== threadRenderVersion) return;
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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(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>`;
|
|
}
|
|
})();
|
|
|