Files
bitvid/js/blog/zapthreadsEmbed.js
T

1293 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { escapeHTML } from '../utils/domUtils.js';
/**
* Responsibilities mirrored from the legacy DS component that ships inside
* blog.html:
*
* - Normalise anchors coming from HTTP URLs, note/nevent IDs, or naddr
* references.
* - Resolve relay inputs (with defaults) and build the correct Nostr filter for
* whichever anchor type we are showing.
* - Hydrate the thread from cache, refresh it from the network, and keep the
* subscription lifecycle tidy so repeated mounts don’t leak listeners.
* - Expose cache clearing, watermark/title rendering, and anchor version updates
* to the host document while dispatching mount/unmount events for observers.
*/
function createSvg(pathD, { viewBox, className } = {}) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
if (viewBox) {
svg.setAttribute('viewBox', viewBox);
}
if (className) {
svg.setAttribute('class', className);
}
if (pathD) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathD);
svg.appendChild(path);
}
return svg;
}
export function createZapthreadsCommentInfoPicture() {
const container = document.createElement('div');
container.className = 'ztr-comment-info-picture';
const image = document.createElement('img');
container.appendChild(image);
return container;
}
export function createZapthreadsReplyForm() {
const form = document.createElement('div');
form.className = 'ztr-reply-form';
const textarea = document.createElement('textarea');
form.appendChild(textarea);
const controls = document.createElement('div');
controls.className = 'ztr-reply-controls';
form.appendChild(controls);
return form;
}
export function createZapthreadsPublishingDisabledMessage() {
const span = document.createElement('span');
span.textContent = 'Publishing is disabled';
return span;
}
export function createZapthreadsReplyErrorMessage() {
const span = document.createElement('span');
span.className = 'ztr-reply-error';
span.textContent = 'Error: ';
return span;
}
export function createZapthreadsSpinner() {
const svg = createSvg(null, {
viewBox: '0 0 50 50',
className: 'ztr-spinner',
});
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('class', 'path');
circle.setAttribute('cx', '25');
circle.setAttribute('cy', '25');
circle.setAttribute('r', '20');
circle.setAttribute('fill', 'none');
circle.setAttribute('stroke-width', '5');
svg.appendChild(circle);
return svg;
}
export function createZapthreadsReplyAsButton() {
const button = document.createElement('button');
button.className = 'ztr-reply-button';
button.textContent = 'Reply as ';
return button;
}
export function createZapthreadsReplyAnonymouslyButton() {
const button = document.createElement('button');
button.className = 'ztr-reply-button';
button.textContent = 'Reply anonymously';
return button;
}
export function createZapthreadsReplyLoginButton() {
const button = document.createElement('button');
button.className = 'ztr-reply-login-button';
button.textContent = 'Log in';
return button;
}
export function createZapthreadsLikeAction() {
const item = document.createElement('li');
item.className = 'ztr-comment-action-like';
const span = document.createElement('span');
span.textContent = ' likes';
item.appendChild(span);
return item;
}
export function createZapthreadsZapAction() {
const item = document.createElement('li');
item.className = 'ztr-comment-action-zap';
const span = document.createElement('span');
span.textContent = ' sats';
item.appendChild(span);
return item;
}
export function createZapthreadsNewCommentContainer() {
const wrapper = document.createElement('div');
wrapper.className = 'ztr-comment-new';
const body = document.createElement('div');
body.className = 'ztr-comment-body';
wrapper.appendChild(body);
const actions = document.createElement('ul');
actions.className = 'ztr-comment-actions';
body.appendChild(actions);
return wrapper;
}
export function createZapthreadsThreadContainer() {
const div = document.createElement('div');
div.className = 'ztr-thread';
return div;
}
export function createZapthreadsReplyActionItem() {
const item = document.createElement('li');
item.className = 'ztr-comment-action-reply';
const span = document.createElement('span');
item.appendChild(span);
return item;
}
export function createZapthreadsCommentContainer() {
const root = document.createElement('div');
root.className = 'ztr-comment';
const body = document.createElement('div');
body.className = 'ztr-comment-body';
root.appendChild(body);
const infoWrapper = document.createElement('div');
infoWrapper.className = 'ztr-comment-info-wrapper';
body.appendChild(infoWrapper);
const info = document.createElement('div');
info.className = 'ztr-comment-info';
infoWrapper.appendChild(info);
info.appendChild(createZapthreadsCommentInfoPicture());
const infoItems = document.createElement('ul');
infoItems.className = 'ztr-comment-info-items';
info.appendChild(infoItems);
const authorItem = document.createElement('li');
authorItem.className = 'ztr-comment-info-author';
infoItems.appendChild(authorItem);
const authorLink = document.createElement('a');
authorLink.target = '_blank';
authorItem.appendChild(authorLink);
const nowrap = document.createElement('span');
nowrap.className = 'ztr-nowrap';
const strong = document.createElement('strong');
strong.textContent = ' <!>ed';
nowrap.appendChild(strong);
nowrap.appendChild(document.createTextNode(' '));
authorItem.appendChild(nowrap);
const dotsItem = document.createElement('li');
infoItems.appendChild(dotsItem);
const dotsLink = document.createElement('a');
dotsLink.className = 'ztr-comment-info-dots';
dotsItem.appendChild(dotsLink);
const metaList = document.createElement('ul');
metaList.className = 'ztr-comment-info-items';
infoWrapper.appendChild(metaList);
const truncatedText = document.createElement('div');
truncatedText.className = 'ztr-comment-text';
body.appendChild(truncatedText);
const fullText = document.createElement('div');
fullText.className = 'ztr-comment-text';
body.appendChild(fullText);
const actions = document.createElement('ul');
actions.className = 'ztr-comment-actions';
body.appendChild(actions);
return root;
}
export function createZapthreadsBulletSeparator() {
const item = document.createElement('li');
item.textContent = '●';
return item;
}
export function createZapthreadsRepliesLabel() {
const item = document.createElement('li');
item.textContent = ' repl';
return item;
}
export function createZapthreadsActionCountItem() {
const item = document.createElement('li');
const span = document.createElement('span');
item.appendChild(span);
return item;
}
export function createZapthreadsInfoPane() {
const container = document.createElement('div');
container.className = 'ztr-info-pane';
const link = document.createElement('a');
link.target = '_blank';
container.appendChild(link);
const small = document.createElement('small');
small.textContent = 'Event data';
link.appendChild(small);
return container;
}
export function createZapthreadsCrossPostWarning() {
const paragraph = document.createElement('p');
paragraph.className = 'warning';
const span = document.createElement('span');
span.textContent = 'This is a <!> that referenced this article in ';
paragraph.appendChild(span);
const link = document.createElement('a');
link.textContent = 'another thread';
span.appendChild(link);
return paragraph;
}
export function createZapthreadsContentMaybeChangedWarning() {
const paragraph = document.createElement('p');
paragraph.className = 'warning';
const span = document.createElement('span');
span.textContent = 'Article contents may have changed since this <!> was made';
paragraph.appendChild(span);
return paragraph;
}
export function createZapthreadsContentChangedWarning() {
const paragraph = document.createElement('p');
paragraph.className = 'warning';
const span = document.createElement('span');
span.textContent = 'Article contents changed since this <!> was made';
paragraph.appendChild(span);
return paragraph;
}
export function createZapthreadsCommentExpand() {
const container = document.createElement('div');
container.className = 'ztr-comment-expand';
const link = document.createElement('a');
container.appendChild(link);
const span = document.createElement('span');
span.textContent = 'Show full comment';
container.appendChild(span);
return container;
}
export function createZapthreadsRepliesContainer() {
const container = document.createElement('div');
container.className = 'ztr-comment-replies';
return container;
}
export function createZapthreadsMessageIcon() {
return createSvg(
'M 12.6030 50.4905 C 13.3758 50.4905 13.9307 50.1140 14.8621 49.2421 L 20.6483 43.8720 C 19.5188 42.9803 18.6073 41.5733 18.6073 38.3433 L 18.6073 25.2052 C 18.6073 19.1217 22.3129 15.5152 28.3766 15.5152 L 42.2479 15.5152 L 42.2281 14.7622 C 41.9306 10.6999 39.2557 8.0643 34.7177 8.0643 L 7.5301 8.0643 C 2.9922 8.0643 0 10.7791 0 15.4954 L 0 34.9548 C 0 39.6710 2.9922 42.7028 7.5301 42.7028 L 10.8195 42.7028 L 10.8195 48.4693 C 10.8195 49.6979 11.4735 50.4905 12.6030 50.4905 Z M 44.6058 53.2450 C 45.7353 53.2450 46.3895 52.4325 46.3895 51.2237 L 46.3895 45.4374 L 48.4702 45.4374 C 53.0078 45.4374 56 42.4056 56 37.7092 L 56 25.6610 C 56 20.9250 53.0078 18.2300 48.4702 18.2300 L 28.8522 18.2300 C 24.1161 18.2300 21.3221 20.9250 21.3221 25.6610 L 21.3221 37.7092 C 21.3221 42.4056 24.1161 45.4374 28.8522 45.4374 L 35.1735 45.4374 L 42.3470 51.9767 C 43.2784 52.8487 43.8331 53.2450 44.6058 53.2450 Z',
{ viewBox: '0 -6 60 60' }
);
}
export function createZapthreadsLightningIcon() {
return createSvg(
'M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z',
{ viewBox: '-120 -80 528 588' }
);
}
export function createZapthreadsHeartIcon() {
return createSvg(
'M60.732 29.7C41.107 29.7 22 39.7 22 67.41c0 27.29 45.274 67.29 74 94.89 28.744-27.6 74-67.6 74-94.89 0-27.71-19.092-37.71-38.695-37.71C116 29.7 104.325 41.575 96 54.066 87.638 41.516 76 29.7 60.732 29.7z',
{ viewBox: '0 -16 180 180' }
);
}
export function createZapthreadsQuoteIcon() {
return createSvg(
'M168 80c-13.3 0-24 10.7-24 24V408c0 8.4-1.4 16.5-4.1 24H440c13.3 0 24-10.7 24-24V104c0-13.3-10.7-24-24-24H168zM72 480c-39.8 0-72-32.2-72-72V112C0 98.7 10.7 88 24 88s24 10.7 24 24V408c0 13.3 10.7 24 24 24s24-10.7 24-24V104c0-39.8 32.2-72 72-72H440c39.8 0 72 32.2 72 72V408c0 39.8-32.2 72-72 72H72zM176 136c0-13.3 10.7-24 24-24h96c13.3 0 24 10.7 24 24v80c0 13.3-10.7 24-24 24H200c-13.3 0-24-10.7-24-24V136zm200-24h32c13.3 0 24 10.7 24 24s-10.7 24-24 24H376c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 80h32c13.3 0 24 10.7 24 24s-10.7 24-24 24H376c-13.3 0-24-10.7-24-24s10.7-24 24-24zM200 272H408c13.3 0 24 10.7 24 24s-10.7 24-24 24H200c-13.3 0-24-10.7-24-24s10.7-24 24-24zm0 80H408c13.3 0 24 10.7 24 24s-10.7 24-24 24H200c-13.3 0-24-10.7-24-24s10.7-24 24-24z',
{ viewBox: '0 0 576 512' }
);
}
export function createZapthreadsEllipsisIcon() {
return createSvg(
'M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z',
{ viewBox: '0 -200 560 640' }
);
}
export function createZapthreadsPlayIcon() {
return createSvg(
'M246.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-128-128c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 256c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l128-128z',
{ viewBox: '0 -50 256 512' }
);
}
export function createZapthreadsReplyIcon() {
return createSvg(
'M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z',
{ viewBox: '0 -50 320 512' }
);
}
export function createZapthreadsAlertIcon() {
const svg = createSvg(
'M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z',
{ viewBox: '0 0 512 512' }
);
return svg;
}
const ZAPTHREADS_MOUNT_CLASS = 'blog-ztr-container';
function removeExistingZapthreadsMount(blogRoot) {
const existingRoot = blogRoot.querySelector('#ztr-root');
if (!existingRoot) {
return;
}
const mountContainer = existingRoot.closest(`.${ZAPTHREADS_MOUNT_CLASS}`);
if (mountContainer && mountContainer.parentNode) {
mountContainer.parentNode.removeChild(mountContainer);
return;
}
existingRoot.remove();
}
function createZapthreadsMountContainer(blogRoot) {
const container = document.createElement('div');
container.className = ZAPTHREADS_MOUNT_CLASS;
blogRoot.appendChild(container);
return container;
}
export function createZapthreadsRoot() {
const root = document.createElement('div');
root.id = 'ztr-root';
const watermark = document.createElement('div');
watermark.className = 'blog-ztr-watermark';
root.appendChild(watermark);
return root;
}
export function createZapthreadsContent() {
const content = document.createElement('div');
content.id = 'ztr-content';
return content;
}
export function createZapthreadsErrorHeading() {
const heading = document.createElement('h1');
heading.textContent = 'Error!';
return heading;
}
export function createZapthreadsErrorDetails() {
const container = document.createElement('div');
container.className = 'ztr-comment-text';
const pre = document.createElement('pre');
container.appendChild(pre);
const message = document.createElement('p');
message.textContent =
'Only properly formed NIP-19 naddr, note and nevent encoded entities and URLs are supported.';
container.appendChild(message);
return container;
}
export function createZapthreadsTitleHeading() {
const heading = document.createElement('h2');
heading.id = 'ztr-title';
return heading;
}
export const DEFAULT_THREAD_TITLE = 'Thread';
export function renderThreadTitle(titleEl, title = DEFAULT_THREAD_TITLE) {
if (!titleEl) return;
titleEl.innerHTML = escapeHTML(title || DEFAULT_THREAD_TITLE);
}
export function createZapthreadsPoweredByMessage() {
const paragraph = document.createElement('p');
paragraph.appendChild(document.createTextNode('Powered by '));
const link = document.createElement('a');
link.href = 'https://github.com/fr4nzap/zapthreads';
link.textContent = 'zapthreads';
paragraph.appendChild(link);
return paragraph;
}
export function createZapthreadsClearCacheButton() {
const button = document.createElement('button');
button.textContent = 'Clear cache';
return button;
}
export function createZapthreadsAnchorVersionLabel() {
const paragraph = document.createElement('p');
paragraph.textContent = 'Anchor version: ';
return paragraph;
}
export const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
];
export const DEFAULT_DISABLE_FEATURES = [];
export function parseRelayList(relayList = '') {
if (!relayList.trim()) {
return [...DEFAULT_RELAYS];
}
const relays = [];
for (const item of relayList.split(',')) {
const trimmed = item.trim();
if (!trimmed) continue;
try {
relays.push(new URL(trimmed).toString());
} catch (error) {
console.warn('[zapthreads] ignored invalid relay input', trimmed, error);
}
}
return relays.length > 0 ? relays : [...DEFAULT_RELAYS];
}
export function parseDisableList(disableList = '') {
return disableList
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
export function parseUrlOptions(urls = '') {
if (!urls || typeof urls !== 'string') {
return {};
}
try {
const parsed = JSON.parse(urls);
if (parsed && typeof parsed === 'object') {
return parsed;
}
} catch (error) {
// Ignore JSON parse errors and fall back to manual parsing below.
}
const map = {};
for (const segment of urls.split(',')) {
const [rawKey, rawValue] = segment.split('=');
if (!rawKey || !rawValue) continue;
const key = rawKey.trim();
const value = rawValue.trim();
if (!key || !value) continue;
map[key] = value;
}
return map;
}
export function normalizeAnchor(anchor, { legacyUrl = false } = {}) {
const trimmed = (anchor || '').trim();
if (!trimmed) {
return { type: 'error', value: 'Missing anchor value' };
}
try {
if (trimmed.startsWith('http')) {
const normalized = legacyUrl ? trimmed : normalizeHttpAnchor(trimmed);
return { type: 'http', value: normalized };
}
const decoded = decodeNostrEntity(trimmed);
if (!decoded) {
return { type: 'error', value: `Unsupported anchor: ${trimmed}` };
}
if (decoded.type === 'note' || decoded.type === 'nevent') {
return { type: 'note', value: decoded.id };
}
if (decoded.type === 'naddr') {
const { kind, pubkey, identifier } = decoded;
return { type: 'naddr', value: `${kind}:${pubkey}:${identifier}` };
}
return { type: 'error', value: `Unsupported anchor type: ${decoded.type}` };
} catch (error) {
return { type: 'error', value: `Malformed anchor: ${trimmed}`, error };
}
}
export function createState(options = {}) {
const {
anchor = '',
legacyUrl = false,
relays = '',
disable = '',
version = '',
} = options;
const normalizedAnchor = normalizeAnchor(anchor, { legacyUrl });
return {
anchor: normalizedAnchor,
version,
relays: parseRelayList(relays),
disableFeatures: parseDisableList(disable),
legacyUrl,
filter: {},
rootEventIds: [],
anchorAuthor: undefined,
subscription: null,
eventMap: new Map(),
events: [],
};
}
export function updateFilterForAnchor(state) {
if (!state || !state.anchor) {
return;
}
const { anchor, rootEventIds, version } = state;
if (anchor.type === 'http' || anchor.type === 'note') {
if (!rootEventIds.length) {
state.filter = {};
return;
}
state.filter = { '#e': [...rootEventIds] };
return;
}
if (anchor.type === 'naddr') {
state.filter = { '#a': [anchor.value] };
state.version = version || rootEventIds[0] || '';
}
}
export function updateStateFromEvents(
state,
events = [],
{ parseEvent = Na } = {}
) {
if (!state || !Array.isArray(events) || events.length === 0) {
return [];
}
const parsedEvents = [];
const rootIds = new Set(state.rootEventIds);
const parser = typeof parseEvent === 'function' ? parseEvent : null;
if (!state.eventMap || typeof state.eventMap.set !== 'function') {
state.eventMap = new Map();
}
for (const event of events) {
if (!event) continue;
const parsed = parser ? parser(event) : event;
if (!parsed) continue;
parsedEvents.push(parsed);
const rootId = parsed.ro || parsed.id || event.id;
if (rootId) {
rootIds.add(rootId);
}
if (
!state.anchorAuthor &&
(!parsed.ro || parsed.ro === parsed.id) &&
parsed.pk
) {
state.anchorAuthor = parsed.pk;
}
const eventId = parsed.id || event.id;
if (!eventId) {
continue;
}
const previous = state.eventMap.get(eventId) || {};
state.eventMap.set(eventId, {
...previous,
...parsed,
});
}
if (rootIds.size > 0) {
state.rootEventIds = [...rootIds];
}
if (!state.version && state.rootEventIds.length > 0) {
state.version = state.rootEventIds[0];
}
updateFilterForAnchor(state);
state.events = Array.from(state.eventMap.values()).sort((a, b) => {
const aTs = typeof a.ts === 'number' ? a.ts : 0;
const bTs = typeof b.ts === 'number' ? b.ts : 0;
return bTs - aTs;
});
return parsedEvents;
}
function normalizeBaseUrl(baseUrl) {
if (!baseUrl) {
return 'https://njump.me/';
}
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
function createProfileUrl(pubkey, baseUrl) {
if (!pubkey) {
return '#';
}
const normalized = normalizeBaseUrl(baseUrl);
return `${normalized}${pubkey}`;
}
function createEventUrl(eventId, baseUrl) {
if (!eventId) {
return '#';
}
const normalized = normalizeBaseUrl(baseUrl);
return `${normalized}${eventId}`;
}
export function formatEventTimestamp(timestamp) {
if (!timestamp && timestamp !== 0) {
return '';
}
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
} catch (error) {
return date.toISOString();
}
}
export function formatAuthorName(pubkey) {
if (!pubkey) {
return 'Unknown';
}
const trimmed = pubkey.trim();
if (trimmed.length <= 16) {
return trimmed;
}
return `${trimmed.slice(0, 8)}…${trimmed.slice(-8)}`;
}
function truncateContent(content = '', limit = 280) {
const text = content.trim();
if (text.length <= limit) {
return text;
}
return `${text.slice(0, Math.max(0, limit - 1))}…`;
}
function convertContentToHtml(content = '') {
return escapeHTML(content).replace(/\n/g, '<br />');
}
function isFeatureDisabled(feature, disabled = []) {
return Array.isArray(disabled) && disabled.includes(feature);
}
export function renderThread(
threadEl,
events = [],
{
anchor,
disableFeatures = [],
urlOptions = {},
} = {},
) {
if (!threadEl) {
return;
}
clearElement(threadEl);
const list = Array.isArray(events) ? [...events] : [];
if (list.length === 0) {
const empty = document.createElement('p');
empty.className = 'ztr-thread-empty';
empty.textContent = 'No comments yet.';
threadEl.appendChild(empty);
return;
}
const profileBaseUrl = normalizeBaseUrl(
urlOptions.profile || urlOptions.profileBase || 'https://njump.me/',
);
const eventBaseUrl = normalizeBaseUrl(
urlOptions.event || urlOptions.eventBase || urlOptions.profile || 'https://njump.me/',
);
list.sort((a, b) => {
const aTs = typeof a.ts === 'number' ? a.ts : 0;
const bTs = typeof b.ts === 'number' ? b.ts : 0;
return bTs - aTs;
});
for (const event of list) {
const comment = createZapthreadsCommentContainer();
const pictureWrapper = comment.querySelector('.ztr-comment-info-picture');
const pictureImage = pictureWrapper ? pictureWrapper.querySelector('img') : null;
if (pictureImage) {
pictureImage.alt = 'User avatar';
pictureImage.decoding = 'async';
pictureImage.loading = 'lazy';
if (event.picture) {
pictureImage.src = event.picture;
} else {
pictureImage.removeAttribute('src');
}
}
const authorLink = comment.querySelector('.ztr-comment-info-author a');
if (authorLink) {
authorLink.href = createProfileUrl(event.pk, profileBaseUrl);
authorLink.textContent = formatAuthorName(event.pk);
authorLink.rel = 'noopener';
authorLink.target = '_blank';
}
const authorBadge = comment.querySelector('.ztr-comment-info-author strong');
if (authorBadge) {
authorBadge.textContent = ' posted';
}
const dotsLink = comment.querySelector('.ztr-comment-info-dots');
if (dotsLink) {
dotsLink.href = createEventUrl(event.id, eventBaseUrl);
dotsLink.textContent = 'View';
dotsLink.target = '_blank';
dotsLink.rel = 'noopener';
}
const infoLists = comment.querySelectorAll('.ztr-comment-info-items');
const metaList = infoLists[1];
if (metaList) {
clearElement(metaList);
const timestampItem = document.createElement('li');
timestampItem.textContent = formatEventTimestamp(event.ts);
metaList.appendChild(timestampItem);
if (event.ro && anchor && anchor.value && event.ro !== anchor.value) {
const bullet = createZapthreadsBulletSeparator();
metaList.appendChild(bullet);
const crossPost = createZapthreadsRepliesLabel();
crossPost.textContent = 'Cross-post';
metaList.appendChild(crossPost);
}
}
const [truncatedText, fullText] = comment.querySelectorAll('.ztr-comment-text');
if (truncatedText) {
truncatedText.innerHTML = convertContentToHtml(truncateContent(event.c));
}
if (fullText) {
fullText.innerHTML = convertContentToHtml(event.c);
}
const actions = comment.querySelector('.ztr-comment-actions');
if (actions) {
clearElement(actions);
if (!isFeatureDisabled('reply', disableFeatures)) {
const replyAction = createZapthreadsReplyActionItem();
const replySpan = replyAction.querySelector('span');
if (replySpan) {
replySpan.textContent = 'Reply';
}
actions.appendChild(replyAction);
}
if (!isFeatureDisabled('likes', disableFeatures)) {
const likeAction = createZapthreadsLikeAction();
const likeSpan = likeAction.querySelector('span');
if (likeSpan) {
const count = typeof event.likeCount === 'number' ? event.likeCount : 0;
likeSpan.textContent = `${count} likes`;
}
actions.appendChild(likeAction);
}
if (!isFeatureDisabled('zaps', disableFeatures)) {
const zapAction = createZapthreadsZapAction();
const zapSpan = zapAction.querySelector('span');
if (zapSpan) {
const total = typeof event.zapTotal === 'number' ? event.zapTotal : 0;
zapSpan.textContent = `${total} sats`;
}
actions.appendChild(zapAction);
}
}
const infoPane = createZapthreadsInfoPane();
const infoLink = infoPane.querySelector('a');
if (infoLink) {
infoLink.href = createEventUrl(event.id, eventBaseUrl);
infoLink.rel = 'noopener';
infoLink.target = '_blank';
}
comment.appendChild(infoPane);
threadEl.appendChild(comment);
}
}
export async function loadRootEvents({
state,
data,
onCache,
onNetwork,
}) {
if (!state || !data) return { cache: [], network: [] };
const { anchor, relays } = state;
if (!anchor || anchor.type === 'error') {
return { cache: [], network: [] };
}
const cacheResults = [];
const networkResults = [];
if (anchor.type === 'http') {
const cached = await data.Ti('events', anchor.value, { index: 'r' });
cacheResults.push(...cached);
} else if (anchor.type === 'note') {
const cached = await data.Ti('events', anchor.value, { index: 'id' });
cacheResults.push(...cached);
} else if (anchor.type === 'naddr') {
const cached = await data.Ti('events', anchor.value, { index: 'a' });
cacheResults.push(...cached);
}
if (onCache) {
onCache(cacheResults);
}
if (data.di && typeof data.di.querySync === 'function') {
const filter = createFilterForAnchor(state);
const queryResults = await data.di.querySync(relays, filter);
networkResults.push(...queryResults);
if (onNetwork) {
onNetwork(queryResults);
}
}
return { cache: cacheResults, network: networkResults };
}
export function createFilterForAnchor(state) {
const { anchor, rootEventIds, legacyUrl } = state;
if (!anchor) return {};
if (anchor.type === 'http') {
const values = [anchor.value];
if (!legacyUrl) {
values.push(`${anchor.value}/`);
}
return { '#r': values, kinds: [1, 8812] };
}
if (anchor.type === 'note') {
return { ids: [anchor.value] };
}
if (anchor.type === 'naddr') {
return { '#a': [anchor.value] };
}
return {};
}
export function decodeNostrEntity(entity) {
try {
const { decode } = window.NostrTools?.nip19 ?? {};
if (!decode) return null;
const result = decode(entity);
if (!result) return null;
switch (result.type) {
case 'nevent':
return { type: 'note', id: result.data.id };
case 'note':
return { type: 'note', id: result.data };
case 'naddr': {
const { kind, pubkey, identifier } = result.data;
return { type: 'naddr', kind, pubkey, identifier };
}
default:
return { type: result.type };
}
} catch (error) {
return { type: 'error', error };
}
return null;
}
export function normalizeHttpAnchor(url) {
try {
const normalized = new URL(url);
normalized.hash = '';
return normalized.toString().replace(/\/$/, '');
} catch (error) {
throw error;
}
}
export function renderWatermark(watermarkEl) {
if (!watermarkEl) return;
watermarkEl.textContent = '';
const icon = createZapthreadsEllipsisIcon();
icon.classList.add('ztr-watermark-icon');
watermarkEl.appendChild(icon);
const label = document.createElement('span');
label.textContent = ' Zapthreads';
watermarkEl.appendChild(label);
}
export function renderAnchorVersion(labelEl, version) {
if (!labelEl) return;
labelEl.innerHTML = `Anchor version: ${escapeHTML(version || 'unknown')}`;
}
export const ZAPTHREADS_EVENT_MOUNTED = 'zapthreads:mounted';
export const ZAPTHREADS_EVENT_UNMOUNTED = 'zapthreads:unmounted';
export const ZAPTHREADS_EVENT_CLEAR_CACHE = 'zapthreads:clear-cache';
export function renderClearCache(buttonEl, clearCache) {
if (!buttonEl) return () => {};
const handler = () => {
if (typeof clearCache === 'function') {
clearCache();
}
buttonEl.dispatchEvent(
new CustomEvent(ZAPTHREADS_EVENT_CLEAR_CACHE, {
bubbles: true,
detail: { source: 'button' },
})
);
};
buttonEl.addEventListener('click', handler);
return () => {
buttonEl.removeEventListener('click', handler);
};
}
export function clearElement(element) {
if (!element) return;
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
function getBlogAppRoot() {
return document.querySelector('.blog-app-root');
}
function dispatchZapthreadsEvent(targets, type, detail) {
for (const target of targets) {
if (!target || typeof target.dispatchEvent !== 'function') continue;
target.dispatchEvent(
new CustomEvent(type, {
bubbles: true,
detail: detail ? { ...detail } : {},
})
);
}
}
export async function initZapthreadsEmbed({
root,
options,
dataUtils,
} = {}) {
if (!root) {
throw new Error('initZapthreadsEmbed requires a root element');
}
const data = dataUtils || {};
const state = createState(options);
const urlOptions = parseUrlOptions(
options && typeof options.urls === 'string' ? options.urls : '',
);
const blogRoot = getBlogAppRoot();
if (!blogRoot) {
throw new Error('initZapthreadsEmbed requires .blog-app-root container');
}
const rootEl = createZapthreadsRoot();
const watermarkEl = rootEl.querySelector('.blog-ztr-watermark');
renderWatermark(watermarkEl);
const contentEl = createZapthreadsContent();
rootEl.appendChild(contentEl);
const titleEl = createZapthreadsTitleHeading();
contentEl.appendChild(titleEl);
let currentTitle = DEFAULT_THREAD_TITLE;
let hasCustomTitle = false;
renderThreadTitle(titleEl, currentTitle);
const poweredByEl = createZapthreadsPoweredByMessage();
contentEl.appendChild(poweredByEl);
const anchorVersionEl = createZapthreadsAnchorVersionLabel();
contentEl.appendChild(anchorVersionEl);
renderAnchorVersion(anchorVersionEl, state.version);
const clearCacheButton = createZapthreadsClearCacheButton();
contentEl.appendChild(clearCacheButton);
const threadEl = createZapthreadsThreadContainer();
contentEl.appendChild(threadEl);
renderThread(threadEl, state.events, {
anchor: state.anchor,
disableFeatures: state.disableFeatures,
urlOptions,
});
const disposeCallbacks = new Set();
const registerDispose = (callback) => {
if (typeof callback === 'function') {
disposeCallbacks.add(callback);
}
};
const clearCacheCleanup = renderClearCache(clearCacheButton, data.clearCache);
registerDispose(clearCacheCleanup);
removeExistingZapthreadsMount(blogRoot);
const mountContainer = createZapthreadsMountContainer(blogRoot);
mountContainer.appendChild(rootEl);
const lifecycleTargets = [rootEl];
if (root && root !== rootEl && typeof root.dispatchEvent === 'function') {
lifecycleTargets.push(root);
}
const lifecycleDetail = () => ({ anchor: state.anchor, root: rootEl });
const renderError = (message) => {
clearElement(contentEl);
const heading = createZapthreadsErrorHeading();
heading.textContent = 'Error!';
const details = createZapthreadsErrorDetails();
const pre = details.querySelector('pre');
if (pre) {
pre.innerHTML = escapeHTML(message || '');
}
contentEl.appendChild(heading);
contentEl.appendChild(details);
};
if (state.anchor.type === 'error') {
renderError(state.anchor.value);
dispatchZapthreadsEvent(lifecycleTargets, ZAPTHREADS_EVENT_MOUNTED, lifecycleDetail());
return () => {
disposeCallbacks.forEach((callback) => {
try {
callback();
} catch (error) {
console.error('[zapthreads] cleanup failed', error);
}
});
disposeCallbacks.clear();
dispatchZapthreadsEvent(
lifecycleTargets,
ZAPTHREADS_EVENT_UNMOUNTED,
lifecycleDetail()
);
if (mountContainer.parentNode) {
mountContainer.parentNode.removeChild(mountContainer);
}
};
}
dispatchZapthreadsEvent(
lifecycleTargets,
ZAPTHREADS_EVENT_MOUNTED,
lifecycleDetail()
);
updateFilterForAnchor(state);
renderAnchorVersion(anchorVersionEl, state.version);
const parseEvent = typeof data.Na === 'function' ? data.Na : null;
let currentSubscription = null;
let currentFilterKey = null;
const closeSubscription = () => {
if (currentSubscription && typeof currentSubscription.close === 'function') {
try {
currentSubscription.close();
} catch (error) {
console.error('[zapthreads] failed to close subscription', error);
}
}
currentSubscription = null;
currentFilterKey = null;
state.subscription = null;
};
registerDispose(closeSubscription);
const processEvents = (events = []) => {
const parsedEvents = updateStateFromEvents(state, events, { parseEvent });
if (parsedEvents.length > 0) {
const eventWithTitle = parsedEvents.find((item) => item && item.tl);
if (eventWithTitle) {
hasCustomTitle = true;
currentTitle = eventWithTitle.tl || DEFAULT_THREAD_TITLE;
renderThreadTitle(titleEl, currentTitle);
}
} else if (!hasCustomTitle) {
renderThreadTitle(titleEl, currentTitle);
}
renderAnchorVersion(anchorVersionEl, state.version);
renderThread(threadEl, state.events, {
anchor: state.anchor,
disableFeatures: state.disableFeatures,
urlOptions,
});
return parsedEvents;
};
const ensureSubscription = () => {
if (!data.di || typeof data.di.subscribeMany !== 'function') return;
if (!state.filter || Object.keys(state.filter).length === 0) return;
const nextKey = JSON.stringify(state.filter);
if (currentSubscription && currentFilterKey === nextKey) {
return;
}
closeSubscription();
currentFilterKey = nextKey;
currentSubscription = data.di.subscribeMany(state.relays, [state.filter], {
onevent(event) {
if (!event) return;
processEvents([event]);
if (typeof data.un === 'function') {
data.un('events', event, { immediate: true });
}
},
});
state.subscription = currentSubscription;
};
await loadRootEvents({
state,
data,
onCache(events = []) {
processEvents(events);
ensureSubscription();
},
onNetwork(events = []) {
if (!Array.isArray(events) || events.length === 0) {
ensureSubscription();
return;
}
processEvents(events);
for (const event of events) {
if (event && typeof data.un === 'function') {
data.un('events', event, { immediate: true });
}
}
ensureSubscription();
},
});
ensureSubscription();
return () => {
disposeCallbacks.forEach((callback) => {
try {
callback();
} catch (error) {
console.error('[zapthreads] cleanup failed', error);
}
});
disposeCallbacks.clear();
dispatchZapthreadsEvent(
lifecycleTargets,
ZAPTHREADS_EVENT_UNMOUNTED,
lifecycleDetail()
);
if (mountContainer.parentNode) {
mountContainer.parentNode.removeChild(mountContainer);
}
};
}