Files
client/www/js/post-interactions.mjs
T

2871 lines
105 KiB
JavaScript

/**
* Post Interactions Module
* Reusable module for rendering and managing social interactions on posts
* Handles likes, comments, quotes, and zaps with NDK integration
*/
import { htmlFormatText, sanitizeInboundText } from './utilities.mjs';
import { createProfileCache } from './profile-cache.mjs';
import { mountDotMenu } from './dot-menu.mjs';
import {
promptZapDetails,
promptNutzapDetails,
prepareZapInvoiceForEvent,
resolveNutzapSpecForPubkey,
resolveZapCapabilities,
extractZapAmount as extractZapAmountFromReceipt,
extractZapComment as extractZapCommentFromReceipt
} from './zaps.mjs';
// =============================================================================
// CONSTANTS
// =============================================================================
const NDK_KIND = {
TEXT_NOTE: 1, // Comments/replies
REPOST: 6, // Reposts
REACTION: 7, // Likes/reactions
NUTZAP: 9321, // NIP-61 nutzaps
ZAP_RECEIPT: 9735 // Zap receipts
};
// =============================================================================
// GLOBAL STATE
// =============================================================================
// Global comments visibility state (persisted to localStorage)
let commentsVisible = localStorage.getItem('commentsVisible') !== 'false';
// Callbacks for when comments visibility changes
const commentsVisibilityCallbacks = [];
// =============================================================================
// PROFILE FETCHING
// =============================================================================
const profileCacheApi = createProfileCache();
// Module-level references to event fetch functions (set during init)
let ndkFetchEventsFn = null;
let queryCacheFn = null;
/**
* Fetch and cache a user's profile (kind 0)
* @param {string} pubkey - The user's pubkey
* @returns {Promise<Object|null>} Profile object or null
*/
function fetchProfile(pubkey) {
return profileCacheApi.fetchProfile(pubkey, { logPrefix: '[post-interactions]' });
}
/**
* Pre-warm the profile cache with a batch of kind 0 events.
* @param {Array} profileEvents - Array of raw kind 0 Nostr events
*/
function prewarmProfileCache(profileEvents) {
profileCacheApi.prewarmProfileCache(profileEvents, { logPrefix: '[post-interactions]' });
}
/**
* Seed a single profile into the in-memory cache.
* @param {string} pubkey - The profile pubkey
* @param {Object} profile - Parsed profile object
*/
function seedProfileCache(pubkey, profile) {
profileCacheApi.seedProfileCache(pubkey, profile);
}
/**
* Apply profile data to an author header's avatar and name elements
* @param {Object} profile - Profile object with name, display_name, picture
* @param {HTMLImageElement} avatar - The avatar img element
* @param {HTMLElement} nameEl - The name span element
*/
function applyProfileToHeader(profile, avatar, nameEl) {
if (profile.picture) {
avatar.src = profile.picture;
avatar.style.display = '';
}
const displayName = profile.display_name || profile.name;
if (displayName) {
nameEl.textContent = displayName;
}
}
/**
* Render an author header with profile pic, name, and per-post dropdown menu
* @param {Object} eventData - The full post event data
* @param {Object} options
* @param {boolean} options.showHeader - Whether to show the header (default: true)
* @returns {HTMLElement} The header element (may update async when profile loads)
*/
async function copyToClipboard(text) {
const value = String(text || '').trim();
if (!value) return false;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
try {
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.left = '-1000px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return !!ok;
} catch (_) {
return false;
}
}
function toNpubRef(pubkey) {
try {
return window?.NostrTools?.nip19?.npubEncode
? window.NostrTools.nip19.npubEncode(pubkey)
: pubkey;
} catch (_) {
return pubkey;
}
}
function toNeventRef(eventData = {}) {
const id = String(eventData?.id || '').trim();
if (!id) return '';
try {
if (window?.NostrTools?.nip19?.neventEncode) {
const payload = { id };
const author = String(eventData?.pubkey || '').trim();
if (author) payload.author = author;
const kind = Number(eventData?.kind);
if (Number.isInteger(kind)) payload.kind = kind;
return window.NostrTools.nip19.neventEncode(payload);
}
} catch (_) {}
return id;
}
function renderPostMenuButton(eventData = {}, options = {}) {
const menuWrap = document.createElement('div');
menuWrap.className = 'divPostMenuWrap';
const items = [
{
label: 'Copy text',
onClick: async () => {
await copyToClipboard(eventData?.content || '');
}
},
{
label: 'Copy author npub',
onClick: async () => {
await copyToClipboard(toNpubRef(eventData?.pubkey || ''));
}
},
{
label: 'Copy note id',
onClick: async () => {
await copyToClipboard(eventData?.id || '');
}
},
{
label: 'Copy nevent',
onClick: async () => {
await copyToClipboard(toNeventRef(eventData));
}
}
];
if (typeof onMuteIntentFn === 'function') {
items.push({
label: 'Mute author',
onClick: async () => {
try {
await onMuteIntentFn({
eventData,
currentPubkey: options.currentPubkey || ''
});
} catch (error) {
console.warn('[post-interactions] Mute intent failed:', error?.message || error);
}
}
});
}
mountDotMenu(menuWrap, {
ariaLabel: 'Post options',
triggerLabel: '⋯',
position: 'right',
items
});
return menuWrap;
}
export function renderAuthorHeader(eventData, options = {}) {
const { showHeader = true, currentPubkey = '' } = options;
const pubkey = eventData?.pubkey || '';
const header = document.createElement('div');
header.className = 'divPostHeader';
if (!showHeader) {
header.style.display = 'none';
return header;
}
const main = document.createElement('div');
main.className = 'divPostHeaderMain';
// Avatar (small square image)
const avatar = document.createElement('img');
avatar.className = 'divPostAvatar';
avatar.alt = '';
avatar.src = '';
avatar.style.display = 'none'; // Hidden until image loads successfully
// Show avatar only when image actually loads
avatar.addEventListener('load', () => {
avatar.style.display = '';
});
avatar.addEventListener('error', () => {
avatar.style.display = 'none';
});
// Author name
const nameEl = document.createElement('span');
nameEl.className = 'divPostAuthorName';
// Show truncated pubkey as placeholder
nameEl.textContent = pubkey ? pubkey.substring(0, 8) + '…' + pubkey.substring(pubkey.length - 4) : '(unknown)';
// Wrap avatar and name in a link to the user's profile page
const profileLink = document.createElement('a');
profileLink.className = 'divPostProfileLink';
profileLink.href = `post.html?profile=${encodeURIComponent(pubkey)}`;
profileLink.target = '_blank';
profileLink.rel = 'noopener noreferrer';
profileLink.appendChild(avatar);
profileLink.appendChild(nameEl);
main.appendChild(profileLink);
header.appendChild(main);
header.appendChild(renderPostMenuButton(eventData, { currentPubkey }));
// Fetch profile and update (works for cached, in-flight, or new fetches)
fetchProfile(pubkey).then(profile => {
if (profile) {
applyProfileToHeader(profile, avatar, nameEl);
}
});
return header;
}
/**
* Build an NDK-style filter from a NIP-19 bech32 entity.
* Mirrors the behavior of NDK's filterFromId() for note/nevent/naddr.
* @param {string} bech32 - note1/nevent1/naddr1 entity (without nostr: prefix)
* @returns {Object|null} Nostr filter object or null
*/
function buildFilterFromNostrEntity(bech32) {
if (!bech32 || !window?.NostrTools?.nip19?.decode) {
console.log('[post-interactions] buildFilterFromNostrEntity: missing bech32 or nip19 decoder');
return null;
}
let decoded;
try {
decoded = window.NostrTools.nip19.decode(bech32);
} catch (err) {
console.log('[post-interactions] buildFilterFromNostrEntity: decode failed for entity:', bech32?.slice?.(0, 24) + '…', err?.message || err);
return null;
}
if (!decoded?.type) {
console.log('[post-interactions] buildFilterFromNostrEntity: decoded without type for entity:', bech32?.slice?.(0, 24) + '…');
return null;
}
switch (decoded.type) {
case 'note': {
const filter = { ids: [decoded.data] };
console.log('[post-interactions] buildFilterFromNostrEntity: note filter', filter);
return filter;
}
case 'nevent': {
const filter = { ids: [decoded.data.id] };
if (decoded.data.author) filter.authors = [decoded.data.author];
if (decoded.data.kind) filter.kinds = [decoded.data.kind];
console.log('[post-interactions] buildFilterFromNostrEntity: nevent filter', filter);
return filter;
}
case 'naddr': {
const filter = {
authors: [decoded.data.pubkey],
kinds: [decoded.data.kind]
};
if (decoded.data.identifier) filter['#d'] = [decoded.data.identifier];
console.log('[post-interactions] buildFilterFromNostrEntity: naddr filter', {
bech32: bech32.slice(0, 24) + '…',
kind: decoded.data.kind,
author: decoded.data.pubkey?.slice?.(0, 8) + '…',
identifier: decoded.data.identifier || '(none)',
relays: decoded.data.relays || []
}, filter);
return filter;
}
default:
console.log('[post-interactions] buildFilterFromNostrEntity: unsupported type', decoded.type);
return null;
}
}
/**
* Hydrate rendered nostr entities in post content.
* - Mentions (npub/nprofile): replace short hex with profile display name
* - Embeds (note/nevent/naddr): fetch referenced event and render preview
* @param {HTMLElement} contentEl
*/
export function hydrateNostrEntities(contentEl) {
if (!contentEl) return;
// Inline nprofile card hydration
const profileInlineEls = Array.from(contentEl.querySelectorAll('.nostr-profile-inline[data-entity]'));
profileInlineEls.forEach((profileEl) => {
let pubkey = profileEl.dataset.pubkey;
if (!pubkey) {
const bech32 = profileEl.dataset.entity;
if (bech32 && window?.NostrTools?.nip19?.decode) {
try {
const decoded = window.NostrTools.nip19.decode(bech32);
if (decoded.type === 'nprofile') pubkey = decoded.data?.pubkey;
if (decoded.type === 'npub') pubkey = decoded.data;
} catch (_) {}
}
if (pubkey) {
profileEl.dataset.pubkey = pubkey;
}
}
if (!pubkey) return;
const linkEl = profileEl.querySelector('.nostr-profile-link');
if (linkEl) {
linkEl.href = `post.html?profile=${encodeURIComponent(pubkey)}`;
}
fetchProfile(pubkey).then((profile) => {
if (!profile) return;
const nameEl = profileEl.querySelector('.nostr-profile-name');
const avatarEl = profileEl.querySelector('.nostr-profile-avatar');
const displayName = profile.display_name || profile.name;
if (nameEl && displayName) {
nameEl.textContent = `@${displayName}`;
}
if (avatarEl) {
if (profile.picture) {
avatarEl.src = profile.picture;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
}
}).catch(() => {});
});
// Mention hydration
const mentionEls = Array.from(contentEl.querySelectorAll('.nostr-mention[data-entity]'));
mentionEls.forEach((mentionEl) => {
let pubkey = mentionEl.dataset.pubkey;
// If formatter couldn't decode at render-time, try again here in-browser.
if (!pubkey) {
const bech32 = mentionEl.dataset.entity;
if (bech32 && window?.NostrTools?.nip19?.decode) {
try {
const decoded = window.NostrTools.nip19.decode(bech32);
if (decoded.type === 'npub') pubkey = decoded.data;
if (decoded.type === 'nprofile') pubkey = decoded.data?.pubkey;
} catch (_) {}
}
if (pubkey) {
mentionEl.dataset.pubkey = pubkey;
mentionEl.href = `post.html?profile=${encodeURIComponent(pubkey)}`;
}
}
if (!pubkey) return;
fetchProfile(pubkey).then((profile) => {
if (!profile) return;
const nameEl = mentionEl.querySelector('.nostr-mention-name');
if (!nameEl) return;
const displayName = profile.display_name || profile.name;
if (displayName) {
nameEl.textContent = `@${displayName}`;
}
}).catch(() => {});
});
// Event embed hydration
const embedEls = Array.from(contentEl.querySelectorAll('.nostr-embed[data-entity], .nostr-embed[data-event-id]'));
embedEls.forEach(async (embedEl) => {
if (embedEl.dataset.hydrated === 'true') return;
embedEl.dataset.hydrated = 'true';
const bech32 = embedEl.dataset.entity;
const fallbackEventId = embedEl.dataset.eventId;
let filter = null;
if (bech32) {
filter = buildFilterFromNostrEntity(bech32);
console.log('[post-interactions] hydrateNostrEntities: entity -> filter JSON', JSON.stringify({
entity: bech32.slice(0, 24) + '…',
hasFilter: !!filter,
filter
}, null, 2));
}
if (!filter && fallbackEventId) {
filter = { ids: [fallbackEventId] };
console.log('[post-interactions] hydrateNostrEntities: using fallback event id filter', filter);
}
if (!filter || (!queryCacheFn && !ndkFetchEventsFn)) {
console.log('[post-interactions] hydrateNostrEntities: missing filter or fetch functions', {
hasFilter: !!filter,
hasCacheFetcher: !!queryCacheFn,
hasRelayFetcher: !!ndkFetchEventsFn,
entity: bech32?.slice?.(0, 24) + '…' || '(none)',
fallbackEventId: fallbackEventId || '(none)'
});
const contentNode = embedEl.querySelector('.nostr-embed-content');
if (contentNode) contentNode.textContent = 'Unable to resolve referenced event.';
return;
}
try {
let events = [];
if (queryCacheFn) {
try {
const cachedEvents = await queryCacheFn(filter);
events = Array.isArray(cachedEvents) ? cachedEvents : [];
console.log('[post-interactions] hydrateNostrEntities: cache result JSON', JSON.stringify({
entity: bech32?.slice?.(0, 24) + '…' || '(none)',
filter,
eventCount: events.length
}, null, 2));
} catch (cacheErr) {
console.warn('[post-interactions] hydrateNostrEntities cache lookup failed:', cacheErr?.message || cacheErr);
}
}
if ((!events || events.length === 0) && ndkFetchEventsFn) {
events = await ndkFetchEventsFn(filter);
console.log('[post-interactions] hydrateNostrEntities: relay fetch result JSON', JSON.stringify({
entity: bech32?.slice?.(0, 24) + '…' || '(none)',
filter,
eventCount: events?.length || 0
}, null, 2));
}
if (!events || events.length === 0) {
const contentNode = embedEl.querySelector('.nostr-embed-content');
if (contentNode) contentNode.textContent = 'Referenced event not found.';
return;
}
const event = events.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
console.log('[post-interactions] hydrateNostrEntities: selected referenced event JSON', JSON.stringify({
id: event?.id,
kind: event?.kind,
pubkey: event?.pubkey?.slice?.(0, 8) + '…',
contentLength: (event?.content || '').length,
tagCount: (event?.tags || []).length,
createdAt: event?.created_at
}, null, 2));
console.log('[post-interactions] hydrateNostrEntities: selected event FULL payload JSON', JSON.stringify(event, null, 2));
const labelNode = embedEl.querySelector('.nostr-embed-label');
const contentNode = embedEl.querySelector('.nostr-embed-content');
const getTagValue = (key) => {
const tag = (event.tags || []).find((t) => t[0] === key && typeof t[1] === 'string' && t[1].trim());
return tag ? tag[1].trim() : '';
};
const getFirstHttpUrl = (value) => {
if (!value || typeof value !== 'string') return '';
const match = value.match(/https?:\/\/[^\s)]+/i);
return match ? match[0] : '';
};
const tagSnapshot = {
d: getTagValue('d'),
title: getTagValue('title'),
summary: getTagValue('summary'),
name: getTagValue('name'),
subject: getTagValue('subject'),
alt: getTagValue('alt'),
description: getTagValue('description'),
published_at: getTagValue('published_at')
};
console.log('[post-interactions] hydrateNostrEntities: tag snapshot for preview JSON', JSON.stringify({
id: event?.id,
kind: event?.kind,
tags: event?.tags || [],
tagSnapshot
}, null, 2));
let preview = sanitizeInboundText(event.content || '').trim();
console.log('[post-interactions] hydrateNostrEntities: raw content preview candidate JSON', JSON.stringify({
id: event?.id,
kind: event?.kind,
rawContentPreview: preview.slice(0, 280),
rawContentLength: preview.length
}, null, 2));
// Fallbacks for addressable/structured events where content may be empty.
if (!preview) {
let parsed = null;
try {
parsed = event.content ? JSON.parse(event.content) : null;
} catch (_) {
parsed = null;
}
const fromJson = parsed && typeof parsed === 'object'
? (parsed.summary || parsed.description || parsed.about || parsed.content || parsed.name || parsed.title || '')
: '';
const fromTags =
getTagValue('title') ||
getTagValue('summary') ||
getTagValue('name') ||
getTagValue('subject') ||
getTagValue('alt') ||
getTagValue('description') ||
getTagValue('published_at') ||
getTagValue('d');
preview = sanitizeInboundText(fromJson || fromTags || '').trim();
console.log('[post-interactions] hydrateNostrEntities: preview fallback resolution JSON', JSON.stringify({
id: event?.id,
kind: event?.kind,
fromJson: String(fromJson || '').slice(0, 120),
fromTags: String(fromTags || '').slice(0, 120),
finalPreviewLength: preview.length
}, null, 2));
}
if (labelNode) {
const shortAuthor = event.pubkey ? `${event.pubkey.substring(0, 8)}…` : 'unknown';
const kindSuffix = Number.isFinite(Number(event.kind)) ? ` (kind ${event.kind})` : '';
labelNode.innerHTML = '';
const authorLink = document.createElement('a');
authorLink.className = 'nostr-embed-author-link';
authorLink.href = event.pubkey
? `post.html?profile=${encodeURIComponent(event.pubkey)}`
: '#';
authorLink.target = '_blank';
authorLink.rel = 'noopener noreferrer';
const authorAvatar = document.createElement('img');
authorAvatar.className = 'nostr-embed-author-avatar';
authorAvatar.alt = '';
authorAvatar.style.display = 'none';
const authorName = document.createElement('span');
authorName.className = 'nostr-embed-author-name';
authorName.textContent = shortAuthor;
authorLink.appendChild(authorAvatar);
authorLink.appendChild(authorName);
labelNode.appendChild(authorLink);
if (event.pubkey) {
fetchProfile(event.pubkey).then((profile) => {
if (!profile) return;
const displayName = profile.display_name || profile.name;
if (displayName) {
authorName.textContent = displayName;
}
if (profile.picture) {
authorAvatar.src = profile.picture;
authorAvatar.style.display = '';
}
}).catch(() => {});
}
}
if (contentNode) {
const normalizedPreview = sanitizeInboundText(preview) || '(no preview text available)';
const finalPreview = normalizedPreview.length > 280
? `${normalizedPreview.slice(0, 277)}…`
: normalizedPreview;
const thumbTagUrl = getTagValue('thumb');
const imageTagUrl = getTagValue('image');
const mainImageUrl = imageTagUrl || thumbTagUrl;
const altUrl = getFirstHttpUrl(getTagValue('alt'));
const streamUrl = getTagValue('streaming');
const linkTarget = altUrl || (streamUrl && /^https?:\/\//i.test(streamUrl) ? streamUrl : '') || (bech32 ? `nostr:${bech32}` : '');
console.log('[post-interactions] hydrateNostrEntities: final preview JSON', JSON.stringify({
id: event?.id,
kind: event?.kind,
normalizedPreview,
previewLength: normalizedPreview.length,
preview: finalPreview,
thumbTagUrl,
imageTagUrl,
mainImageUrl,
linkTarget
}, null, 2));
contentNode.innerHTML = '';
if (mainImageUrl) {
const thumbLink = document.createElement('a');
thumbLink.className = 'nostr-embed-thumb-link';
thumbLink.href = linkTarget || mainImageUrl;
thumbLink.target = '_blank';
thumbLink.rel = 'noopener noreferrer';
const img = document.createElement('img');
img.className = 'nostr-embed-thumb';
img.src = mainImageUrl;
img.alt = finalPreview;
img.loading = 'lazy';
thumbLink.appendChild(img);
contentNode.appendChild(thumbLink);
}
const textEl = document.createElement(linkTarget ? 'a' : 'div');
textEl.className = 'nostr-embed-preview-text';
textEl.textContent = finalPreview;
if (linkTarget) {
textEl.href = linkTarget;
textEl.target = '_blank';
textEl.rel = 'noopener noreferrer';
}
contentNode.appendChild(textEl);
}
} catch (err) {
const contentNode = embedEl.querySelector('.nostr-embed-content');
if (contentNode) contentNode.textContent = 'Failed to fetch referenced event.';
console.warn('[post-interactions] Failed to hydrate nostr embed:', err?.message || err);
}
});
}
function isVideoUrl(url) {
if (!url || typeof url !== 'string') return false;
return /\.(mp4|webm|ogg|mov|m4v)(?:$|[?#])/i.test(url);
}
function extractMediaUrlsFromEvent(eventData) {
const out = { video: [], image: [] };
if (!eventData) return out;
const seen = new Set();
const maybeAdd = (url, mimeHint = '') => {
if (!url || typeof url !== 'string') return;
const trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) return;
if (seen.has(trimmed)) return;
const mime = String(mimeHint || '').toLowerCase();
const isVideo = mime.startsWith('video/') || isVideoUrl(trimmed);
if (isVideo) {
seen.add(trimmed);
out.video.push(trimmed);
return;
}
if (/\.(png|jpg|jpeg|gif|svg|webp)(?:$|[?#])/i.test(trimmed)) {
seen.add(trimmed);
out.image.push(trimmed);
}
};
const contentUrls = String(eventData.content || '').match(/\bhttps?:\/\/[^\s<]+/gi) || [];
contentUrls.forEach((url) => maybeAdd(url));
const tags = Array.isArray(eventData.tags) ? eventData.tags : [];
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) return;
const key = String(tag[0] || '').toLowerCase();
if (key === 'imeta') {
const raw = tag.slice(1).join(' ');
const urlMatch = raw.match(/\burl\s+https?:\/\/[^\s]+/i) || raw.match(/\bhttps?:\/\/[^\s]+/i);
const mimeMatch = raw.match(/\bm\s+([^\s]+)/i);
const url = urlMatch ? urlMatch[0].replace(/^url\s+/i, '').trim() : '';
const mime = mimeMatch ? mimeMatch[1].trim() : '';
maybeAdd(url, mime);
return;
}
if (['url', 'r', 'u', 'x'].includes(key)) {
maybeAdd(tag[1], tag[2]);
}
});
return out;
}
function appendTaggedMedia(contentEl, eventData) {
if (!contentEl) return;
const media = extractMediaUrlsFromEvent(eventData);
if (!media.video.length) return;
const existingVideoSrcs = new Set(
Array.from(contentEl.querySelectorAll('video')).map((el) => el.getAttribute('src')).filter(Boolean)
);
media.video.forEach((url) => {
if (existingVideoSrcs.has(url)) return;
const videoEl = document.createElement('video');
videoEl.className = 'post-video-embed';
videoEl.src = url;
videoEl.controls = true;
videoEl.playsInline = true;
videoEl.preload = 'metadata';
contentEl.appendChild(videoEl);
});
}
function applyVideoPlaybackPreferences(contentEl, autoplayVideo = false) {
if (!contentEl) return;
const videoEls = Array.from(contentEl.querySelectorAll('video.post-video-embed, .divPostContent video, video'));
videoEls.forEach((videoEl) => {
if (!(videoEl instanceof HTMLVideoElement)) return;
videoEl.controls = true;
videoEl.playsInline = true;
videoEl.setAttribute('playsinline', '');
videoEl.preload = 'metadata';
if (autoplayVideo) {
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.setAttribute('autoplay', '');
videoEl.setAttribute('muted', '');
const playPromise = videoEl.play?.();
if (playPromise?.catch) playPromise.catch(() => {});
} else {
videoEl.autoplay = false;
videoEl.muted = false;
videoEl.removeAttribute('autoplay');
videoEl.removeAttribute('muted');
}
});
}
// =============================================================================
// UNIFIED POST ITEM RENDERING
// =============================================================================
/**
* Render a single post/comment/reply item.
* This is the ONE function used for all post-like items: main feed posts,
* comments, and nested replies. They all use the same .divPostItem structure.
*
* @param {Object} eventData - The Nostr event: { id, pubkey, content, created_at, tags }
* @param {Object} options - Rendering options
* @param {string} options.currentPubkey - Current user's pubkey
* @param {boolean} options.showHeader - Show author header (default: true)
* @param {boolean} options.isCompact - Use compact footer styling (default: false)
* @returns {HTMLElement} The post item element (.divPostItem)
*/
export function renderPostItem(eventData, options = {}) {
const { currentPubkey, showHeader = true, isCompact = false, autoplayVideo = false } = options;
const postEl = document.createElement('div');
postEl.className = 'divPostItem';
postEl.dataset.postId = eventData.id;
postEl.dataset.postPubkey = eventData.pubkey;
// Author header
const authorHeader = renderAuthorHeader(eventData, { showHeader, currentPubkey });
postEl.appendChild(authorHeader);
// Content (use htmlFormatText for proper link/image handling)
const contentEl = document.createElement('div');
contentEl.className = 'divPostContent';
contentEl.innerHTML = htmlFormatText(eventData.content);
hydrateNostrEntities(contentEl);
appendTaggedMedia(contentEl, eventData);
applyVideoPlaybackPreferences(contentEl, autoplayVideo);
postEl.appendChild(contentEl);
// Footer row with interactions (L/C/R/Z) and time
const footerRow = renderFooterRow(eventData.id, eventData, {
currentPubkey,
isCompact
});
postEl.appendChild(footerRow);
return postEl;
}
/**
* Get the current comments visibility state
* @returns {boolean}
*/
export function getCommentsVisible() {
return commentsVisible;
}
/**
* Set comments visibility and notify all listeners
* @param {boolean} visible
*/
export function setCommentsVisible(visible) {
commentsVisible = visible;
localStorage.setItem('commentsVisible', visible.toString());
// Apply to all existing comment threads
document.querySelectorAll('.divCommentThread').forEach(thread => {
if (visible) {
thread.classList.remove('hidden');
} else {
thread.classList.add('hidden');
}
});
// Notify callbacks
commentsVisibilityCallbacks.forEach(cb => cb(visible));
}
/**
* Register a callback for comments visibility changes
* @param {Function} callback
*/
export function onCommentsVisibilityChange(callback) {
commentsVisibilityCallbacks.push(callback);
}
// =============================================================================
// ICON LETTER GENERATION
// =============================================================================
/**
* Map of interaction types to visible text labels
*/
const ICON_LABELS = {
like: { label: 'Like', activeLabel: 'Like' },
comment: { label: 'Comment', activeLabel: 'Comment' },
quote: { label: 'Quote', activeLabel: 'Quote' },
zap: { label: 'Zap', activeLabel: 'Zap' },
nutzap: { label: 'Nutzap', activeLabel: 'Nutzap' }
};
/**
* Generate HTML for interaction label text
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap', 'nutzap'
* @param {boolean} active - Whether the label is in active/selected state
* @returns {string} HTML string with label text
*/
export function getIconSvg(type, active = false) {
const config = ICON_LABELS[type] || ICON_LABELS.like;
const label = active ? config.activeLabel : config.label;
return `<span class="interaction-icon ${active ? 'active' : ''}">${label}</span>`;
}
// =============================================================================
// TIME FORMATTING
// =============================================================================
/**
* Format a timestamp as "time ago" (e.g., "5m", "2h")
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Formatted time string
*/
export function formatTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() / 1000) - timestamp);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
// Store timestamps for real-time updates
const timeAgoElements = new Map(); // element -> { timestamp, eventId }
// Track relay publish info per event ID from broadcastProgress events.
// Keyed by event ID → { count, urls } where urls is an array of relay URLs.
const relayInfoByEventId = new Map();
// Listen for broadcast progress events to capture final relay counts and URLs.
// The worker emits ndkBroadcastProgress with phase 'done' containing the
// total successful count and the list of relay URLs. We store it so post
// cards can show "21r - 1h" with a hover tooltip listing the relays.
if (typeof window !== 'undefined') {
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || d.phase !== 'done') return;
const eventId = d.eventId;
if (!eventId) return;
relayInfoByEventId.set(eventId, {
count: d.successful || 0,
urls: Array.isArray(d.relayUrls) ? d.relayUrls : [],
});
// Update any already-rendered time elements for this event.
timeAgoElements.forEach((info, element) => {
if (info.eventId === eventId && element.isConnected) {
updateTimeAgoElement(element, info);
}
});
});
}
/**
* Build the hover tooltip text for a relay list.
* Shows "Published to N relays:" followed by the URL list, truncated.
* @param {number} count - Number of successful relays
* @param {string[]} urls - Array of relay URLs
* @returns {string}
*/
function buildRelayTooltip(count, urls) {
if (!count || count === 0) return '';
const header = `Published to ${count} relay${count === 1 ? '' : 's'}:`;
if (!urls || urls.length === 0) return header;
// Show up to 50 relay URLs in the tooltip; beyond that, show a summary.
if (urls.length <= 50) {
return header + '\n' + urls.join('\n');
}
return header + '\n' + urls.slice(0, 50).join('\n') + `\n… and ${urls.length - 50} more`;
}
/**
* Update a time-ago element's text and tooltip from its stored info.
* @param {HTMLElement} element - The element to update
* @param {{timestamp: number, eventId: string}} info - Stored info
*/
function updateTimeAgoElement(element, info) {
const timeStr = formatTimeAgo(info.timestamp);
if (info.eventId && relayInfoByEventId.has(info.eventId)) {
const relayInfo = relayInfoByEventId.get(info.eventId);
element.textContent = `${relayInfo.count}r - ${timeStr}`;
element.title = buildRelayTooltip(relayInfo.count, relayInfo.urls);
} else {
element.textContent = timeStr;
}
}
/**
* Register an element for time updates
* @param {HTMLElement} element - The element to update
* @param {number} timestamp - Unix timestamp in seconds
* @param {string} [eventId] - Optional event ID for relay count display
*/
export function registerTimeAgo(element, timestamp, eventId) {
timeAgoElements.set(element, { timestamp, eventId });
updateTimeAgoElement(element, { timestamp, eventId });
}
/**
* Update all registered time displays
* Call this periodically (e.g., every 30 seconds)
*/
export function updateTimeAgos() {
timeAgoElements.forEach((info, element) => {
if (element.isConnected) {
updateTimeAgoElement(element, info);
} else {
// Clean up detached elements
timeAgoElements.delete(element);
}
});
}
// =============================================================================
// INTERACTION STATE MANAGEMENT
// =============================================================================
// Per-post interaction state: Map<postId, InteractionState>
const interactionState = new Map();
// Active subscriptions Map<subscriptionId, {postIds, unsubscribe}>
const activeSubscriptions = new Map();
// Pending animation instances (for cleanup)
const pendingAnimations = new Set();
// Module-level reference to publishEvent function (set during init)
let publishEventFn = null;
let walletPayInvoiceFn = null;
let walletSendNutzapFn = null;
let walletFetchMintListFn = null;
let walletGetMintsFn = null;
let walletGetBalanceFn = null;
let getRelayDataFn = null;
let getUserSettingsFn = null;
let patchUserSettingsFn = null;
// Optional UI intent hooks (set during init)
let onCommentIntentFn = null;
let onQuoteIntentFn = null;
let onMuteIntentFn = null;
/**
* Get or create interaction state for a post
* @param {string} postId - The post/event ID
* @returns {Object} Interaction state object
*/
export function getPostState(postId) {
if (!interactionState.has(postId)) {
interactionState.set(postId, {
likes: new Set(), // Set of pubkeys who liked
quotes: new Set(), // Set of quote event IDs
reposts: new Set(), // Set of pubkeys who reposted
comments: [], // Array of comment events
zaps: [], // Array of Lightning zap receipt events (kind 9735)
nutzaps: [], // Array of nutzap events (kind 9321)
zapTotal: 0, // Total sats zapped via Lightning
nutzapTotal: 0, // Total sats zapped via Cashu nutzaps
userZapTotal: 0, // Sats zapped by current user (Lightning receipts)
userLiked: false,
userQuoted: false,
userReposted: false,
commentCount: 0, // Total comment count
quoteCount: 0 // Total quote count
});
}
return interactionState.get(postId);
}
// =============================================================================
// FOOTER ROW RENDERING (Shared for posts and comments)
// =============================================================================
/**
* Render a complete footer row with interaction bar and time
* Used by both main posts and comment replies for consistent UI
* @param {string} eventId - The event ID
* @param {Object} eventData - The event data including pubkey, content, created_at, etc.
* @param {Object} options - Rendering options
* @param {string} options.currentPubkey - Current user's pubkey
* @param {boolean} options.isCompact - Whether to use compact styling (for comments)
* @returns {HTMLElement} The footer row element
*/
export function renderFooterRow(eventId, eventData, options = {}) {
const { currentPubkey, isCompact = false } = options;
const footerRow = document.createElement('div');
footerRow.className = isCompact ? 'divPostFooterRow compact' : 'divPostFooterRow';
footerRow.dataset.currentPubkey = currentPubkey || '';
// Get or create state for this event
const state = getPostState(eventId);
// Interaction bar container
const container = document.createElement('div');
container.className = 'divPostInteractions';
container.dataset.postId = eventId;
// Like button
const likeItem = createInteractionItem({
type: 'like',
count: state.likes.size,
active: state.userLiked,
title: 'Like',
onClick: () => handleLikeClick(eventId, eventData.pubkey, currentPubkey, likeItem)
});
// Comment button (shows count, always visible)
const commentItem = createInteractionItem({
type: 'comment',
count: state.commentCount,
active: false,
title: 'Comment',
onClick: () => handleCommentClick(eventId, commentItem, container)
});
// Quote button
const quoteItem = createInteractionItem({
type: 'quote',
count: state.quoteCount,
active: state.userQuoted,
title: 'Quote',
onClick: () => handleQuoteClick(eventId, eventData.pubkey, currentPubkey, quoteItem)
});
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
active: false,
title: 'Zap',
onClick: () => handleZapClick(eventId, eventData.pubkey, currentPubkey, zapItem)
});
updateZapCountDisplay(zapItem, state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
title: 'Nutzap',
onClick: () => handleNutzapClick(eventId, eventData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
const midControls = document.createElement('div');
midControls.className = 'divPostMidControls';
midControls.appendChild(likeItem);
midControls.appendChild(commentItem);
midControls.appendChild(quoteItem);
container.appendChild(zapItem);
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row.
// Pass eventId so registerTimeAgo can prepend the relay count
// (e.g., "21r - 1h") when a broadcastProgress 'done' event arrives.
const timeEl = document.createElement('span');
timeEl.className = 'divPostTime';
registerTimeAgo(timeEl, eventData.created_at, eventId);
container.appendChild(timeEl);
footerRow.appendChild(container);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(eventId, eventData.pubkey, zapItem, nutzapItem), 0);
return footerRow;
}
/**
* Render the interaction bar HTML for a post
* @param {string} postId - The post/event ID
* @param {Object} postData - The post data including pubkey, content, etc.
* @param {Object} options - Rendering options
* @param {string} options.currentPubkey - Current user's pubkey
* @returns {HTMLElement} The interaction bar element
*/
export function renderInteractionBar(postId, postData, options = {}) {
const { currentPubkey } = options;
const state = getPostState(postId);
const container = document.createElement('div');
container.className = 'divPostInteractions';
container.dataset.postId = postId;
// Like button
const likeItem = createInteractionItem({
type: 'like',
count: state.likes.size,
active: state.userLiked,
title: 'Like',
onClick: () => handleLikeClick(postId, postData.pubkey, currentPubkey, likeItem)
});
// Comment button
const commentItem = createInteractionItem({
type: 'comment',
count: state.commentCount,
active: false,
title: 'Comment',
onClick: () => handleCommentClick(postId, commentItem, container)
});
// Quote button
const quoteItem = createInteractionItem({
type: 'quote',
count: state.quoteCount,
active: state.userQuoted,
title: 'Quote',
onClick: () => handleQuoteClick(postId, postData.pubkey, currentPubkey, quoteItem)
});
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
active: false,
showCount: state.zapTotal > 0 || state.userZapTotal > 0,
title: 'Zap',
onClick: () => handleZapClick(postId, postData.pubkey, currentPubkey, zapItem)
});
updateZapCountDisplay(zapItem, state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
showCount: state.nutzapTotal > 0,
title: 'Nutzap',
onClick: () => handleNutzapClick(postId, postData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
container.appendChild(zapItem);
container.appendChild(nutzapItem);
container.appendChild(likeItem);
container.appendChild(commentItem);
container.appendChild(quoteItem);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(postId, postData.pubkey, zapItem, nutzapItem), 0);
return container;
}
/**
* Update the interaction bar UI for a post when state changes
* @param {string} postId - The post ID
* @param {Object} state - The interaction state
*/
export function updateInteractionBar(postId, state) {
const bar = document.querySelector(`.divPostInteractions[data-post-id="${postId}"]`);
if (!bar) return;
// Update like count and active state
const likeItem = bar.querySelector('.interaction-item.like');
if (likeItem) {
const countEl = likeItem.querySelector('.interaction-count');
if (countEl) countEl.textContent = formatCount(state.likes.size);
if (state.userLiked) {
likeItem.classList.add('active');
const iconContainer = likeItem.querySelector('.interaction-icon-container');
if (iconContainer) {
iconContainer.innerHTML = getIconSvg('like', true);
}
}
}
// Update quote count and active state
const quoteItem = bar.querySelector('.interaction-item.quote');
if (quoteItem) {
const countEl = quoteItem.querySelector('.interaction-count');
if (countEl) countEl.textContent = formatCount(state.quoteCount);
if (state.userQuoted) {
quoteItem.classList.add('active');
}
}
// Update comment count
const commentItem = bar.querySelector('.interaction-item.comment');
if (commentItem) {
const countEl = commentItem.querySelector('.interaction-count');
if (countEl) countEl.textContent = formatCount(state.commentCount);
}
// Update zap count
const zapItem = bar.querySelector('.interaction-item.zap');
if (zapItem) {
updateZapCountDisplay(zapItem, state);
}
// Update nutzap button count
const nutzapItem = bar.querySelector('.interaction-item.nutzap');
if (nutzapItem) {
updateNutzapButtonCount(nutzapItem, state);
}
}
/**
* Create a single interaction item element
* @param {Object} config - Configuration object
* @returns {HTMLElement} The interaction item element
*/
function createInteractionItem(config) {
const { type, count, active, showCount = true, title, onClick } = config;
const item = document.createElement('div');
item.className = `interaction-item ${type}${active ? ' active' : ''}`;
item.dataset.type = type;
if (title) {
item.title = title;
}
// Icon container with letter
const iconContainer = document.createElement('div');
iconContainer.className = 'interaction-icon-container';
iconContainer.innerHTML = getIconSvg(type, active);
item.appendChild(iconContainer);
// Count
if (showCount) {
const countEl = document.createElement('span');
countEl.className = 'interaction-count';
countEl.textContent = formatCount(count);
item.appendChild(countEl);
}
// Click handler
item.addEventListener('click', (e) => {
e.stopPropagation();
onClick();
});
return item;
}
/**
* Format a count number for display
* Returns empty string for zero counts to hide them
* @param {number} count
* @returns {string}
*/
function formatCount(count) {
if (count === 0) return '';
if (count < 1000) return count.toString();
if (count < 1000000) return (count / 1000).toFixed(1) + 'k';
return (count / 1000000).toFixed(1) + 'M';
}
function updateZapCountDisplay(itemEl, state = {}) {
const countEl = itemEl?.querySelector?.('.interaction-count');
if (!countEl) return;
const userZapTotal = Math.max(0, Number(state?.userZapTotal || 0));
const zapTotal = Math.max(0, Number(state?.zapTotal || 0));
if (userZapTotal > 0 && zapTotal > userZapTotal) {
countEl.innerHTML = `<span class="zap-user-amount">${formatCount(userZapTotal)}</span>/<span class="zap-total-amount">${formatCount(zapTotal)}</span>`;
return;
}
if (userZapTotal > 0) {
countEl.innerHTML = `<span class="zap-user-amount">${formatCount(userZapTotal)}</span>`;
return;
}
countEl.textContent = formatCount(zapTotal);
}
/**
* Update the count display on the Nutzap interaction button.
* Mirrors updateZapCountDisplay but for the nutzap button.
* @param {HTMLElement} itemEl - The nutzap interaction item element
* @param {Object} state - The interaction state
*/
function updateNutzapButtonCount(itemEl, state = {}) {
const countEl = itemEl?.querySelector?.('.interaction-count');
if (!countEl) return;
const nutzapTotal = Math.max(0, Number(state?.nutzapTotal || 0));
countEl.textContent = formatCount(nutzapTotal);
}
// =============================================================================
// ZAP CAPABILITY DIMMING
// =============================================================================
//
// Instead of separate capability badges, the Zap and Nutzap buttons themselves
// indicate capability via dimming:
// - Zap button dimmed when recipient has no lud16 Lightning address
// - Nutzap button dimmed when recipient has no shared mints for nutzaps
//
// Dimming is applied asynchronously after the bar is in the DOM so feed
// rendering is never blocked. Colors come exclusively from CSS variables.
/**
* Dim the Zap and/or Nutzap buttons based on the recipient's zap capabilities.
* Resolves capabilities async and applies a `.dimmed` class + tooltip to
* buttons whose rail is unavailable. Non-throwing.
*
* @param {string} postId - the event id (used for logging only)
* @param {string} pubkey - the post author's pubkey
* @param {HTMLElement} zapItem - the Zap (Lightning) interaction item
* @param {HTMLElement} nutzapItem - the Nutzap interaction item
*/
function applyZapCapabilityDimming(postId, pubkey, zapItem, nutzapItem) {
if (!pubkey) return;
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
return; // nothing to resolve with
}
if (!zapItem && !nutzapItem) return;
resolveZapCapabilities(pubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][zap-caps]'
}).then((caps) => {
// Elements may have been detached from the DOM by the time this resolves.
if (zapItem && zapItem.isConnected) {
if (!caps.canLightning) {
zapItem.classList.add('dimmed');
zapItem.title = 'No Lightning address';
} else {
zapItem.classList.remove('dimmed');
zapItem.title = caps.lud16 ? `Lightning: ${caps.lud16}` : 'Zap';
}
}
if (nutzapItem && nutzapItem.isConnected) {
if (!caps.canNutzap) {
nutzapItem.classList.add('dimmed');
nutzapItem.title = caps.hasMintList
? 'No shared mints for nutzap'
: 'No shared mints for nutzap';
} else {
nutzapItem.classList.remove('dimmed');
nutzapItem.title = 'Nutzap';
}
}
}).catch((error) => {
console.warn('[post-interactions][zap-caps] dimming failed', error?.message || error);
});
}
// =============================================================================
// ANIMATION HANDLING
// =============================================================================
/**
* Update icon appearance based on active state
* Simply updates the letter styling without complex animations
* @param {HTMLElement} iconContainer - The container element
* @param {string} type - Interaction type: 'like', 'comment', 'quote', 'zap'
* @param {boolean} active - Whether the icon is active
*/
function updateIconState(iconContainer, type, active) {
// Update the letter display
iconContainer.innerHTML = getIconSvg(type, active);
}
function isZapTimeoutError(error) {
const msg = String(error?.message || error || '').toLowerCase();
return msg.includes('timeout');
}
/**
* Classify a zap/melt payment error into a user-friendly message.
* Hides raw mint API errors while still being informative.
* @param {Error|*} error
* @returns {string}
*/
function friendlyZapErrorMessage(error) {
const raw = String(error?.message || error || '').trim();
const msg = raw.toLowerCase();
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
}
if (msg.includes('timeout')) {
return 'Payment timed out. The mint may be slow or unresponsive.';
}
if (msg.includes('insufficient') || msg.includes('balance')) {
return 'Insufficient Cashu balance for this payment.';
}
return raw || 'Zap failed.';
}
/**
* Fetch the current user's Cashu balance in sats.
* Returns null when the wallet function is unavailable or the fetch fails.
* @returns {Promise<number|null>}
*/
async function fetchWalletBalanceSats() {
if (typeof walletGetBalanceFn !== 'function') return null;
try {
const result = await walletGetBalanceFn();
const balance = Number(result?.balance ?? result?.sats ?? result);
if (Number.isFinite(balance) && balance >= 0) {
return Math.floor(balance);
}
} catch (error) {
console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error);
}
return null;
}
/**
* Refresh the wallet balance display after a zap.
* Re-fetches the balance and dispatches an `ndkWalletBalance` event so any
* footer/dialog balance displays can update. Non-throwing.
* @returns {Promise<void>}
*/
async function refreshWalletBalanceDisplay() {
const balanceSats = await fetchWalletBalanceSats();
if (balanceSats === null) return;
try {
window.dispatchEvent(new CustomEvent('ndkWalletBalance', {
detail: { balanceSats, source: 'post-interactions-zap' }
}));
} catch (error) {
console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error);
}
}
// =============================================================================
// INTERACTION HANDLERS
// =============================================================================
/**
* Handle like button click
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleLikeClick(postId, postPubkey, currentPubkey, itemEl) {
const state = getPostState(postId);
// Don't allow double-liking
if (state.userLiked) {
console.log('[post-interactions] Already liked');
return;
}
// Update icon to active state
const iconContainer = itemEl.querySelector('.interaction-icon-container');
updateIconState(iconContainer, 'like', true);
// Update UI immediately (optimistic)
state.userLiked = true;
state.likes.add(currentPubkey);
updateInteractionCount(itemEl, state.likes.size);
itemEl.classList.add('active');
// Publish the reaction
try {
if (!publishEventFn) {
throw new Error('publishEvent function not initialized');
}
const event = {
kind: NDK_KIND.REACTION,
content: '+',
tags: [
['e', postId],
['p', postPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
await publishEventFn(event);
console.log('[post-interactions] Like published successfully');
} catch (error) {
console.error('[post-interactions] Failed to publish like:', error);
// Revert UI on failure
state.userLiked = false;
state.likes.delete(currentPubkey);
updateInteractionCount(itemEl, state.likes.size);
itemEl.classList.remove('active');
updateIconState(iconContainer, 'like', false);
}
}
/**
* Handle comment button click - toggle comment thread visibility and input box
* @param {string} postId - Post ID
* @param {HTMLElement} itemEl - The interaction item element
* @param {HTMLElement} barEl - The interaction bar element
*/
function handleCommentClick(postId, itemEl, barEl) {
const postEl = barEl.closest('.divPostItem');
if (!postEl) return;
const postPubkey = postEl.dataset.postPubkey || '';
const currentPubkey = postEl.dataset.currentPubkey || '';
const isCompact = !!barEl.closest('.divPostFooterRow')?.classList.contains('compact');
if (typeof onCommentIntentFn !== 'function') return;
const consumed = onCommentIntentFn({ postId, postPubkey, currentPubkey, isCompact }) === true;
if (!consumed) return;
const iconContainer = itemEl.querySelector('.interaction-icon-container');
updateIconState(iconContainer, 'comment', true);
}
/**
* Handle quote button click
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleQuoteClick(postId, postPubkey, currentPubkey, itemEl) {
const iconContainer = itemEl.querySelector('.interaction-icon-container');
if (typeof onQuoteIntentFn === 'function') {
const consumed = onQuoteIntentFn({ postId, postPubkey, currentPubkey }) === true;
if (consumed) {
updateIconState(iconContainer, 'quote', true);
return;
}
}
const quoteText = window.prompt('Write your quote text (optional):', '');
if (quoteText === null) return;
updateIconState(iconContainer, 'quote', true);
const noteRef = toNostrNoteRef(postId);
const quoteBody = quoteText.trim();
const content = quoteBody.length > 0
? `${quoteBody}\nnostr:${noteRef}`
: `nostr:${noteRef}`;
try {
if (!publishEventFn) {
throw new Error('publishEvent function not initialized');
}
const result = await publishEventFn({
kind: NDK_KIND.TEXT_NOTE,
content,
tags: [
['e', postId, '', 'mention'],
['p', postPubkey],
['q', postId]
],
created_at: Math.floor(Date.now() / 1000)
});
const state = getPostState(postId);
if (result?.event?.id && !state.quotes.has(result.event.id)) {
state.quotes.add(result.event.id);
state.quoteCount = state.quotes.size;
}
state.userQuoted = true;
updateInteractionCount(itemEl, state.quoteCount);
itemEl.classList.add('active');
console.log('[post-interactions] Quote published successfully');
} catch (error) {
console.error('[post-interactions] Failed to publish quote:', error);
updateIconState(iconContainer, 'quote', false);
}
}
/**
* Handle repost button click
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleRepostClick(postId, postPubkey, currentPubkey, itemEl) {
const state = getPostState(postId);
if (state.userReposted) {
console.log('[post-interactions] Already reposted');
return;
}
// Update icon state
const iconContainer = itemEl.querySelector('.interaction-icon-container');
updateIconState(iconContainer, 'repost', true);
// Optimistic UI update
state.userReposted = true;
state.reposts.add(currentPubkey);
updateInteractionCount(itemEl, state.reposts.size);
itemEl.classList.add('active');
// Publish
try {
if (!publishEventFn) {
throw new Error('publishEvent function not initialized');
}
const event = {
kind: NDK_KIND.REPOST,
content: '',
tags: [
['e', postId],
['p', postPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
await publishEventFn(event);
console.log('[post-interactions] Repost published successfully');
} catch (error) {
console.error('[post-interactions] Failed to publish repost:', error);
state.userReposted = false;
state.reposts.delete(currentPubkey);
updateInteractionCount(itemEl, state.reposts.size);
itemEl.classList.remove('active');
}
}
/**
* Handle zap button click
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function getZapDefaultsFromSettings() {
try {
if (typeof getUserSettingsFn !== 'function') {
return { amountSats: 21, comment: '' };
}
const settings = await getUserSettingsFn();
const zapsNode = settings?.global_zaps || settings?.zaps || {};
const amountRaw = Number(zapsNode?.defaultAmountSats);
const amountSats = Number.isFinite(amountRaw) && amountRaw > 0
? Math.floor(amountRaw)
: 21;
const comment = String(zapsNode?.defaultComment || '').trim();
return { amountSats, comment };
} catch (_error) {
return { amountSats: 21, comment: '' };
}
}
async function saveZapDefaultsToSettings({ amountSats, comment }) {
if (typeof patchUserSettingsFn !== 'function') {
throw new Error('patchUserSettings unavailable');
}
const normalizedAmount = Number(amountSats);
const normalizedComment = String(comment || '').trim();
if (!Number.isFinite(normalizedAmount) || normalizedAmount <= 0) {
throw new Error('Invalid zap default amount');
}
await patchUserSettingsFn({
global_zaps: {
defaultAmountSats: Math.floor(normalizedAmount),
defaultComment: normalizedComment
}
});
}
/**
* Handle Zap (Lightning) button click.
*
* Sends a Lightning zap via Cashu melt: create a LN invoice from the
* recipient's lud16, then pay it via walletPayInvoiceFn. No rail selector —
* nutzaps have their own button and handler.
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
const iconContainer = itemEl.querySelector('.interaction-icon-container');
const countEl = itemEl.querySelector('.interaction-count');
console.log('[post-interactions][zap] handleZapClick:start', {
postId: String(postId || '').slice(0, 8) + '…',
recipient: String(postPubkey || '').slice(0, 8) + '…'
});
itemEl.dataset.busy = '1';
itemEl.classList.add('zap-pending');
updateIconState(iconContainer, 'zap', true);
let pendingAccent = false;
const zapPulseInterval = setInterval(() => {
pendingAccent = !pendingAccent;
itemEl.classList.toggle('zap-pending-accent', pendingAccent);
}, 500);
let paymentSucceeded = false;
try {
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('Lightning zap unavailable (walletPayInvoice not configured)');
}
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
}
}
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptZapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings,
balanceSats
});
if (!details) {
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
return;
}
console.log('[post-interactions][zap] handleZapClick:details', {
amountSats: details.amountSats,
hasComment: Boolean(String(details.comment || '').trim()),
shouldZap: Boolean(details.shouldZap)
});
const state = getPostState(postId);
if (!state.userLiked && currentPubkey) {
const interactionBar = itemEl.closest('.divPostInteractions');
const likeItem = interactionBar?.querySelector('.interaction-item.like');
if (likeItem) {
try {
await handleLikeClick(postId, postPubkey, currentPubkey, likeItem);
console.log('[post-interactions][zap] handleZapClick:auto-like:ok');
} catch (likeError) {
console.error('[post-interactions][zap] handleZapClick:auto-like:failed', likeError);
}
} else {
console.log('[post-interactions][zap] handleZapClick:auto-like:skipped-no-like-item');
}
}
const shouldZap = details.shouldZap !== false;
if (!shouldZap) {
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
if (countEl) {
countEl.textContent = '';
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, 150);
}
return;
}
// --- Pre-flight balance check (Phase 5) --------------------------------
// A final safety net right before sending, in case the user ignored the
// dialog warning or the balance changed between dialog and send.
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
// "Barely sufficient" warning: remaining balance would be less than
// 10% of the current balance.
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
return;
}
}
}
// --- Create the LN invoice and pay it via Cashu melt -------------------
const { invoice } = await prepareZapInvoiceForEvent({
eventId: postId,
recipientPubkey: postPubkey,
amountSats: details.amountSats,
comment: details.comment,
fetchProfile,
getRelayData: getRelayDataFn,
logPrefix: '[post-interactions][zap]'
});
if (countEl) countEl.textContent = 'pay…';
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
const paymentResult = await walletPayInvoiceFn(invoice);
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
if (countEl) countEl.textContent = 'sent';
const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
if (sentAmount > 0) {
state.userZapTotal = Math.max(0, Number(state.userZapTotal || 0)) + sentAmount;
updateZapCountDisplay(itemEl, state);
}
setTimeout(() => {
itemEl.classList.remove('zap-success');
updateZapCountDisplay(itemEl, state);
}, 1800);
// --- Post-zap balance refresh (Phase 5) --------------------------------
// Refresh the wallet balance display so the user sees their updated
// balance after a successful zap. Non-blocking — failures are logged
// but never surface to the user since the zap itself succeeded.
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][zap] handleZapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
// Surface a user-friendly error message. Avoids exposing raw mint API
// errors while still being informative.
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
itemEl.dataset.busy = '0';
if (!paymentSucceeded) {
itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success');
updateIconState(iconContainer, 'zap', false);
} else {
itemEl.classList.remove('active', 'zap-pending-accent');
updateIconState(iconContainer, 'zap', true);
}
console.log('[post-interactions][zap] handleZapClick:finish');
}
}
/**
* Handle Nutzap (Cashu ecash) button click.
*
* Checks the recipient's kind 10019 for shared mints, shows a nutzap-specific
* dialog (with the recipient's accepted mints), and sends ecash via
* walletSendNutzapFn. Mirrors handleZapClick's UI feedback (pulse animation,
* count update, balance check).
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The nutzap interaction item element
*/
async function handleNutzapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
const iconContainer = itemEl.querySelector('.interaction-icon-container');
const countEl = itemEl.querySelector('.interaction-count');
console.log('[post-interactions][nutzap] handleNutzapClick:start', {
postId: String(postId || '').slice(0, 8) + '…',
recipient: String(postPubkey || '').slice(0, 8) + '…'
});
itemEl.dataset.busy = '1';
itemEl.classList.add('zap-pending');
updateIconState(iconContainer, 'nutzap', true);
let pendingAccent = false;
const zapPulseInterval = setInterval(() => {
pendingAccent = !pendingAccent;
itemEl.classList.toggle('zap-pending-accent', pendingAccent);
}, 500);
let paymentSucceeded = false;
try {
if (typeof walletSendNutzapFn !== 'function') {
throw new Error('Nutzap unavailable (walletSendNutzap not configured)');
}
// --- Resolve zap capabilities to confirm nutzap is possible -------------
let zapCaps = null;
try {
zapCaps = await resolveZapCapabilities(postPubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][nutzap-caps]'
});
} catch (capsError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:caps-failed', capsError?.message || capsError);
}
const canNutzap = Boolean(zapCaps?.canNutzap);
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
const recipientMints = Array.isArray(zapCaps?.nutzapMints) ? zapCaps.nutzapMints : [];
console.log('[post-interactions][nutzap] handleNutzapClick:caps', {
recipient: String(postPubkey || '').slice(0, 8) + '…',
canNutzap,
sharedMints: sharedMints.length,
recipientMints: recipientMints.length
});
if (!canNutzap || sharedMints.length === 0) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
console.log('[post-interactions][nutzap] handleNutzapClick:no-shared-mints');
return;
}
// --- Resolve the nutzap spec (mints, p2pk, relays) for the send --------
let nutzapSpec = null;
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (nutzapDiscoveryError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:spec-failed', nutzapDiscoveryError?.message || nutzapDiscoveryError);
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
if (!nutzapSpec?.mints?.length) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-failed', balanceError?.message || balanceError);
}
}
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptNutzapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings,
balanceSats,
recipientMints: nutzapSpec.mints,
sharedMints
});
if (!details) {
console.log('[post-interactions][nutzap] handleNutzapClick:cancelled-by-user');
return;
}
console.log('[post-interactions][nutzap] handleNutzapClick:details', {
amountSats: details.amountSats,
hasComment: Boolean(String(details.comment || '').trim()),
shouldZap: Boolean(details.shouldZap)
});
const state = getPostState(postId);
if (!state.userLiked && currentPubkey) {
const interactionBar = itemEl.closest('.divPostInteractions');
const likeItem = interactionBar?.querySelector('.interaction-item.like');
if (likeItem) {
try {
await handleLikeClick(postId, postPubkey, currentPubkey, likeItem);
console.log('[post-interactions][nutzap] handleNutzapClick:auto-like:ok');
} catch (likeError) {
console.error('[post-interactions][nutzap] handleNutzapClick:auto-like:failed', likeError);
}
}
}
const shouldZap = details.shouldZap !== false;
if (!shouldZap) {
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
if (countEl) {
countEl.textContent = '';
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 150);
}
return;
}
// --- Pre-flight balance check (Phase 5) --------------------------------
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][nutzap] handleNutzapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][nutzap] handleNutzapClick:declined-low-balance');
return;
}
}
}
// --- Send the nutzap (Cashu ecash) -------------------------------------
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][nutzap] handleNutzapClick:sending-nutzap');
const nutzapResult = await walletSendNutzapFn({
amount: details.amountSats,
memo: details.comment,
targetPubkey: postPubkey,
eventId: postId,
recipientMints: nutzapSpec.mints,
recipientP2pk: nutzapSpec.p2pk,
recipientRelays: nutzapSpec.relays
});
console.log('[post-interactions][nutzap] handleNutzapClick:nutzap-ok', nutzapResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
if (countEl) countEl.textContent = 'sent';
const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
if (sentAmount > 0) {
state.nutzapTotal = Math.max(0, Number(state.nutzapTotal || 0)) + sentAmount;
updateNutzapButtonCount(itemEl, state);
}
setTimeout(() => {
itemEl.classList.remove('zap-success');
updateNutzapButtonCount(itemEl, state);
}, 1800);
// --- Post-nutzap balance refresh ---------------------------------------
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][nutzap] handleNutzapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
itemEl.dataset.busy = '0';
if (!paymentSucceeded) {
itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success');
updateIconState(iconContainer, 'nutzap', false);
} else {
itemEl.classList.remove('active', 'zap-pending-accent');
updateIconState(iconContainer, 'nutzap', true);
}
console.log('[post-interactions][nutzap] handleNutzapClick:finish');
}
}
/**
* Update the count display for an interaction item
* @param {HTMLElement} itemEl - The interaction item element
* @param {number} count - New count
*/
function updateInteractionCount(itemEl, count) {
const countEl = itemEl.querySelector('.interaction-count');
if (countEl) {
countEl.textContent = formatCount(count);
}
}
// =============================================================================
// COMMENT THREAD RENDERING
// =============================================================================
/**
* Render a comment thread container for a post
* @param {string} postId - The post ID
* @param {Object} options - Rendering options
* @returns {HTMLElement} The comment thread element
*/
export function renderCommentThread(postId, options = {}) {
const state = getPostState(postId);
const { currentPubkey, onPublishComment, showInputBox = true } = options;
const container = document.createElement('div');
container.className = commentsVisible ? 'divCommentThread' : 'divCommentThread hidden';
container.dataset.postId = postId;
// Comments list
const commentsList = document.createElement('div');
commentsList.className = 'divCommentsList';
if (state.comments.length > 0) {
state.comments
.sort((a, b) => a.created_at - b.created_at)
.forEach(comment => {
commentsList.appendChild(renderCommentItem(comment, currentPubkey));
});
}
container.appendChild(commentsList);
return container;
}
/**
* Render a single comment/reply item using the unified renderPostItem.
* Adds reply functionality on top of the standard post item.
* @param {Object} comment - The comment event
* @param {string} currentPubkey - Current user's pubkey
* @returns {HTMLElement} The comment element (a .divPostItem)
*/
function renderCommentItem(comment, currentPubkey) {
// Use the unified renderPostItem — same structure as main posts
const item = renderPostItem(comment, {
currentPubkey,
showHeader: true,
isCompact: true
});
return item;
}
// =============================================================================
// SUBSCRIPTION MANAGEMENT
// =============================================================================
/**
* Subscribe to interactions for a set of posts
* @param {string[]} postIds - Array of post/event IDs
* @param {Object} options - Subscription options
* @param {Function} options.onUpdate - Callback when interaction data updates
* @param {Function} options.subscribeFn - The subscribe function from init-ndk.mjs
* @returns {string} Subscription ID
*/
export function subscribeToInteractions(postIds, options = {}) {
const { onUpdate, subscribeFn } = options;
if (!subscribeFn) {
throw new Error('subscribeFn is required');
}
if (!postIds || postIds.length === 0) {
console.warn('[post-interactions] No post IDs provided for subscription');
return null;
}
const subId = `interactions-${Date.now()}`;
// Create filters for all interaction kinds referencing these posts
const filters = {
kinds: [NDK_KIND.REACTION, NDK_KIND.REPOST, NDK_KIND.TEXT_NOTE, NDK_KIND.NUTZAP, NDK_KIND.ZAP_RECEIPT],
'#e': postIds
};
// Subscribe
try {
subscribeFn(filters, { closeOnEose: false });
// Listen for events
const handleEvent = (e) => {
const evt = e.detail;
if (!evt) return;
// Check if this event references any of our tracked posts
const eTags = evt.tags?.filter(t => t[0] === 'e').map(t => t[1]) || [];
const relevantPostId = postIds.find(id => eTags.includes(id));
if (!relevantPostId) return;
// Process the interaction
const updated = processInteractionEvent(relevantPostId, evt, options.currentPubkey);
if (updated && onUpdate) {
onUpdate(relevantPostId, interactionState.get(relevantPostId), evt);
}
};
window.addEventListener('ndkEvent', handleEvent);
// Store subscription info
activeSubscriptions.set(subId, {
postIds,
unsubscribe: () => {
window.removeEventListener('ndkEvent', handleEvent);
}
});
console.log(`[post-interactions] Subscribed to interactions for ${postIds.length} posts`);
return subId;
} catch (error) {
console.error('[post-interactions] Subscription failed:', error);
return null;
}
}
/**
* Unsubscribe from interactions
* @param {string} subId - Subscription ID
*/
export function unsubscribeFromInteractions(subId) {
const sub = activeSubscriptions.get(subId);
if (sub) {
sub.unsubscribe();
activeSubscriptions.delete(subId);
console.log(`[post-interactions] Unsubscribed: ${subId}`);
}
}
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
function isQuoteEventForPost(event, postId) {
if (event?.kind !== NDK_KIND.TEXT_NOTE) return false;
const qTags = event.tags?.filter(t => t[0] === 'q').map(t => t[1]) || [];
if (qTags.includes(postId)) return true;
const eTags = event.tags?.filter(t => t[0] === 'e' && t[1] === postId) || [];
if (eTags.some(t => t[3] === 'mention')) return true;
const noteRef = toNostrNoteRef(postId);
return typeof event.content === 'string' && event.content.includes(`nostr:${noteRef}`);
}
export function isCommentEvent(event, postId) {
if (event?.kind !== NDK_KIND.TEXT_NOTE) return false;
const eTags = event.tags?.filter(t => t[0] === 'e').map(t => t[1]) || [];
if (!eTags.includes(postId)) return false;
return !isQuoteEventForPost(event, postId);
}
/**
* Process an incoming interaction event
* @param {string} postId - The post ID this event references
* @param {Object} event - The Nostr event
* @param {string} currentPubkey - Current user's pubkey
* @returns {boolean} Whether state was updated
*/
function processInteractionEvent(postId, event, currentPubkey) {
const state = getPostState(postId);
let updated = false;
switch (event.kind) {
case NDK_KIND.REACTION:
// Like/reaction
if (!state.likes.has(event.pubkey)) {
state.likes.add(event.pubkey);
if (event.pubkey === currentPubkey) {
state.userLiked = true;
}
updated = true;
}
break;
case NDK_KIND.REPOST:
// Repost
if (!state.reposts.has(event.pubkey)) {
state.reposts.add(event.pubkey);
if (event.pubkey === currentPubkey) {
state.userReposted = true;
}
updated = true;
}
break;
case NDK_KIND.TEXT_NOTE:
if (isQuoteEventForPost(event, postId)) {
if (!state.quotes.has(event.id)) {
state.quotes.add(event.id);
state.quoteCount = state.quotes.size;
if (event.pubkey === currentPubkey) {
state.userQuoted = true;
}
updated = true;
}
} else if (isCommentEvent(event, postId)) {
// Comment/reply
if (!state.comments.some(c => c.id === event.id)) {
state.comments.push({
id: event.id,
pubkey: event.pubkey,
content: event.content,
created_at: event.created_at,
tags: event.tags
});
state.commentCount = state.comments.length;
updated = true;
}
}
break;
case NDK_KIND.ZAP_RECEIPT:
// Zap receipt (dedupe by receipt event id; same event can arrive from multiple relays)
if (!state.zaps.some(z => z.id === event.id)) {
state.zaps.push({
id: event.id,
pubkey: event.pubkey,
senderPubkey: extractZapSenderPubkey(event),
amount: extractZapAmount(event),
comment: extractZapComment(event),
created_at: event.created_at
});
state.zapTotal = state.zaps.reduce((sum, z) => sum + z.amount, 0);
state.userZapTotal = state.zaps
.filter((z) => z.senderPubkey && z.senderPubkey === currentPubkey)
.reduce((sum, z) => sum + z.amount, 0);
updated = true;
}
break;
case NDK_KIND.NUTZAP:
// Nutzap event (kind 9321)
if (!state.nutzaps.some((z) => z.id === event.id)) {
state.nutzaps.push({
id: event.id,
pubkey: event.pubkey,
amount: extractNutzapAmount(event),
comment: String(event?.content || '').trim(),
created_at: event.created_at
});
state.nutzapTotal = state.nutzaps.reduce((sum, z) => sum + Math.max(0, Number(z.amount || 0)), 0);
updated = true;
}
break;
}
return updated;
}
/**
* Add a comment to the DOM for a post (called when new comments arrive)
* @param {string} postId - The post ID
* @param {Object} comment - The comment event
* @param {string} currentPubkey - Current user's pubkey
*/
export function addCommentToPost(postId, comment, currentPubkey) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return;
// Check if comment thread already exists
let threadEl = postEl.querySelector('.divCommentThread');
if (!threadEl) {
// Create new comment thread
threadEl = renderCommentThread(postId, { currentPubkey });
postEl.appendChild(threadEl);
} else {
// Add comment to existing thread
const commentsList = threadEl.querySelector('.divCommentsList');
if (commentsList) {
// Remove "no comments" message if present
const noCommentsMsg = commentsList.querySelector('.no-comments');
if (noCommentsMsg) {
noCommentsMsg.remove();
}
// Add new comment only if it's not already rendered
const existingComment = commentsList.querySelector(`.divPostItem[data-post-id="${comment.id}"]`);
if (!existingComment) {
const commentEl = renderCommentItem(comment, currentPubkey);
commentsList.appendChild(commentEl);
}
}
}
}
/**
* Extract zap amount from a zap receipt event
* @param {Object} event - The zap receipt event
* @returns {number} Amount in sats
*/
function extractZapAmount(event) {
return extractZapAmountFromReceipt(event);
}
/**
* Extract zap comment from a zap receipt event
* @param {Object} event - The zap receipt event
* @returns {string} Comment text
*/
function extractZapComment(event) {
return extractZapCommentFromReceipt(event);
}
function extractZapSenderPubkey(event) {
const description = event?.tags?.find((t) => t[0] === 'description')?.[1];
if (!description) return '';
try {
const descObj = JSON.parse(description);
return String(descObj?.pubkey || '').trim();
} catch (_e) {
return '';
}
}
function extractNutzapAmount(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
let totalFromProofs = 0;
for (const tag of tags) {
if (!Array.isArray(tag) || tag[0] !== 'proof') continue;
const serializedProof = String(tag[1] || '').trim();
if (!serializedProof) continue;
try {
const proofObj = JSON.parse(serializedProof);
const amount = Number(proofObj?.amount || 0);
if (Number.isFinite(amount) && amount > 0) {
totalFromProofs += amount;
}
} catch (_error) {
// Ignore malformed proof tags
}
}
if (totalFromProofs > 0) {
return Math.floor(totalFromProofs);
}
const amountTag = tags.find((t) => Array.isArray(t) && t[0] === 'amount');
const fallbackAmount = Number(amountTag?.[1] || 0);
if (Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
return Math.floor(fallbackAmount);
}
return 0;
}
// =============================================================================
// PUBLISH FUNCTIONS
// =============================================================================
/**
* Publish a like/reaction to a post
* @param {string} postId - The post ID to like
* @param {string} postPubkey - The post author's pubkey
* @param {Object} options - Options including publishEvent function
* @returns {Promise} Result of publish
*/
export async function publishLike(postId, postPubkey, options = {}) {
const { publishEvent } = options;
if (!publishEvent) {
throw new Error('publishEvent function is required');
}
const event = {
kind: NDK_KIND.REACTION,
content: '+', // Standard like reaction
tags: [
['e', postId],
['p', postPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
return await publishEvent(event);
}
/**
* Publish a repost of a post
* @param {string} postId - The post ID to repost
* @param {string} postPubkey - The post author's pubkey
* @param {Object} options - Options including publishEvent function
* @returns {Promise} Result of publish
*/
export async function publishRepost(postId, postPubkey, options = {}) {
const { publishEvent, originalEvent } = options;
if (!publishEvent) {
throw new Error('publishEvent function is required');
}
// For a proper repost, we should include the original event as content
const event = {
kind: NDK_KIND.REPOST,
content: originalEvent ? JSON.stringify(originalEvent) : '',
tags: [
['e', postId],
['p', postPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
return await publishEvent(event);
}
/**
* Publish a comment/reply to a post
* @param {string} postId - The post ID to reply to
* @param {string} postPubkey - The post author's pubkey
* @param {string} content - Comment content
* @param {Object} options - Options including publishEvent function
* @returns {Promise} Result of publish
*/
export async function publishComment(postId, postPubkey, content, options = {}) {
const { publishEvent } = options;
if (!publishEvent) {
throw new Error('publishEvent function is required');
}
const event = {
kind: NDK_KIND.TEXT_NOTE,
content: content,
tags: [
['e', postId, '', 'root'],
['p', postPubkey]
],
created_at: Math.floor(Date.now() / 1000)
};
return await publishEvent(event);
}
// =============================================================================
// FETCH EXISTING INTERACTIONS
// =============================================================================
/**
* Fetch existing interactions for a set of posts
* This should be called when posts are first loaded to get historical data
* @param {string[]} postIds - Array of post/event IDs
* @param {Object} options - Options
* @param {Function} options.ndkFetchEvents - Function from init-ndk.mjs
* @param {string} options.currentPubkey - Current user's pubkey
* @param {Function} options.onUpdate - Callback for each post's data
*/
export async function fetchExistingInteractions(postIds, options = {}) {
const { ndkFetchEvents, queryCache, currentPubkey, onUpdate } = options;
const relayFetchFn = typeof ndkFetchEvents === 'function' ? ndkFetchEvents : ndkFetchEventsFn;
const cacheFetchFn = typeof queryCache === 'function' ? queryCache : queryCacheFn;
if (postIds.length === 0 || (!relayFetchFn && !cacheFetchFn)) {
return;
}
console.log('[post-interactions] Fetching existing interactions for', postIds.length, 'posts');
const filters = {
kinds: [NDK_KIND.REACTION, NDK_KIND.REPOST, NDK_KIND.TEXT_NOTE, NDK_KIND.NUTZAP, NDK_KIND.ZAP_RECEIPT],
'#e': postIds
};
const applyInteractionEvents = (events, sourceLabel) => {
const allEvents = Array.isArray(events) ? events : [];
console.log('[post-interactions] Fetched', allEvents.length, `existing interactions from ${sourceLabel}`);
// Process all events first (state only), then batch-notify onUpdate once per post
const updatedPosts = new Map(); // postId -> last event that triggered an update
allEvents.forEach(event => {
const eTags = event.tags?.filter(t => t[0] === 'e').map(t => t[1]) || [];
const relevantPostId = postIds.find(id => eTags.includes(id));
if (relevantPostId) {
const updated = processInteractionEvent(relevantPostId, event, currentPubkey);
if (updated) {
updatedPosts.set(relevantPostId, event);
}
}
});
// Now notify once per post with final state — avoids 387 individual DOM updates
if (onUpdate) {
updatedPosts.forEach((lastEvent, postId) => {
onUpdate(postId, interactionState.get(postId), lastEvent);
});
}
return allEvents.length > 0;
};
let hadCacheEvents = false;
if (cacheFetchFn) {
try {
const cachedEvents = await cacheFetchFn(filters);
hadCacheEvents = applyInteractionEvents(cachedEvents, 'cache');
} catch (cacheError) {
console.warn('[post-interactions] Failed to query interaction cache:', cacheError?.message || cacheError);
}
}
if (!relayFetchFn) {
return;
}
if (hadCacheEvents) {
void relayFetchFn(filters).then((relayEvents) => {
applyInteractionEvents(relayEvents, 'relays');
}).catch((error) => {
console.warn('[post-interactions] Relay hydration failed for interactions:', error?.message || error);
});
return;
}
try {
const relayEvents = await relayFetchFn(filters);
applyInteractionEvents(relayEvents, 'relays');
} catch (error) {
console.error('[post-interactions] Failed to fetch existing interactions:', error);
}
}
// =============================================================================
// INITIALIZATION
// =============================================================================
/**
* Initialize the interactions module with NDK functions
* @param {Object} ndkFunctions - Object containing NDK functions
* @param {Function} ndkFunctions.subscribe - subscribe function from init-ndk.mjs
* @param {Function} ndkFunctions.publishEvent - publishEvent function from init-ndk.mjs
* @param {Function} ndkFunctions.getPubkey - getPubkey function from init-ndk.mjs
* @param {Function} ndkFunctions.ndkFetchEvents - ndkFetchEvents function from init-ndk.mjs
*/
export function initInteractions(ndkFunctions = {}) {
const {
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent,
onQuoteIntent,
onMuteIntent
} = ndkFunctions;
// Store publishEvent function for use in click handlers
publishEventFn = publishEvent;
// Store fetch functions for embeds/interactions and other hydration paths
ndkFetchEventsFn = typeof ndkFetchEvents === 'function' ? ndkFetchEvents : null;
queryCacheFn = typeof queryCache === 'function' ? queryCache : null;
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;
profileCacheApi.configure({
fetchCachedProfile,
ndkFetchEvents,
storeProfile
});
// Store optional intent handlers used by host page composer integration
onCommentIntentFn = typeof onCommentIntent === 'function' ? onCommentIntent : null;
onQuoteIntentFn = typeof onQuoteIntent === 'function' ? onQuoteIntent : null;
onMuteIntentFn = typeof onMuteIntent === 'function' ? onMuteIntent : null;
// Start time-ago updater
setInterval(updateTimeAgos, 30000); // Update every 30 seconds
console.log('[post-interactions] Module initialized');
return {
subscribeToInteractions: (postIds, options) => subscribeToInteractions(postIds, { ...options, subscribeFn: subscribe }),
unsubscribeFromInteractions,
renderInteractionBar,
renderFooterRow,
renderPostItem,
updateInteractionBar,
renderCommentThread,
addCommentToPost,
getPostState,
getCommentsVisible,
setCommentsVisible,
onCommentsVisibilityChange,
fetchExistingInteractions: (postIds, options) => fetchExistingInteractions(postIds, { ...options, ndkFetchEvents, queryCache }),
publishLike: (postId, postPubkey) => publishLike(postId, postPubkey, { publishEvent }),
publishRepost: (postId, postPubkey, originalEvent) => publishRepost(postId, postPubkey, { publishEvent, originalEvent }),
publishComment: (postId, postPubkey, content) => publishComment(postId, postPubkey, content, { publishEvent }),
renderAuthorHeader,
formatTimeAgo,
registerTimeAgo,
updateTimeAgos,
getIconSvg,
prewarmProfileCache,
seedProfileCache,
isCommentEvent
};
}
export default initInteractions;