2871 lines
105 KiB
JavaScript
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;
|