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. 87
      src/lib/modules/feed/FeedPage.svelte
  9. 138
      src/lib/modules/feed/FeedPost.svelte
  10. 332
      src/lib/modules/feed/ThreadDrawer.svelte
  11. 305
      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 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-03T14:14:13.893Z",
"buildTime": "2026-02-03T14:56:41.652Z",
"gitCommit": "unknown",
"timestamp": 1770128053893
"timestamp": 1770130601652
}

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

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

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

@ -9,9 +9,10 @@ @@ -9,9 +9,10 @@
quotedEventId?: string; // Optional - used to load quoted event if not provided
targetId?: string; // Optional ID to scroll to (defaults to quoted event ID)
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 loadingQuoted = $state(false);
@ -50,8 +51,6 @@ @@ -50,8 +51,6 @@
if (onQuotedLoaded && typeof onQuotedLoaded === 'function') {
onQuotedLoaded(loadedQuotedEvent);
}
// After loading, try to scroll to it
setTimeout(() => scrollToQuoted(), 100);
}
} catch (error) {
console.error('Error loading quoted event:', error);
@ -69,45 +68,10 @@ @@ -69,45 +68,10 @@
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>
<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"
onclick={scrollToQuoted}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToQuoted();
}
}}
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"
>
<span class="font-semibold">Quoting:</span> {getQuotedPreview()}
{#if loadingQuoted}

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

@ -9,9 +9,10 @@ @@ -9,9 +9,10 @@
parentEventId?: string; // Optional - used to load parent if not provided
targetId?: string; // Optional ID to scroll to (defaults to parent event ID)
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 loadingParent = $state(false);
@ -50,8 +51,6 @@ @@ -50,8 +51,6 @@
if (onParentLoaded && typeof onParentLoaded === 'function') {
onParentLoaded(loadedParentEvent);
}
// After loading, try to scroll to it
setTimeout(() => scrollToParent(), 100);
}
} catch (error) {
console.error('Error loading parent event:', error);
@ -69,45 +68,10 @@ @@ -69,45 +68,10 @@
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>
<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"
onclick={scrollToParent}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToParent();
}
}}
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"
>
<span class="font-semibold">Replying to:</span> {getParentPreview()}
{#if loadingParent}

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

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

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

@ -5,18 +5,45 @@ @@ -5,18 +5,45 @@
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
threadId: string; // The kind 11 thread event ID
parentEvent?: NostrEvent; // If replying to a comment
threadId: string; // The root event ID
rootEvent?: NostrEvent; // The root event (to determine reply kind)
parentEvent?: NostrEvent; // If replying to a comment/reply
onPublished?: () => void;
onCancel?: () => void;
}
let { threadId, parentEvent, onPublished, onCancel }: Props = $props();
let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props();
let content = $state('');
let publishing = $state(false);
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() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to comment');
@ -31,18 +58,40 @@ @@ -31,18 +58,40 @@
publishing = true;
try {
const tags: string[][] = [
['K', '11'], // Kind of the event being commented on
['E', threadId] // Event ID of the thread
];
// If replying to a comment, add parent references
if (parentEvent) {
tags.push(['E', parentEvent.id]); // Parent comment event ID
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
tags.push(['k', '1111']); // Kind of parent (comment)
const replyKind = getReplyKind();
const tags: string[][] = [];
if (replyKind === 1) {
// Kind 1 reply (NIP-10)
tags.push(['e', threadId]); // Root event
if (rootEvent) {
tags.push(['p', rootEvent.pubkey]); // Root author
}
// If replying to a parent, add parent references
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) {
@ -50,7 +99,7 @@ @@ -50,7 +99,7 @@
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 1111,
kind: replyKind,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,

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

@ -9,55 +9,149 @@ @@ -9,55 +9,149 @@
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
threadId: string; // The event ID
event?: NostrEvent; // The event itself (optional, used to determine reply types)
threadId: string; // The event ID of the root event
event?: NostrEvent; // The root event itself (optional, used to determine reply types)
}
let { threadId, event }: Props = $props();
let comments = $state<NostrEvent[]>([]);
let kind1Replies = $state<NostrEvent[]>([]);
let yakBacks = $state<NostrEvent[]>([]);
let zapReceipts = $state<NostrEvent[]>([]);
let comments = $state<NostrEvent[]>([]); // kind 1111
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[]>([]); // kind 1244 (voice replies)
let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts)
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
const isKind1 = $derived(event?.kind === 1);
const rootKind = $derived(event?.kind || null);
onMount(async () => {
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() {
loading = true;
try {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
const replyFilters: any[] = [
{ kinds: [9735], '#e': [threadId] }, // Zap receipts
{ kinds: [1244], '#e': [threadId] }, // Yak backs (voice replies)
];
const replyFilters: any[] = [];
// For kind 1 events, also fetch kind 1 replies
if (isKind1) {
replyFilters.push({ kinds: [1], '#e': [threadId] });
}
// Always fetch kind 1111 comments - check both e and E tags, and a and A tags
replyFilters.push(
{ 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 11 threads, use #E and #K tags (NIP-22)
// For other events, use #e tag
if (event?.kind === 11) {
replyFilters.push(
{ kinds: [1111], '#E': [threadId], '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': [threadId] } // Fallback (lowercase)
);
} else {
replyFilters.push({ kinds: [1111], '#e': [threadId] });
}
// For kind 1 events, fetch kind 1 replies
// Also fetch kind 1 replies for any event (some apps use kind 1 for everything)
replyFilters.push({ kinds: [1], '#e': [threadId] });
// Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [1244], '#e': [threadId] });
// Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [9735], '#e': [threadId] });
const allReplies = await nostrClient.fetchEvents(
replyFilters,
@ -65,17 +159,18 @@ @@ -65,17 +159,18 @@
{ useCache: true, cacheResults: true }
);
// Filter to only replies that reference the root
const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Separate by type
comments = allReplies.filter(e => e.kind === 1111);
kind1Replies = allReplies.filter(e => e.kind === 1);
yakBacks = allReplies.filter(e => e.kind === 1244);
zapReceipts = allReplies.filter(e => e.kind === 9735);
comments = rootReplies.filter(e => e.kind === 1111);
kind1Replies = rootReplies.filter(e => e.kind === 1);
yakBacks = rootReplies.filter(e => e.kind === 1244);
zapReceipts = rootReplies.filter(e => e.kind === 9735);
// Recursively fetch all nested replies
await fetchNestedReplies();
// Fetch zap receipts that reference this thread or any comment/reply
await fetchZapReceipts();
} catch (error) {
console.error('Error loading comments:', error);
} finally {
@ -89,49 +184,43 @@ @@ -89,49 +184,43 @@
const allRelays = [...new Set([...relays, ...feedRelays])];
let hasNewReplies = true;
let iterations = 0;
const maxIterations = 10; // Prevent infinite loops
const maxIterations = 10;
// Keep fetching until we have all nested replies
while (hasNewReplies && iterations < maxIterations) {
iterations++;
hasNewReplies = false;
const allReplyIds = new Set([
...comments.map(c => c.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) {
const nestedFilters: any[] = [
{ kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts
{ kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs
// Fetch nested kind 1111 comments - check both e/E and a/A tags
{ 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(
nestedFilters,
allRelays,
{ useCache: true, cacheResults: true }
);
{ useCache: true, cacheResults: true }
);
// Add new replies by type
for (const reply of nestedReplies) {
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) {
comments.push(reply);
comments.push(reply);
hasNewReplies = true;
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply);
@ -139,114 +228,42 @@ @@ -139,114 +228,42 @@
} else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply);
hasNewReplies = true;
}
}
}
}
}
async function fetchZapReceipts() {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
// Keep fetching until we have all zaps
let previousCount = -1;
while (zapReceipts.length !== previousCount) {
previousCount = zapReceipts.length;
const allEventIds = new Set([
threadId,
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...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);
} else if (reply.kind === 9735 && !zapReceipts.some(z => z.id === reply.id)) {
zapReceipts.push(reply);
hasNewReplies = true;
}
}
// Fetch nested replies to newly found events
await fetchNestedReplies();
}
}
}
/**
* Get parent event from any of our loaded events
*/
function getParentEvent(replyEvent: NostrEvent): NostrEvent | undefined {
const parentId = getParentEventId(replyEvent);
if (!parentId) return undefined;
// Check if parent is the root event
if (parentId === threadId) return event || undefined;
// Find parent in loaded events
return comments.find(c => c.id === parentId)
|| kind1Replies.find(r => r.id === parentId)
|| yakBacks.find(y => y.id === parentId)
|| zapReceipts.find(z => z.id === parentId);
}
/**
* 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' }> {
// Build thread structure similar to feed
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
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) {
eventMap.set(item.event.id, item);
allEventIds.add(item.event.id);
@ -254,24 +271,16 @@ @@ -254,24 +271,16 @@
// Second pass: determine parent-child relationships
for (const item of items) {
// Check if this is a reply - check both uppercase E (NIP-22) and lowercase e tags
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];
const parentId = getParentEventId(item.event);
if (parentId) {
// Check if parent is the thread or another reply we have
if (parentId === threadId || allEventIds.has(parentId)) {
if (parentId && (parentId === threadId || allEventIds.has(parentId))) {
// This is a reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root item (might be a missing parent)
rootItems.push(item);
}
} else {
// No parent tag - this is a root item (direct reply to thread)
// No parent or parent not found - treat as root item
rootItems.push(item);
}
}
@ -291,7 +300,7 @@ @@ -291,7 +300,7 @@
const replyItems = replies
.map(id => eventMap.get(id))
.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) {
addThread(reply);
@ -317,31 +326,31 @@ @@ -317,31 +326,31 @@
return sortThreadItems(items);
}
function getParentEvent(event: NostrEvent): NostrEvent | undefined {
// NIP-22: E tag (uppercase) points to parent event, or lowercase e tag
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 handleReply(replyEvent: NostrEvent) {
replyingTo = replyEvent;
}
function handleCommentPublished() {
replyingTo = null;
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>
<div class="comment-thread">
@ -372,6 +381,7 @@ @@ -372,6 +381,7 @@
<FeedPost post={item.event} />
</div>
{:else if item.type === 'zap'}
<!-- Zap receipt - render with lightning bolt -->
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parent}
@ -386,6 +396,7 @@ @@ -386,6 +396,7 @@
<div class="reply-form-container mt-4">
<CommentForm
threadId={threadId}
rootEvent={event}
parentEvent={replyingTo}
onPublished={handleCommentPublished}
onCancel={() => (replyingTo = null)}
@ -394,7 +405,8 @@ @@ -394,7 +405,8 @@
{:else}
<div class="new-comment-container mt-4">
<CommentForm
{threadId}
threadId={threadId}
rootEvent={event}
onPublished={handleCommentPublished}
/>
</div>

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

@ -2,8 +2,9 @@ @@ -2,8 +2,9 @@
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
let posts = $state<NostrEvent[]>([]);
let loading = $state(true);
@ -11,31 +12,37 @@ @@ -11,31 +12,37 @@
let hasMore = $state(true);
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
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdates: NostrEvent[] = [];
onMount(() => {
(async () => {
await nostrClient.initialize();
await loadFeed();
})();
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
// Set up intersection observer for infinite scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1 });
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
const sentinel = document.getElementById('feed-sentinel');
if (sentinel) {
observer.observe(sentinel);
}
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
onMount(async () => {
await nostrClient.initialize();
await loadFeed();
});
// Cleanup on unmount
$effect(() => {
return () => {
if (sentinel) {
observer.unobserve(sentinel);
if (observer) {
observer.disconnect();
}
if (updateTimeout) {
clearTimeout(updateTimeout);
@ -43,6 +50,26 @@ @@ -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() {
loading = true;
try {
@ -119,9 +146,21 @@ @@ -119,9 +146,21 @@
if (oldest < (oldestTimestamp || Infinity)) {
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) {
console.error('Error loading more:', error);
} finally {
@ -168,11 +207,15 @@ @@ -168,11 +207,15 @@
{:else}
<div class="feed-posts">
{#each posts as post (post.id)}
<FeedPost post={post} />
<FeedPost post={post} onOpenEvent={openDrawer} />
{/each}
</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}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p>
{:else if hasMore}

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

@ -19,16 +19,18 @@ @@ -19,16 +19,18 @@
onReply?: (post: NostrEvent) => void;
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent 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
}
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 loadingParent = $state(false);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
let zapCount = $state(0);
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -51,8 +53,43 @@ @@ -51,8 +53,43 @@
if (!providedParentEvent && !loadedParentEvent && isReply()) {
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 {
const now = Math.floor(Date.now() / 1000);
const diff = now - post.created_at;
@ -164,9 +201,65 @@ @@ -164,9 +201,65 @@
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>
<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}
<!-- Preview mode: show only title and first 150 chars -->
<div class="card-content">
@ -199,6 +292,7 @@ @@ -199,6 +292,7 @@
parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded}
onOpenEvent={onOpenEvent}
/>
{/if}
@ -208,6 +302,7 @@ @@ -208,6 +302,7 @@
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded}
onOpenEvent={onOpenEvent}
/>
{/if}
@ -228,6 +323,12 @@ @@ -228,6 +323,12 @@
</div>
<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} />
{#if onReply}
<button
@ -284,6 +385,27 @@ @@ -284,6 +385,27 @@
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 {
max-height: 500px;
overflow: hidden;
@ -333,4 +455,16 @@ @@ -333,4 +455,16 @@
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>

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

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
<script lang="ts">
import { fade, slide } from 'svelte/transition';
import FeedPost from './FeedPost.svelte';
import ZapReceiptReply from './ZapReceiptReply.svelte';
import Comment from '../comments/Comment.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
@ -17,21 +16,17 @@ @@ -17,21 +16,17 @@
let { opEvent, isOpen, onClose }: Props = $props();
let loading = $state(false);
let threadEvents = $state<NostrEvent[]>([]);
let reactions = $state<NostrEvent[]>([]);
let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event
// Load thread when drawer opens
// Load root event when drawer opens
$effect(() => {
if (isOpen && opEvent) {
// Hide main page scrollbar when drawer is open
document.body.style.overflow = 'hidden';
loadThread();
loadRootEvent();
} else {
// Reset when closed and restore scrollbar
document.body.style.overflow = '';
threadEvents = [];
reactions = [];
rootEvent = null;
}
@ -101,7 +96,7 @@ @@ -101,7 +96,7 @@
return findRootEvent(parent, visited);
}
async function loadThread() {
async function loadRootEvent() {
if (!opEvent) return;
loading = true;
@ -111,87 +106,7 @@ @@ -111,87 +106,7 @@
// First, find the root OP event
rootEvent = await findRootEvent(opEvent);
const eventId = rootEvent.id;
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
// Root event is now loaded, CommentThread will handle loading replies
} catch (error) {
console.error('Error loading thread:', error);
} finally {
@ -199,179 +114,6 @@ @@ -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) {
if (e.target === e.currentTarget) {
@ -422,40 +164,10 @@ @@ -422,40 +164,10 @@
</div>
</div>
<!-- Threaded replies -->
{#if threadEvents.length > 0}
<div class="replies-section">
<h3 class="replies-title">Replies</h3>
<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}
<!-- Threaded replies using CommentThread -->
<div class="replies-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
{/if}
</div>
</div>
@ -571,30 +283,4 @@ @@ -571,30 +283,4 @@
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>

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

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
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';
interface Props {
@ -13,15 +14,14 @@ @@ -13,15 +14,14 @@
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 userReactionEventId = $state<string | null>(null); // Track the event ID of user's reaction
let loading = $state(true);
let showMenu = $state(false);
let menuButton: HTMLButtonElement | null = $state(null);
let menuPosition = $state<'above' | 'below'>('below');
let customEmojiUrls = $state<Map<string, string>>(new Map());
let emojiSearchQuery = $state('');
let isMobile = $state(false);
let heartCount = $derived(getReactionCount('+'));
@ -42,11 +42,23 @@ @@ -42,11 +42,23 @@
}
const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => {
if (emoji.toLowerCase().includes(query)) return true;
// For custom emojis (shortcodes), search the shortcode itself
if (emoji.startsWith(':') && emoji.endsWith(':')) {
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,19 +66,8 @@ @@ -54,19 +66,8 @@
nostrClient.initialize().then(() => {
loadReactions();
});
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
});
function checkMobile() {
isMobile = window.innerWidth < 768;
}
async function loadReactions() {
loading = true;
try {
@ -88,18 +89,20 @@ @@ -88,18 +89,20 @@
}
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();
for (const reactionEvent of reactionEvents) {
const content = reactionEvent.content.trim();
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)!.eventIds.set(reactionEvent.pubkey, reactionEvent.id);
if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = content;
userReactionEventId = reactionEvent.id;
}
}
@ -115,14 +118,51 @@ @@ -115,14 +118,51 @@
}
if (userReaction === content) {
userReaction = null;
const reaction = reactions.get(content);
if (reaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
reaction.pubkeys.delete(currentUser);
if (reaction.pubkeys.size === 0) {
reactions.delete(content);
// Remove reaction by publishing a kind 5 deletion event
if (userReactionEventId) {
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 5,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
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 @@ @@ -149,14 +189,22 @@
};
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;
userReactionEventId = signedEvent.id;
const currentPubkey = sessionManager.getCurrentPubkey()!;
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 {
reactions.get(content)!.pubkeys.add(currentPubkey);
reactions.get(content)!.eventIds.set(currentPubkey, signedEvent.id);
}
} catch (error) {
console.error('Error publishing reaction:', error);
@ -236,20 +284,8 @@ @@ -236,20 +284,8 @@
showMenu = false;
emojiSearchQuery = '';
} else {
if (isMobile) {
menuPosition = 'below';
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 = '';
}
showMenu = true;
emojiSearchQuery = '';
}
}
@ -260,6 +296,9 @@ @@ -260,6 +296,9 @@
$effect(() => {
if (showMenu) {
// Prevent body scroll when drawer is open
document.body.style.overflow = 'hidden';
const timeoutId = setTimeout(() => {
document.addEventListener('click', closeMenuOnOutsideClick, true);
}, 0);
@ -267,6 +306,7 @@ @@ -267,6 +306,7 @@
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', closeMenuOnOutsideClick, true);
document.body.style.overflow = '';
};
}
});
@ -287,27 +327,32 @@ @@ -287,27 +327,32 @@
</button>
{#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
class="reaction-menu"
class:menu-below={menuPosition === 'below'}
class:mobile-drawer={isMobile}
>
class="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>
<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">
<input
type="text"
@ -454,87 +499,114 @@ @@ -454,87 +499,114 @@
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 {
position: absolute;
bottom: 100%;
position: fixed;
top: 0;
left: 0;
margin-bottom: 0.5rem;
bottom: 0;
width: min(400px, 80vw);
max-width: 400px;
background: var(--fog-post, #ffffff);
border: 2px solid var(--fog-border, #cbd5e1);
border-radius: 0.5rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 0.75rem;
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
min-width: 200px;
max-width: 300px;
max-height: min(60vh, 400px);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
}
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
.reaction-menu.mobile-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
border-radius: 1rem 1rem 0 0;
max-width: 100%;
max-height: 70vh;
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;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.mobile-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;
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
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 {
opacity: 0;
transform: translateX(-100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.reaction-menu.menu-below {
bottom: auto;
top: 100%;
margin-bottom: 0;
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
margin-top: 0.5rem;
}
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937);
border-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);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.reaction-menu-grid {
@ -636,8 +708,9 @@ @@ -636,8 +708,9 @@
}
.emoji-search-container {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
margin: 0;
padding: 1rem;
padding-top: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;

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

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

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

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

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

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

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

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

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

@ -113,7 +113,7 @@ export async function resolveEmojiShortcode( @@ -113,7 +113,7 @@ export async function resolveEmojiShortcode(
/**
* 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>();
for (const { pubkeys: reactionPubkeys } of reactions.values()) {
for (const pubkey of reactionPubkeys) {
@ -127,7 +127,7 @@ export function extractPubkeysFromReactions(reactions: Map<string, { content: st @@ -127,7 +127,7 @@ export function extractPubkeysFromReactions(reactions: Map<string, { content: st
* Resolve all custom emoji shortcodes in reactions to their URLs
*/
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>> {
// Extract all pubkeys that have reactions
const pubkeys = extractPubkeysFromReactions(reactions);

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

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

Loading…
Cancel
Save