Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
897f5df07a
  1. 4
      public/healthz.json
  2. 59
      src/lib/components/content/MarkdownRenderer.svelte
  3. 42
      src/lib/components/content/QuotedContext.svelte
  4. 42
      src/lib/components/content/ReplyContext.svelte
  5. 3
      src/lib/components/layout/ProfileBadge.svelte
  6. 81
      src/lib/modules/comments/CommentForm.svelte
  7. 378
      src/lib/modules/comments/CommentThread.svelte
  8. 89
      src/lib/modules/feed/FeedPage.svelte
  9. 138
      src/lib/modules/feed/FeedPost.svelte
  10. 332
      src/lib/modules/feed/ThreadDrawer.svelte
  11. 307
      src/lib/modules/reactions/FeedReactionButtons.svelte
  12. 6
      src/lib/modules/reactions/ReactionButtons.svelte
  13. 38
      src/lib/modules/threads/ThreadView.svelte
  14. 3
      src/lib/modules/zaps/ZapButton.svelte
  15. 2
      src/lib/modules/zaps/ZapReceipt.svelte
  16. 4
      src/lib/services/nostr/nip30-emoji.ts
  17. 8
      src/lib/services/security/sanitizer.ts

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-03T14:14:13.893Z", "buildTime": "2026-02-03T14:56:41.652Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770128053893 "timestamp": 1770130601652
} }

59
src/lib/components/content/MarkdownRenderer.svelte

@ -34,9 +34,10 @@
const pubkey = el.getAttribute('data-pubkey'); const pubkey = el.getAttribute('data-pubkey');
const placeholder = el.getAttribute('data-placeholder'); const placeholder = el.getAttribute('data-placeholder');
if (pubkey && placeholder && profileBadges.has(placeholder)) { if (pubkey && placeholder && profileBadges.has(placeholder)) {
// Clear the element and mount component // Don't clear if already mounted
el.innerHTML = ''; if (el.children.length === 0) {
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey });
}
} }
}); });
@ -46,9 +47,10 @@
const eventId = el.getAttribute('data-event-id'); const eventId = el.getAttribute('data-event-id');
const placeholder = el.getAttribute('data-placeholder'); const placeholder = el.getAttribute('data-placeholder');
if (eventId && placeholder && embeddedEvents.has(placeholder)) { if (eventId && placeholder && embeddedEvents.has(placeholder)) {
// Clear the element and mount component // Don't clear if already mounted
el.innerHTML = ''; if (el.children.length === 0) {
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId });
}
} }
}); });
}); });
@ -118,8 +120,9 @@
const sortedLinks = [...links].sort((a, b) => b.start - a.start); const sortedLinks = [...links].sort((a, b) => b.start - a.start);
for (const link of sortedLinks) { for (const link of sortedLinks) {
// Use a unique placeholder that won't be processed by markdown // Use a special marker that will be replaced after markdown parsing
const placeholder = `\u200B\u200B\u200BNIP21_LINK_${offset}\u200B\u200B\u200B`; // Use a format that markdown won't process: a code-like structure
const placeholder = `\`NIP21PLACEHOLDER${offset}\``;
const before = processed.slice(0, link.start); const before = processed.slice(0, link.start);
const after = processed.slice(link.end); const after = processed.slice(link.end);
processed = before + placeholder + after; processed = before + placeholder + after;
@ -145,7 +148,7 @@
const eventId = parsed.data; const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else { } else {
const decoded: any = nip19.decode(parsed.data); const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') { if (decoded.type === 'npub' || decoded.type === 'nprofile') {
@ -158,7 +161,7 @@
// Use custom element that will be replaced with ProfileBadge component // Use custom element that will be replaced with ProfileBadge component
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey); profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`; replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
@ -167,17 +170,17 @@
// Use custom element for embedded event // Use custom element for embedded event
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id); const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
// For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string // For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
@ -197,7 +200,7 @@
if (pubkey) { if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey); profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`; replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
} }
@ -209,13 +212,17 @@
} }
} }
// Escape placeholder for regex replacement // Replace placeholder - it will be in a <code> tag after markdown parsing
const codePlaceholder = `<code>${placeholder.replace(/`/g, '')}</code>`;
finalHtml = finalHtml.replace(new RegExp(codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
// Also try without code tag (in case markdown didn't process it)
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
// Clean up any remaining placeholders (fallback) // Clean up any remaining placeholders (fallback) - look for code tags with our placeholder
finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, ''); finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, '');
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, '');
rendered = finalHtml; rendered = finalHtml;
}); });
@ -235,7 +242,7 @@
const eventId = parsed.data; const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else { } else {
const decoded: any = nip19.decode(parsed.data); const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') { if (decoded.type === 'npub' || decoded.type === 'nprofile') {
@ -247,7 +254,7 @@
if (pubkey) { if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey); profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`; replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
@ -255,16 +262,16 @@
const eventId = String(decoded.data); const eventId = String(decoded.data);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id); const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId); embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data); embeddedEvents.set(eventPlaceholder, parsed.data);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
@ -387,6 +394,8 @@
.markdown-content :global(img) { .markdown-content :global(img) {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block;
margin: 0.5em 0;
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
} }
@ -394,6 +403,12 @@
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
} }
.markdown-content :global(.nostr-profile-badge-placeholder),
.markdown-content :global(.nostr-embedded-event-placeholder) {
display: inline-block;
vertical-align: middle;
}
/* Style emojis in content */ /* Style emojis in content */
.markdown-content :global(span[role="img"]), .markdown-content :global(span[role="img"]),
.markdown-content :global(.emoji) { .markdown-content :global(.emoji) {

42
src/lib/components/content/QuotedContext.svelte

@ -9,9 +9,10 @@
quotedEventId?: string; // Optional - used to load quoted event if not provided quotedEventId?: string; // Optional - used to load quoted event if not provided
targetId?: string; // Optional ID to scroll to (defaults to quoted event ID) targetId?: string; // Optional ID to scroll to (defaults to quoted event ID)
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
} }
let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded }: Props = $props(); let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded, onOpenEvent }: Props = $props();
let loadedQuotedEvent = $state<NostrEvent | null>(null); let loadedQuotedEvent = $state<NostrEvent | null>(null);
let loadingQuoted = $state(false); let loadingQuoted = $state(false);
@ -50,8 +51,6 @@
if (onQuotedLoaded && typeof onQuotedLoaded === 'function') { if (onQuotedLoaded && typeof onQuotedLoaded === 'function') {
onQuotedLoaded(loadedQuotedEvent); onQuotedLoaded(loadedQuotedEvent);
} }
// After loading, try to scroll to it
setTimeout(() => scrollToQuoted(), 100);
} }
} catch (error) { } catch (error) {
console.error('Error loading quoted event:', error); console.error('Error loading quoted event:', error);
@ -69,45 +68,10 @@
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
} }
async function scrollToQuoted() {
const eventId = quotedEvent?.id || quotedEventId;
if (!eventId) return;
// If quoted event not loaded yet, load it first
if (!quotedEvent && quotedEventId) {
await loadQuotedEvent();
}
const elementId = targetId || `event-${eventId}`;
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
// If still not found, wait a bit for DOM to update
if (!element) {
await new Promise(resolve => setTimeout(resolve, 200));
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
}
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('highlight-quoted');
setTimeout(() => {
element?.classList.remove('highlight-quoted');
}, 2000);
}
}
</script> </script>
<div <div
class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity" class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"
onclick={scrollToQuoted}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToQuoted();
}
}}
> >
<span class="font-semibold">Quoting:</span> {getQuotedPreview()} <span class="font-semibold">Quoting:</span> {getQuotedPreview()}
{#if loadingQuoted} {#if loadingQuoted}

42
src/lib/components/content/ReplyContext.svelte

@ -9,9 +9,10 @@
parentEventId?: string; // Optional - used to load parent if not provided parentEventId?: string; // Optional - used to load parent if not provided
targetId?: string; // Optional ID to scroll to (defaults to parent event ID) targetId?: string; // Optional ID to scroll to (defaults to parent event ID)
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
} }
let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded }: Props = $props(); let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded, onOpenEvent }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
@ -50,8 +51,6 @@
if (onParentLoaded && typeof onParentLoaded === 'function') { if (onParentLoaded && typeof onParentLoaded === 'function') {
onParentLoaded(loadedParentEvent); onParentLoaded(loadedParentEvent);
} }
// After loading, try to scroll to it
setTimeout(() => scrollToParent(), 100);
} }
} catch (error) { } catch (error) {
console.error('Error loading parent event:', error); console.error('Error loading parent event:', error);
@ -69,45 +68,10 @@
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
} }
async function scrollToParent() {
const eventId = parentEvent?.id || parentEventId;
if (!eventId) return;
// If parent not loaded yet, load it first
if (!parentEvent && parentEventId) {
await loadParentEvent();
}
const elementId = targetId || `event-${eventId}`;
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
// If still not found, wait a bit for DOM to update
if (!element) {
await new Promise(resolve => setTimeout(resolve, 200));
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`);
}
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('highlight-parent');
setTimeout(() => {
element?.classList.remove('highlight-parent');
}, 2000);
}
}
</script> </script>
<div <div
class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity" class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"
onclick={scrollToParent}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToParent();
}
}}
> >
<span class="font-semibold">Replying to:</span> {getParentPreview()} <span class="font-semibold">Replying to:</span> {getParentPreview()}
{#if loadingParent} {#if loadingParent}

3
src/lib/components/layout/ProfileBadge.svelte

@ -133,10 +133,13 @@
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
max-width: 100%; max-width: 100%;
filter: grayscale(100%) opacity(0.7);
transition: filter 0.2s;
} }
.profile-badge:hover { .profile-badge:hover {
text-decoration: underline; text-decoration: underline;
filter: grayscale(100%) opacity(0.9);
} }
.profile-picture { .profile-picture {

81
src/lib/modules/comments/CommentForm.svelte

@ -5,18 +5,45 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
threadId: string; // The kind 11 thread event ID threadId: string; // The root event ID
parentEvent?: NostrEvent; // If replying to a comment rootEvent?: NostrEvent; // The root event (to determine reply kind)
parentEvent?: NostrEvent; // If replying to a comment/reply
onPublished?: () => void; onPublished?: () => void;
onCancel?: () => void; onCancel?: () => void;
} }
let { threadId, parentEvent, onPublished, onCancel }: Props = $props(); let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props();
let content = $state(''); let content = $state('');
let publishing = $state(false); let publishing = $state(false);
let includeClientTag = $state(true); let includeClientTag = $state(true);
/**
* Determine what kind of reply to create
* - Kind 1 events should get kind 1 replies
* - Everything else gets kind 1111 comments
*/
function getReplyKind(): number {
// If replying to a parent event, check its kind
if (parentEvent) {
// If parent is kind 1, reply with kind 1
if (parentEvent.kind === 1) return 1;
// Everything else gets kind 1111
return 1111;
}
// If replying to root, check root kind
if (rootEvent) {
// If root is kind 1, reply with kind 1
if (rootEvent.kind === 1) return 1;
// Everything else gets kind 1111
return 1111;
}
// Default to kind 1111 if we can't determine
return 1111;
}
async function publish() { async function publish() {
if (!sessionManager.isLoggedIn()) { if (!sessionManager.isLoggedIn()) {
alert('Please log in to comment'); alert('Please log in to comment');
@ -31,18 +58,40 @@
publishing = true; publishing = true;
try { try {
const tags: string[][] = [ const replyKind = getReplyKind();
['K', '11'], // Kind of the event being commented on const tags: string[][] = [];
['E', threadId] // Event ID of the thread
]; if (replyKind === 1) {
// Kind 1 reply (NIP-10)
// If replying to a comment, add parent references tags.push(['e', threadId]); // Root event
if (parentEvent) { if (rootEvent) {
tags.push(['E', parentEvent.id]); // Parent comment event ID tags.push(['p', rootEvent.pubkey]); // Root author
tags.push(['e', parentEvent.id]); // Also add lowercase e tag for compatibility }
tags.push(['p', parentEvent.pubkey]); // Parent comment author
tags.push(['P', parentEvent.pubkey]); // NIP-22 uppercase P tag // If replying to a parent, add parent references
tags.push(['k', '1111']); // Kind of parent (comment) if (parentEvent) {
tags.push(['e', parentEvent.id, '', 'reply']); // Parent event with 'reply' marker
tags.push(['p', parentEvent.pubkey]); // Parent author
}
} else {
// Kind 1111 comment (NIP-22)
const rootKind = rootEvent?.kind || '1';
tags.push(['K', String(rootKind)]); // Root kind
tags.push(['E', threadId]); // Root event ID (uppercase for NIP-22)
if (rootEvent) {
tags.push(['P', rootEvent.pubkey]); // Root author (uppercase P)
}
// If replying to a parent, add parent references
if (parentEvent) {
const parentKind = parentEvent.kind;
tags.push(['e', parentEvent.id]); // Parent event ID (lowercase for parent)
tags.push(['k', String(parentKind)]); // Parent kind (lowercase k)
tags.push(['p', parentEvent.pubkey]); // Parent author (lowercase p)
// Also add uppercase for compatibility
tags.push(['E', parentEvent.id]);
tags.push(['P', parentEvent.pubkey]);
}
} }
if (includeClientTag) { if (includeClientTag) {
@ -50,7 +99,7 @@
} }
const event: Omit<NostrEvent, 'id' | 'sig'> = { const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 1111, kind: replyKind,
pubkey: sessionManager.getCurrentPubkey()!, pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,

378
src/lib/modules/comments/CommentThread.svelte

@ -9,55 +9,149 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
threadId: string; // The event ID threadId: string; // The event ID of the root event
event?: NostrEvent; // The event itself (optional, used to determine reply types) event?: NostrEvent; // The root event itself (optional, used to determine reply types)
} }
let { threadId, event }: Props = $props(); let { threadId, event }: Props = $props();
let comments = $state<NostrEvent[]>([]); let comments = $state<NostrEvent[]>([]); // kind 1111
let kind1Replies = $state<NostrEvent[]>([]); let kind1Replies = $state<NostrEvent[]>([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything)
let yakBacks = $state<NostrEvent[]>([]); let yakBacks = $state<NostrEvent[]>([]); // kind 1244 (voice replies)
let zapReceipts = $state<NostrEvent[]>([]); let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts)
let loading = $state(true); let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null); let replyingTo = $state<NostrEvent | null>(null);
const isKind1 = $derived(event?.kind === 1); const isKind1 = $derived(event?.kind === 1);
const rootKind = $derived(event?.kind || null);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
loadComments();
}); });
// Reload comments when threadId or event changes
$effect(() => {
if (threadId) {
loadComments();
}
});
/**
* Get the parent event ID from a reply event
* For kind 1111: checks both E/e and A/a tags (NIP-22)
* For kind 1: checks e tag (NIP-10)
* For kind 1244: checks E/e and A/a tags (follows NIP-22)
* For kind 9735: checks e tag
*/
function getParentEventId(replyEvent: NostrEvent): string | null {
// For kind 1111, check both uppercase and lowercase E and A tags
if (replyEvent.kind === 1111) {
// Check uppercase E tag first (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1]) {
// If it points to root, check lowercase e for parent
if (eTag[1] === threadId) {
const parentETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId);
if (parentETag && parentETag[1]) return parentETag[1];
} else {
// E tag points to parent (non-standard but some clients do this)
return eTag[1];
}
}
// Check lowercase e tag
const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId && t[1] !== replyEvent.id);
if (lowerETag && lowerETag[1]) return lowerETag[1];
// Check uppercase A tag (NIP-22 for addressable events)
const aTag = replyEvent.tags.find((t) => t[0] === 'A');
if (aTag && aTag[1]) {
// If it points to root, check lowercase a for parent
const parentATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] !== aTag[1]);
if (parentATag && parentATag[1]) {
// Need to find event by address - for now, check if we have it
// This is complex, so we'll handle it in the parent lookup
}
}
// Check lowercase a tag
const lowerATag = replyEvent.tags.find((t) => t[0] === 'a');
if (lowerATag && lowerATag[1]) {
// Try to find event with matching address
// For now, we'll handle this by checking all events
}
}
// For kind 1, 1244, 9735: check e tag
if (replyEvent.kind === 1 || replyEvent.kind === 1244 || replyEvent.kind === 9735) {
const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== replyEvent.id);
if (eTag && eTag[1]) return eTag[1];
}
return null;
}
/**
* Check if a reply event references the root thread
* For kind 1111: checks both E/e and A/a tags (NIP-22)
* For other kinds: checks e tag
*/
function referencesRoot(replyEvent: NostrEvent): boolean {
if (replyEvent.kind === 1111) {
// Check uppercase E tag (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1] === threadId) return true;
// Check lowercase e tag (fallback)
const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId);
if (lowerETag) return true;
// Check A and a tags for addressable events
// If root event has an address (a-tag), check if reply references it
if (event) {
const rootATag = event.tags.find((t) => t[0] === 'a');
if (rootATag && rootATag[1]) {
const replyATag = replyEvent.tags.find((t) => t[0] === 'A');
if (replyATag && replyATag[1] === rootATag[1]) return true;
const replyLowerATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] === rootATag[1]);
if (replyLowerATag) return true;
}
}
// If no direct reference found, check if parent is root
const parentId = getParentEventId(replyEvent);
return parentId === null || parentId === threadId;
}
// For other kinds, check e tag
const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId);
return !!eTag;
}
async function loadComments() { async function loadComments() {
loading = true; loading = true;
try { try {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays(); const relays = relayManager.getCommentReadRelays();
const feedRelays = relayManager.getFeedReadRelays(); const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])]; const allRelays = [...new Set([...relays, ...feedRelays])];
const replyFilters: any[] = [ const replyFilters: any[] = [];
{ kinds: [9735], '#e': [threadId] }, // Zap receipts
{ kinds: [1244], '#e': [threadId] }, // Yak backs (voice replies)
];
// For kind 1 events, also fetch kind 1 replies // Always fetch kind 1111 comments - check both e and E tags, and a and A tags
if (isKind1) { replyFilters.push(
replyFilters.push({ kinds: [1], '#e': [threadId] }); { kinds: [1111], '#e': [threadId] }, // Lowercase e tag
} { kinds: [1111], '#E': [threadId] }, // Uppercase E tag (NIP-22)
{ kinds: [1111], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags)
{ kinds: [1111], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events)
);
// For all events, fetch kind 1111 comments // For kind 1 events, fetch kind 1 replies
// For kind 11 threads, use #E and #K tags (NIP-22) // Also fetch kind 1 replies for any event (some apps use kind 1 for everything)
// For other events, use #e tag replyFilters.push({ kinds: [1], '#e': [threadId] });
if (event?.kind === 11) {
replyFilters.push( // Fetch yak backs (kind 1244) - voice replies
{ kinds: [1111], '#E': [threadId], '#K': ['11'] }, // NIP-22 standard (uppercase) replyFilters.push({ kinds: [1244], '#e': [threadId] });
{ kinds: [1111], '#e': [threadId] } // Fallback (lowercase)
); // Fetch zap receipts (kind 9735)
} else { replyFilters.push({ kinds: [9735], '#e': [threadId] });
replyFilters.push({ kinds: [1111], '#e': [threadId] });
}
const allReplies = await nostrClient.fetchEvents( const allReplies = await nostrClient.fetchEvents(
replyFilters, replyFilters,
@ -65,17 +159,18 @@
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
// Filter to only replies that reference the root
const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Separate by type // Separate by type
comments = allReplies.filter(e => e.kind === 1111); comments = rootReplies.filter(e => e.kind === 1111);
kind1Replies = allReplies.filter(e => e.kind === 1); kind1Replies = rootReplies.filter(e => e.kind === 1);
yakBacks = allReplies.filter(e => e.kind === 1244); yakBacks = rootReplies.filter(e => e.kind === 1244);
zapReceipts = allReplies.filter(e => e.kind === 9735); zapReceipts = rootReplies.filter(e => e.kind === 9735);
// Recursively fetch all nested replies // Recursively fetch all nested replies
await fetchNestedReplies(); await fetchNestedReplies();
// Fetch zap receipts that reference this thread or any comment/reply
await fetchZapReceipts();
} catch (error) { } catch (error) {
console.error('Error loading comments:', error); console.error('Error loading comments:', error);
} finally { } finally {
@ -89,49 +184,43 @@
const allRelays = [...new Set([...relays, ...feedRelays])]; const allRelays = [...new Set([...relays, ...feedRelays])];
let hasNewReplies = true; let hasNewReplies = true;
let iterations = 0; let iterations = 0;
const maxIterations = 10; // Prevent infinite loops const maxIterations = 10;
// Keep fetching until we have all nested replies
while (hasNewReplies && iterations < maxIterations) { while (hasNewReplies && iterations < maxIterations) {
iterations++; iterations++;
hasNewReplies = false; hasNewReplies = false;
const allReplyIds = new Set([ const allReplyIds = new Set([
...comments.map(c => c.id), ...comments.map(c => c.id),
...kind1Replies.map(r => r.id), ...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id) ...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]); ]);
if (allReplyIds.size > 0) { if (allReplyIds.size > 0) {
const nestedFilters: any[] = [ const nestedFilters: any[] = [
{ kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts // Fetch nested kind 1111 comments - check both e/E and a/A tags
{ kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs { kinds: [1111], '#e': Array.from(allReplyIds) },
{ kinds: [1111], '#E': Array.from(allReplyIds) },
{ kinds: [1111], '#a': Array.from(allReplyIds) },
{ kinds: [1111], '#A': Array.from(allReplyIds) },
// Fetch nested kind 1 replies
{ kinds: [1], '#e': Array.from(allReplyIds) },
// Fetch nested yak backs
{ kinds: [1244], '#e': Array.from(allReplyIds) },
// Fetch nested zap receipts
{ kinds: [9735], '#e': Array.from(allReplyIds) }
]; ];
// For kind 1 events, also fetch nested kind 1 replies
if (isKind1) {
nestedFilters.push({ kinds: [1], '#e': Array.from(allReplyIds) });
}
// Fetch nested comments
if (event?.kind === 11) {
nestedFilters.push(
{ kinds: [1111], '#E': Array.from(allReplyIds), '#K': ['11'] },
{ kinds: [1111], '#e': Array.from(allReplyIds) }
);
} else {
nestedFilters.push({ kinds: [1111], '#e': Array.from(allReplyIds) });
}
const nestedReplies = await nostrClient.fetchEvents( const nestedReplies = await nostrClient.fetchEvents(
nestedFilters, nestedFilters,
allRelays, allRelays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
// Add new replies by type // Add new replies by type
for (const reply of nestedReplies) { for (const reply of nestedReplies) {
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) { if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) {
comments.push(reply); comments.push(reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) { } else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply); kind1Replies.push(reply);
@ -139,114 +228,42 @@
} else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) { } else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply); yakBacks.push(reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 9735 && !zapReceipts.some(z => z.id === reply.id)) {
zapReceipts.push(reply);
hasNewReplies = true;
}
} }
} }
}
} }
} }
/**
async function fetchZapReceipts() { * Get parent event from any of our loaded events
const config = nostrClient.getConfig(); */
const relays = relayManager.getCommentReadRelays(); function getParentEvent(replyEvent: NostrEvent): NostrEvent | undefined {
const feedRelays = relayManager.getFeedReadRelays(); const parentId = getParentEventId(replyEvent);
const allRelays = [...new Set([...relays, ...feedRelays])]; if (!parentId) return undefined;
// Keep fetching until we have all zaps // Check if parent is the root event
let previousCount = -1; if (parentId === threadId) return event || undefined;
while (zapReceipts.length !== previousCount) {
previousCount = zapReceipts.length; // Find parent in loaded events
const allEventIds = new Set([ return comments.find(c => c.id === parentId)
threadId, || kind1Replies.find(r => r.id === parentId)
...comments.map(c => c.id), || yakBacks.find(y => y.id === parentId)
...kind1Replies.map(r => r.id), || zapReceipts.find(z => z.id === parentId);
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]);
// Fetch zap receipts that reference thread or any comment/reply/yak/zap
const zapFilters = [
{
kinds: [9735],
'#e': Array.from(allEventIds) // Zap receipts for thread and all replies
}
];
const zapEvents = await nostrClient.fetchEvents(
zapFilters,
allRelays,
{ useCache: true, cacheResults: true }
);
const validZaps = zapEvents.filter(receipt => {
// Filter by threshold
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= config.zapThreshold;
}
return false;
});
// Add new zap receipts
const existingZapIds = new Set(zapReceipts.map(z => z.id));
for (const zap of validZaps) {
if (!existingZapIds.has(zap.id)) {
zapReceipts.push(zap);
}
}
// Check if any zaps reference events we don't have
const missingEventIds = new Set<string>();
for (const zap of validZaps) {
const eTag = zap.tags.find((t) => t[0] === 'e');
if (eTag && eTag[1] && eTag[1] !== threadId) {
const exists = comments.some(c => c.id === eTag[1])
|| kind1Replies.some(r => r.id === eTag[1])
|| yakBacks.some(y => y.id === eTag[1]);
if (!exists) {
missingEventIds.add(eTag[1]);
}
}
}
// Fetch missing events (could be comments, replies, or yak backs)
if (missingEventIds.size > 0) {
const missingEvents = await nostrClient.fetchEvents(
[
{ kinds: [1111], ids: Array.from(missingEventIds) },
{ kinds: [1], ids: Array.from(missingEventIds) },
{ kinds: [1244], ids: Array.from(missingEventIds) }
],
allRelays,
{ useCache: true, cacheResults: true }
);
for (const event of missingEvents) {
if (event.kind === 1111 && !comments.some(c => c.id === event.id)) {
comments.push(event);
} else if (event.kind === 1 && !kind1Replies.some(r => r.id === event.id)) {
kind1Replies.push(event);
} else if (event.kind === 1244 && !yakBacks.some(y => y.id === event.id)) {
yakBacks.push(event);
}
}
// Fetch nested replies to newly found events
await fetchNestedReplies();
}
}
} }
/**
* Sort thread items with proper nesting
*/
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> { function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
// Build thread structure similar to feed
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>(); const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[] const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = []; const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const allEventIds = new Set<string>(); const allEventIds = new Set<string>();
// First pass: build event map and collect all event IDs // First pass: build event map
for (const item of items) { for (const item of items) {
eventMap.set(item.event.id, item); eventMap.set(item.event.id, item);
allEventIds.add(item.event.id); allEventIds.add(item.event.id);
@ -254,24 +271,16 @@
// Second pass: determine parent-child relationships // Second pass: determine parent-child relationships
for (const item of items) { for (const item of items) {
// Check if this is a reply - check both uppercase E (NIP-22) and lowercase e tags const parentId = getParentEventId(item.event);
const eTag = item.event.tags.find((t) => t[0] === 'E') || item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id);
const parentId = eTag?.[1];
if (parentId) { if (parentId && (parentId === threadId || allEventIds.has(parentId))) {
// Check if parent is the thread or another reply we have
if (parentId === threadId || allEventIds.has(parentId)) {
// This is a reply // This is a reply
if (!replyMap.has(parentId)) { if (!replyMap.has(parentId)) {
replyMap.set(parentId, []); replyMap.set(parentId, []);
} }
replyMap.get(parentId)!.push(item.event.id); replyMap.get(parentId)!.push(item.event.id);
} else { } else {
// Parent not found - treat as root item (might be a missing parent) // No parent or parent not found - treat as root item
rootItems.push(item);
}
} else {
// No parent tag - this is a root item (direct reply to thread)
rootItems.push(item); rootItems.push(item);
} }
} }
@ -291,7 +300,7 @@
const replyItems = replies const replyItems = replies
.map(id => eventMap.get(id)) .map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined) .filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at); // Sort replies chronologically .sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of replyItems) { for (const reply of replyItems) {
addThread(reply); addThread(reply);
@ -317,31 +326,31 @@
return sortThreadItems(items); return sortThreadItems(items);
} }
function getParentEvent(event: NostrEvent): NostrEvent | undefined { function handleReply(replyEvent: NostrEvent) {
// NIP-22: E tag (uppercase) points to parent event, or lowercase e tag replyingTo = replyEvent;
const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Find parent in comments, replies, yak backs, or zap receipts
const parent = comments.find((c) => c.id === eTag[1])
|| kind1Replies.find((r) => r.id === eTag[1])
|| yakBacks.find((y) => y.id === eTag[1])
|| zapReceipts.find((z) => z.id === eTag[1]);
if (parent) return parent;
// If parent not found, it might be the thread itself
return undefined;
}
return undefined;
}
function handleReply(comment: NostrEvent) {
replyingTo = comment;
} }
function handleCommentPublished() { function handleCommentPublished() {
replyingTo = null; replyingTo = null;
loadComments(); loadComments();
} }
/**
* Determine what kind of reply is allowed for a given event
*/
function getAllowedReplyKind(targetEvent: NostrEvent | null): number {
if (!targetEvent) {
// If replying to root, check root kind
if (isKind1) return 1;
return 1111;
}
// If target is kind 1, allow kind 1 reply
if (targetEvent.kind === 1) return 1;
// Everything else gets kind 1111
return 1111;
}
</script> </script>
<div class="comment-thread"> <div class="comment-thread">
@ -372,6 +381,7 @@
<FeedPost post={item.event} /> <FeedPost post={item.event} />
</div> </div>
{:else if item.type === 'zap'} {:else if item.type === 'zap'}
<!-- Zap receipt - render with lightning bolt -->
<ZapReceiptReply <ZapReceiptReply
zapReceipt={item.event} zapReceipt={item.event}
parentEvent={parent} parentEvent={parent}
@ -386,6 +396,7 @@
<div class="reply-form-container mt-4"> <div class="reply-form-container mt-4">
<CommentForm <CommentForm
threadId={threadId} threadId={threadId}
rootEvent={event}
parentEvent={replyingTo} parentEvent={replyingTo}
onPublished={handleCommentPublished} onPublished={handleCommentPublished}
onCancel={() => (replyingTo = null)} onCancel={() => (replyingTo = null)}
@ -394,7 +405,8 @@
{:else} {:else}
<div class="new-comment-container mt-4"> <div class="new-comment-container mt-4">
<CommentForm <CommentForm
{threadId} threadId={threadId}
rootEvent={event}
onPublished={handleCommentPublished} onPublished={handleCommentPublished}
/> />
</div> </div>

89
src/lib/modules/feed/FeedPage.svelte

@ -2,8 +2,9 @@
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte'; import { onMount, tick } from 'svelte';
let posts = $state<NostrEvent[]>([]); let posts = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
@ -11,31 +12,37 @@
let hasMore = $state(true); let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null); let oldestTimestamp = $state<number | null>(null);
// Drawer state for viewing parent/quoted events
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Debounce updates to prevent rapid re-renders // Debounce updates to prevent rapid re-renders
let updateTimeout: ReturnType<typeof setTimeout> | null = null; let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdates: NostrEvent[] = []; let pendingUpdates: NostrEvent[] = [];
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
onMount(() => { let sentinelElement = $state<HTMLElement | null>(null);
(async () => { let observer: IntersectionObserver | null = null;
await nostrClient.initialize();
await loadFeed();
})();
// Set up intersection observer for infinite scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1 });
const sentinel = document.getElementById('feed-sentinel'); onMount(async () => {
if (sentinel) { await nostrClient.initialize();
observer.observe(sentinel); await loadFeed();
} });
// Cleanup on unmount
$effect(() => {
return () => { return () => {
if (sentinel) { if (observer) {
observer.unobserve(sentinel); observer.disconnect();
} }
if (updateTimeout) { if (updateTimeout) {
clearTimeout(updateTimeout); clearTimeout(updateTimeout);
@ -43,6 +50,26 @@
}; };
}); });
// Set up observer when sentinel element is available
$effect(() => {
if (sentinelElement && !loading && !observer) {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1 });
observer.observe(sentinelElement);
return () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
}
});
async function loadFeed() { async function loadFeed() {
loading = true; loading = true;
try { try {
@ -119,9 +146,21 @@
if (oldest < (oldestTimestamp || Infinity)) { if (oldest < (oldestTimestamp || Infinity)) {
oldestTimestamp = oldest; oldestTimestamp = oldest;
} }
hasMore = events.length >= 20;
} else if (events.length > 0) {
// All events were duplicates, but we got some results
// This might mean we've reached the end, or we need to adjust the timestamp
if (oldestTimestamp) {
// Try moving the timestamp forward slightly to avoid getting the same results
oldestTimestamp = oldestTimestamp - 1;
hasMore = events.length >= 20;
} else {
hasMore = false;
}
} else {
// No events returned at all
hasMore = false;
} }
hasMore = events.length >= 20;
} catch (error) { } catch (error) {
console.error('Error loading more:', error); console.error('Error loading more:', error);
} finally { } finally {
@ -168,11 +207,15 @@
{:else} {:else}
<div class="feed-posts"> <div class="feed-posts">
{#each posts as post (post.id)} {#each posts as post (post.id)}
<FeedPost post={post} /> <FeedPost post={post} onOpenEvent={openDrawer} />
{/each} {/each}
</div> </div>
<div id="feed-sentinel" class="feed-sentinel"> {#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
<div id="feed-sentinel" class="feed-sentinel" bind:this={sentinelElement}>
{#if loadingMore} {#if loadingMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p>
{:else if hasMore} {:else if hasMore}

138
src/lib/modules/feed/FeedPost.svelte

@ -19,16 +19,18 @@
onReply?: (post: NostrEvent) => void; onReply?: (post: NostrEvent) => void;
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
previewMode?: boolean; // If true, show only title and first 150 chars of content previewMode?: boolean; // If true, show only title and first 150 chars of content
} }
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, previewMode = false }: Props = $props(); let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
let expanded = $state(false); let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null); let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false); let needsExpansion = $state(false);
let zapCount = $state(0);
// Derive the effective parent event: prefer provided, fall back to loaded // Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -51,8 +53,43 @@
if (!providedParentEvent && !loadedParentEvent && isReply()) { if (!providedParentEvent && !loadedParentEvent && isReply()) {
await loadParentEvent(); await loadParentEvent();
} }
// Load zap receipt count
await loadZapCount();
}); });
async function loadZapCount() {
try {
const config = nostrClient.getConfig();
const threshold = config.zapThreshold;
const filters = [{
kinds: [9735],
'#e': [post.id]
}];
const receipts = await nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
{ useCache: true, cacheResults: true }
);
// Filter by threshold and count
const validReceipts = receipts.filter((receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= threshold;
}
return false;
});
zapCount = validReceipts.length;
} catch (error) {
console.error('Error loading zap count:', error);
zapCount = 0;
}
}
function getRelativeTime(): string { function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const diff = now - post.created_at; const diff = now - post.created_at;
@ -164,9 +201,65 @@
return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : ''); return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : '');
} }
function handlePostClick(e: MouseEvent) {
// Don't open drawer if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.closest('button') ||
target.closest('a') ||
target.closest('.Feed-reaction-buttons') ||
target.closest('.post-actions')
) {
return;
}
// Open drawer if onOpenEvent callback is provided
if (onOpenEvent) {
onOpenEvent(post);
}
}
function handlePostKeydown(e: KeyboardEvent) {
// Only handle Enter and Space keys
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
// Don't open drawer if focus is on interactive elements
const target = e.target as HTMLElement;
if (
target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.closest('button') ||
target.closest('a') ||
target.closest('.Feed-reaction-buttons') ||
target.closest('.post-actions')
) {
return;
}
e.preventDefault();
// Open drawer if onOpenEvent callback is provided
if (onOpenEvent) {
onOpenEvent(post);
}
}
</script> </script>
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}> <article
class="Feed-post"
data-post-id={post.id}
id="event-{post.id}"
data-event-id={post.id}
onclick={handlePostClick}
onkeydown={handlePostKeydown}
class:cursor-pointer={!!onOpenEvent}
{...(onOpenEvent ? { role: "button", tabindex: 0 } : {})}
>
{#if previewMode} {#if previewMode}
<!-- Preview mode: show only title and first 150 chars --> <!-- Preview mode: show only title and first 150 chars -->
<div class="card-content"> <div class="card-content">
@ -199,6 +292,7 @@
parentEventId={getReplyEventId() || undefined} parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined} targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded} onParentLoaded={onParentLoaded}
onOpenEvent={onOpenEvent}
/> />
{/if} {/if}
@ -208,6 +302,7 @@
quotedEventId={getQuotedEventId() || undefined} quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined} targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded} onQuotedLoaded={onQuotedLoaded}
onOpenEvent={onOpenEvent}
/> />
{/if} {/if}
@ -228,6 +323,12 @@
</div> </div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if zapCount > 0}
<span class="zap-count-display">
<span class="zap-emoji"></span>
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
<FeedReactionButtons event={post} /> <FeedReactionButtons event={post} />
{#if onReply} {#if onReply}
<button <button
@ -284,6 +385,27 @@
border-top-color: var(--fog-dark-border, #374151); border-top-color: var(--fog-dark-border, #374151);
} }
.zap-count-display {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .zap-count-display {
color: var(--fog-dark-text-light, #6b7280);
}
.zap-emoji {
filter: grayscale(100%) opacity(0.6);
font-size: 0.875rem;
}
.zap-count-number {
font-weight: 500;
}
.card-content { .card-content {
max-height: 500px; max-height: 500px;
overflow: hidden; overflow: hidden;
@ -333,4 +455,16 @@
position: relative; position: relative;
} }
.Feed-post.cursor-pointer {
cursor: pointer;
}
.Feed-post.cursor-pointer:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .Feed-post.cursor-pointer:hover {
background: var(--fog-dark-highlight, #374151);
}
</style> </style>

332
src/lib/modules/feed/ThreadDrawer.svelte

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import ZapReceiptReply from './ZapReceiptReply.svelte';
import Comment from '../comments/Comment.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -17,21 +16,17 @@
let { opEvent, isOpen, onClose }: Props = $props(); let { opEvent, isOpen, onClose }: Props = $props();
let loading = $state(false); let loading = $state(false);
let threadEvents = $state<NostrEvent[]>([]);
let reactions = $state<NostrEvent[]>([]);
let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event
// Load thread when drawer opens // Load root event when drawer opens
$effect(() => { $effect(() => {
if (isOpen && opEvent) { if (isOpen && opEvent) {
// Hide main page scrollbar when drawer is open // Hide main page scrollbar when drawer is open
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
loadThread(); loadRootEvent();
} else { } else {
// Reset when closed and restore scrollbar // Reset when closed and restore scrollbar
document.body.style.overflow = ''; document.body.style.overflow = '';
threadEvents = [];
reactions = [];
rootEvent = null; rootEvent = null;
} }
@ -101,7 +96,7 @@
return findRootEvent(parent, visited); return findRootEvent(parent, visited);
} }
async function loadThread() { async function loadRootEvent() {
if (!opEvent) return; if (!opEvent) return;
loading = true; loading = true;
@ -111,87 +106,7 @@
// First, find the root OP event // First, find the root OP event
rootEvent = await findRootEvent(opEvent); rootEvent = await findRootEvent(opEvent);
const eventId = rootEvent.id; // Root event is now loaded, CommentThread will handle loading replies
const isThread = rootEvent.kind === 11;
console.log('Loading thread:', { eventId, isThread, rootEventKind: rootEvent.kind });
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments
// For kind 1111 comments: use #E tag for threads (kind 11), #e tag for other events
// Note: Some relays may be case-sensitive, so we query with both uppercase and lowercase
const replyFilters = [
{ kinds: [9735], '#e': [eventId] }, // Zap receipts
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies)
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies
// Kind 1111 comments: use #E for threads, #e for other events
// Query with both uppercase (NIP-22) and lowercase (fallback) tag names
...(isThread
? [
{ kinds: [1111], '#E': [eventId], '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': [eventId] } // Fallback (lowercase) - some clients might use this
]
: [{ kinds: [1111], '#e': [eventId] }]
)
];
console.log('Loading thread:', { eventId, isThread, rootEventKind: rootEvent.kind });
console.log('Reply filters:', JSON.stringify(replyFilters, null, 2));
// Check cache first for speed - only fetch from network if cache is empty
let allReplies = await nostrClient.getByFilters(replyFilters);
// If cache has results, use them immediately
// Only fetch from network if cache is empty or we need fresh data
if (allReplies.length === 0) {
// Cache miss - fetch from network
allReplies = await nostrClient.fetchEvents(
replyFilters,
relays,
{ useCache: true, cacheResults: true }
);
}
// Filter comments to ensure they match the thread (for threads, check #E tag and #K tag)
const filteredReplies = allReplies.filter(reply => {
if (reply.kind === 1111 && isThread) {
// For comments on threads, verify they have the correct tags
const eTag = reply.tags.find(t => t[0] === 'E' || t[0] === 'e');
const kTag = reply.tags.find(t => t[0] === 'K' || t[0] === 'k');
const matchesE = eTag && (eTag[1] === eventId);
const matchesK = kTag && (kTag[1] === '11');
// Accept if it matches with uppercase E tag, or if it matches with lowercase e tag (fallback)
return matchesE && (matchesK || !kTag); // If K tag exists, it must match; if not, just check E
}
return true; // Keep all other reply types
});
console.log('Fetched replies:', allReplies.length, 'Filtered:', filteredReplies.length);
console.log('Reply details:', filteredReplies.map(r => ({
kind: r.kind,
id: r.id.slice(0, 8),
tags: r.tags.filter(t => ['E', 'e', 'K', 'k'].includes(t[0])).map(t => [t[0], t[1]])
})));
// Load reactions (kind 7) for the OP - check cache first
let reactionEvents = await nostrClient.getByFilters([{ kinds: [7], '#e': [eventId] }]);
// Only fetch from network if cache is empty
if (reactionEvents.length === 0) {
reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
}
reactions = reactionEvents;
// Recursively fetch nested replies (this updates threadEvents internally)
await fetchNestedReplies(filteredReplies, relays, eventId, isThread);
console.log('Final threadEvents:', threadEvents.length);
// threadEvents is updated by fetchNestedReplies
} catch (error) { } catch (error) {
console.error('Error loading thread:', error); console.error('Error loading thread:', error);
} finally { } finally {
@ -199,179 +114,6 @@
} }
} }
async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string, isThread: boolean) {
let hasNewReplies = true;
let iterations = 0;
const maxIterations = 10;
const allReplies = new Map<string, NostrEvent>();
// Add initial replies
for (const reply of initialReplies) {
allReplies.set(reply.id, reply);
}
while (hasNewReplies && iterations < maxIterations) {
iterations++;
hasNewReplies = false;
const replyIds = Array.from(allReplies.keys());
if (replyIds.length > 0) {
// Check cache first before making network requests
// This significantly speeds up loading when data is already cached
const nestedFilters = [
{ kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#e': replyIds },
{ kinds: [1], '#e': replyIds },
...(isThread
? [
{ kinds: [1111], '#E': replyIds, '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': replyIds } // Fallback (lowercase)
]
: [{ kinds: [1111], '#e': replyIds }]
)
];
// Check cache first - this is much faster than fetchEvents which may trigger network requests
let nestedReplies = await nostrClient.getByFilters(nestedFilters);
// If cache has results, use them immediately
// Only fetch from network if cache is empty or we need fresh data
if (nestedReplies.length === 0) {
// Cache miss - fetch from network
nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
relays,
{ useCache: true, cacheResults: true }
);
}
// Filter nested comments to ensure they match correctly
const filteredNested = nestedReplies.filter(reply => {
if (reply.kind === 1111 && isThread) {
// For comments on threads, verify they have the correct tags
const eTag = reply.tags.find(t => (t[0] === 'E' || t[0] === 'e') && t[1]);
const kTag = reply.tags.find(t => (t[0] === 'K' || t[0] === 'k') && t[1]);
// Check if this reply references the root thread or one of our reply IDs
const referencedId = eTag?.[1];
const matchesReply = referencedId && (referencedId === rootEventId || replyIds.includes(referencedId));
const matchesK = !kTag || kTag[1] === '11'; // K tag should be '11' or not present
return matchesReply && matchesK;
}
// For non-thread comments or other reply types, check if they reference our replies
if (reply.kind !== 1111) {
const eTag = reply.tags.find(t => t[0] === 'e' && t[1]);
return eTag && replyIds.includes(eTag[1]);
}
return true; // Keep other comment types
});
for (const reply of filteredNested) {
if (!allReplies.has(reply.id)) {
allReplies.set(reply.id, reply);
hasNewReplies = true;
}
}
}
}
threadEvents = Array.from(allReplies.values());
}
function getParentEvent(event: NostrEvent): NostrEvent | undefined {
// Find parent event in thread
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Check if parent is the root OP
if (rootEvent && eTag[1] === rootEvent.id) {
return rootEvent;
}
// Check if parent is another reply
return threadEvents.find((e) => e.id === eTag[1]);
}
return undefined;
}
function sortThreadItems(): Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> {
const items: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
for (const event of threadEvents) {
if (event.kind === 9735) {
items.push({ event, type: 'zap' });
} else if (event.kind === 1244) {
items.push({ event, type: 'yak' });
} else if (event.kind === 1) {
items.push({ event, type: 'reply' });
} else if (event.kind === 1111) {
items.push({ event, type: 'comment' });
}
}
// Build thread structure
const eventMap = new Map<string, { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }>();
const replyMap = new Map<string, string[]>();
const rootItems: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
// First pass: build maps
for (const item of items) {
eventMap.set(item.event.id, item);
}
// Second pass: determine parent-child relationships
for (const item of items) {
const eTag = item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id);
const parentId = eTag?.[1];
if (parentId) {
// Check if parent is root OP or another reply
if (rootEvent && parentId === rootEvent.id) {
// Direct reply to root OP
rootItems.push(item);
} else if (eventMap.has(parentId)) {
// Reply to another reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root
rootItems.push(item);
}
} else {
// No parent tag - treat as root
rootItems.push(item);
}
}
// Third pass: recursively collect in thread order
const result: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
result.push(item);
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of replyItems) {
addThread(reply);
}
}
// Sort root items by created_at
rootItems.sort((a, b) => a.event.created_at - b.event.created_at);
for (const root of rootItems) {
addThread(root);
}
return result;
}
function handleBackdropClick(e: MouseEvent) { function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
@ -422,40 +164,10 @@
</div> </div>
</div> </div>
<!-- Threaded replies --> <!-- Threaded replies using CommentThread -->
{#if threadEvents.length > 0} <div class="replies-section">
<div class="replies-section"> <CommentThread threadId={rootEvent.id} event={rootEvent} />
<h3 class="replies-title">Replies</h3> </div>
<div class="replies-list">
{#each sortThreadItems() as item (item.event.id)}
{@const parentEvent = getParentEvent(item.event)}
{#if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parentEvent || rootEvent}
/>
{:else if item.type === 'yak'}
<!-- Yak back (voice reply) - TODO: create component or use existing -->
<div class="yak-reply">
<p class="text-fog-text-light dark:text-fog-dark-text-light">Voice reply (kind 1244) - TODO: implement component</p>
</div>
{:else if item.type === 'reply'}
<FeedPost
post={item.event}
parentEvent={parentEvent || rootEvent}
/>
{:else if item.type === 'comment'}
<Comment
comment={item.event}
parentEvent={parentEvent || rootEvent}
/>
{/if}
{/each}
</div>
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet.</p>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@ -571,30 +283,4 @@
margin-top: 2rem; margin-top: 2rem;
} }
.replies-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--fog-text, #111827);
}
:global(.dark) .replies-title {
color: var(--fog-dark-text, #f9fafb);
}
.replies-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.yak-reply {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .yak-reply {
background: var(--fog-dark-highlight, #374151);
}
</style> </style>

307
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -5,6 +5,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import emojiNames from 'unicode-emoji-json/data-by-emoji.json';
import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js'; import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js';
interface Props { interface Props {
@ -13,15 +14,14 @@
let { event }: Props = $props(); let { event }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map()); let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map());
let userReaction = $state<string | null>(null); let userReaction = $state<string | null>(null);
let userReactionEventId = $state<string | null>(null); // Track the event ID of user's reaction
let loading = $state(true); let loading = $state(true);
let showMenu = $state(false); let showMenu = $state(false);
let menuButton: HTMLButtonElement | null = $state(null); let menuButton: HTMLButtonElement | null = $state(null);
let menuPosition = $state<'above' | 'below'>('below');
let customEmojiUrls = $state<Map<string, string>>(new Map()); let customEmojiUrls = $state<Map<string, string>>(new Map());
let emojiSearchQuery = $state(''); let emojiSearchQuery = $state('');
let isMobile = $state(false);
let heartCount = $derived(getReactionCount('+')); let heartCount = $derived(getReactionCount('+'));
@ -42,11 +42,23 @@
} }
const query = emojiSearchQuery.toLowerCase().trim(); const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => { return reactionMenu.filter(emoji => {
if (emoji.toLowerCase().includes(query)) return true; // For custom emojis (shortcodes), search the shortcode itself
if (emoji.startsWith(':') && emoji.endsWith(':')) { if (emoji.startsWith(':') && emoji.endsWith(':')) {
return emoji.toLowerCase().includes(query); return emoji.toLowerCase().includes(query);
} }
return false;
// For regular emojis, search by name
const emojiInfo = (emojiNames as Record<string, { name?: string; slug?: string }>)[emoji];
if (emojiInfo) {
const name = emojiInfo.name?.toLowerCase() || '';
const slug = emojiInfo.slug?.toLowerCase() || '';
if (name.includes(query) || slug.includes(query)) {
return true;
}
}
// Fallback: search the emoji character itself (though this rarely matches)
return emoji.toLowerCase().includes(query);
}); });
}); });
@ -54,18 +66,7 @@
nostrClient.initialize().then(() => { nostrClient.initialize().then(() => {
loadReactions(); loadReactions();
}); });
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}); });
function checkMobile() {
isMobile = window.innerWidth < 768;
}
async function loadReactions() { async function loadReactions() {
loading = true; loading = true;
@ -88,18 +89,20 @@
} }
async function processReactions(reactionEvents: NostrEvent[]) { async function processReactions(reactionEvents: NostrEvent[]) {
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>(); const reactionMap = new Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>();
const currentUser = sessionManager.getCurrentPubkey(); const currentUser = sessionManager.getCurrentPubkey();
for (const reactionEvent of reactionEvents) { for (const reactionEvent of reactionEvents) {
const content = reactionEvent.content.trim(); const content = reactionEvent.content.trim();
if (!reactionMap.has(content)) { if (!reactionMap.has(content)) {
reactionMap.set(content, { content, pubkeys: new Set() }); reactionMap.set(content, { content, pubkeys: new Set(), eventIds: new Map() });
} }
reactionMap.get(content)!.pubkeys.add(reactionEvent.pubkey); reactionMap.get(content)!.pubkeys.add(reactionEvent.pubkey);
reactionMap.get(content)!.eventIds.set(reactionEvent.pubkey, reactionEvent.id);
if (currentUser && reactionEvent.pubkey === currentUser) { if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = content; userReaction = content;
userReactionEventId = reactionEvent.id;
} }
} }
@ -115,14 +118,51 @@
} }
if (userReaction === content) { if (userReaction === content) {
userReaction = null; // Remove reaction by publishing a kind 5 deletion event
const reaction = reactions.get(content); if (userReactionEventId) {
if (reaction) { try {
const currentUser = sessionManager.getCurrentPubkey(); const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
if (currentUser) { kind: 5,
reaction.pubkeys.delete(currentUser); pubkey: sessionManager.getCurrentPubkey()!,
if (reaction.pubkeys.size === 0) { created_at: Math.floor(Date.now() / 1000),
reactions.delete(content); tags: [['e', userReactionEventId]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Update local state
userReaction = null;
userReactionEventId = null;
const reaction = reactions.get(content);
if (reaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
reaction.pubkeys.delete(currentUser);
reaction.eventIds.delete(currentUser);
if (reaction.pubkeys.size === 0) {
reactions.delete(content);
}
}
}
} catch (error) {
console.error('Error deleting reaction:', error);
alert('Error deleting reaction');
}
} else {
// Fallback: just update UI if we don't have the event ID
userReaction = null;
userReactionEventId = null;
const reaction = reactions.get(content);
if (reaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
reaction.pubkeys.delete(currentUser);
reaction.eventIds.delete(currentUser);
if (reaction.pubkeys.size === 0) {
reactions.delete(content);
}
} }
} }
} }
@ -149,14 +189,22 @@
}; };
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
await signAndPublish(reactionEvent, [...config.defaultRelays]);
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event
await nostrClient.publish(signedEvent, { relays: [...config.defaultRelays] });
// Update local state with the new reaction
userReaction = content; userReaction = content;
userReactionEventId = signedEvent.id;
const currentPubkey = sessionManager.getCurrentPubkey()!; const currentPubkey = sessionManager.getCurrentPubkey()!;
if (!reactions.has(content)) { if (!reactions.has(content)) {
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) }); reactions.set(content, { content, pubkeys: new Set([currentPubkey]), eventIds: new Map([[currentPubkey, signedEvent.id]]) });
} else { } else {
reactions.get(content)!.pubkeys.add(currentPubkey); reactions.get(content)!.pubkeys.add(currentPubkey);
reactions.get(content)!.eventIds.set(currentPubkey, signedEvent.id);
} }
} catch (error) { } catch (error) {
console.error('Error publishing reaction:', error); console.error('Error publishing reaction:', error);
@ -236,20 +284,8 @@
showMenu = false; showMenu = false;
emojiSearchQuery = ''; emojiSearchQuery = '';
} else { } else {
if (isMobile) { showMenu = true;
menuPosition = 'below'; emojiSearchQuery = '';
showMenu = true;
emojiSearchQuery = '';
} else {
if (menuButton) {
const rect = menuButton.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
menuPosition = spaceBelow > spaceAbove || spaceAbove < 300 ? 'below' : 'above';
}
showMenu = true;
emojiSearchQuery = '';
}
} }
} }
@ -260,6 +296,9 @@
$effect(() => { $effect(() => {
if (showMenu) { if (showMenu) {
// Prevent body scroll when drawer is open
document.body.style.overflow = 'hidden';
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
document.addEventListener('click', closeMenuOnOutsideClick, true); document.addEventListener('click', closeMenuOnOutsideClick, true);
}, 0); }, 0);
@ -267,6 +306,7 @@
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
document.removeEventListener('click', closeMenuOnOutsideClick, true); document.removeEventListener('click', closeMenuOnOutsideClick, true);
document.body.style.overflow = '';
}; };
} }
}); });
@ -287,27 +327,32 @@
</button> </button>
{#if showMenu} {#if showMenu}
{#if isMobile}
<div
class="mobile-drawer-backdrop"
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showMenu = false;
emojiSearchQuery = '';
}
}}
role="button"
tabindex="0"
aria-label="Close emoji menu"
></div>
{/if}
<div <div
class="reaction-menu" class="drawer-backdrop"
class:menu-below={menuPosition === 'below'} onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
class:mobile-drawer={isMobile} onkeydown={(e) => {
> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showMenu = false;
emojiSearchQuery = '';
}
}}
role="button"
tabindex="0"
aria-label="Close emoji menu"
></div>
<div class="reaction-menu drawer-left">
<div class="drawer-header">
<h3 class="drawer-title">Choose Reaction</h3>
<button
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
class="drawer-close"
aria-label="Close emoji menu"
title="Close"
>
×
</button>
</div>
<div class="emoji-search-container"> <div class="emoji-search-container">
<input <input
type="text" type="text"
@ -454,87 +499,114 @@
position: relative; position: relative;
} }
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.reaction-menu { .reaction-menu {
position: absolute; position: fixed;
bottom: 100%; top: 0;
left: 0; left: 0;
margin-bottom: 0.5rem; bottom: 0;
width: min(400px, 80vw);
max-width: 400px;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border: 2px solid var(--fog-border, #cbd5e1); border-right: 2px solid var(--fog-border, #cbd5e1);
border-radius: 0.5rem; box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 4px 6px -1px rgba(0, 0, 0, 0.1); padding: 0;
padding: 0.75rem;
z-index: 1000; z-index: 1000;
min-width: 200px;
max-width: 300px;
max-height: min(60vh, 400px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
} }
.reaction-menu-content { .drawer-header {
overflow-y: auto; display: flex;
overflow-x: hidden; justify-content: space-between;
flex: 1; align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
} }
.reaction-menu.mobile-drawer { :global(.dark) .drawer-header {
position: fixed; border-bottom-color: var(--fog-dark-border, #374151);
bottom: 0; }
left: 0;
right: 0; .drawer-title {
top: auto;
margin: 0; margin: 0;
border-radius: 1rem 1rem 0 0; font-size: 1.125rem;
max-width: 100%; font-weight: 600;
max-height: 70vh; color: var(--fog-text, #1f2937);
min-width: auto;
width: 100%;
box-shadow: 0 -10px 25px -5px rgba(0, 0, 0, 0.2), 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
animation: slideUp 0.3s ease-out;
} }
@keyframes slideUp { :global(.dark) .drawer-title {
from { color: var(--fog-dark-text, #f9fafb);
transform: translateY(100%);
}
to {
transform: translateY(0);
}
} }
.mobile-drawer-backdrop { .drawer-close {
position: fixed; background: transparent;
top: 0; border: none;
left: 0; font-size: 1.5rem;
right: 0; line-height: 1;
bottom: 0; cursor: pointer;
background: rgba(0, 0, 0, 0.5); color: var(--fog-text-light, #9ca3af);
z-index: 999; padding: 0.25rem 0.5rem;
animation: fadeIn 0.3s ease-out; border-radius: 0.25rem;
transition: all 0.2s;
} }
@keyframes fadeIn { .drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from { from {
opacity: 0; transform: translateX(-100%);
} }
to { to {
opacity: 1; transform: translateX(0);
} }
} }
.reaction-menu.menu-below { .reaction-menu-content {
bottom: auto; overflow-y: auto;
top: 100%; overflow-x: hidden;
margin-bottom: 0; flex: 1;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
:global(.dark) .reaction-menu { :global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #475569); border-right-color: var(--fog-dark-border, #475569);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 4px 6px -1px rgba(0, 0, 0, 0.3); box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
} }
.reaction-menu-grid { .reaction-menu-grid {
@ -636,8 +708,9 @@
} }
.emoji-search-container { .emoji-search-container {
margin-bottom: 0.5rem; margin: 0;
padding-bottom: 0.5rem; padding: 1rem;
padding-top: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block; display: block;
width: 100%; width: 100%;

6
src/lib/modules/reactions/ReactionButtons.svelte

@ -201,7 +201,8 @@
<style> <style>
.reaction-buttons { .reaction-buttons {
margin-top: 0.5rem; display: flex;
align-items: center;
} }
.reaction-btn { .reaction-btn {
@ -213,6 +214,9 @@
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5;
display: inline-flex;
align-items: center;
} }
:global(.dark) .reaction-btn { :global(.dark) .reaction-btn {

38
src/lib/modules/threads/ThreadView.svelte

@ -146,10 +146,16 @@
<MarkdownRenderer content={thread.content} /> <MarkdownRenderer content={thread.content} />
</div> </div>
<div class="thread-actions flex items-center gap-4 mb-6"> <div class="thread-actions flex items-center justify-between gap-4 mb-6">
<ReactionButtons event={thread} /> <div class="flex items-center gap-4">
<ZapButton event={thread} /> <ReactionButtons event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} /> <ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div>
</div> </div>
</div> </div>
@ -165,11 +171,6 @@
<div class="comments-section"> <div class="comments-section">
<CommentThread threadId={thread.id} event={thread} /> <CommentThread threadId={thread.id} event={thread} />
</div> </div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div>
</article> </article>
{:else} {:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p> <p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
@ -190,6 +191,11 @@
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
} }
.thread-actions > div {
display: flex;
align-items: center;
}
:global(.dark) .thread-actions { :global(.dark) .thread-actions {
border-top-color: var(--fog-dark-border, #374151); border-top-color: var(--fog-dark-border, #374151);
@ -225,16 +231,14 @@
} }
.kind-badge { .kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.5rem;
font-size: 0.625rem; font-size: 0.75rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
flex-shrink: 0;
} }
:global(.dark) .kind-badge { :global(.dark) .kind-badge {
@ -246,7 +250,7 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
</style> </style>

3
src/lib/modules/zaps/ZapButton.svelte

@ -158,6 +158,9 @@
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5;
display: inline-flex;
align-items: center;
} }
:global(.dark) .zap-button { :global(.dark) .zap-button {

2
src/lib/modules/zaps/ZapReceipt.svelte

@ -122,6 +122,8 @@
<style> <style>
.zap-receipts { .zap-receipts {
display: flex;
align-items: center;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }

4
src/lib/services/nostr/nip30-emoji.ts

@ -113,7 +113,7 @@ export async function resolveEmojiShortcode(
/** /**
* Get all unique pubkeys from reactions to fetch their emoji sets * Get all unique pubkeys from reactions to fetch their emoji sets
*/ */
export function extractPubkeysFromReactions(reactions: Map<string, { content: string; pubkeys: Set<string> }>): string[] { export function extractPubkeysFromReactions(reactions: Map<string, { content: string; pubkeys: Set<string>; eventIds?: Map<string, string> }>): string[] {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
for (const { pubkeys: reactionPubkeys } of reactions.values()) { for (const { pubkeys: reactionPubkeys } of reactions.values()) {
for (const pubkey of reactionPubkeys) { for (const pubkey of reactionPubkeys) {
@ -127,7 +127,7 @@ export function extractPubkeysFromReactions(reactions: Map<string, { content: st
* Resolve all custom emoji shortcodes in reactions to their URLs * Resolve all custom emoji shortcodes in reactions to their URLs
*/ */
export async function resolveCustomEmojis( export async function resolveCustomEmojis(
reactions: Map<string, { content: string; pubkeys: Set<string> }> reactions: Map<string, { content: string; pubkeys: Set<string>; eventIds?: Map<string, string> }>
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
// Extract all pubkeys that have reactions // Extract all pubkeys that have reactions
const pubkeys = extractPubkeysFromReactions(reactions); const pubkeys = extractPubkeysFromReactions(reactions);

8
src/lib/services/security/sanitizer.ts

@ -32,10 +32,12 @@ export function sanitizeHtml(dirty: string): string {
'img', 'img',
'video', 'video',
'audio', 'audio',
'div' 'div',
'span'
], ],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'data-pubkey', 'data-event-id', 'data-placeholder'], ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'data-pubkey', 'data-event-id', 'data-placeholder'],
ALLOW_DATA_ATTR: true ALLOW_DATA_ATTR: true,
KEEP_CONTENT: true
}); });
} }

Loading…
Cancel
Save