You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
414 lines
14 KiB
414 lines
14 KiB
<script lang="ts"> |
|
import Comment from './Comment.svelte'; |
|
import CommentForm from './CommentForm.svelte'; |
|
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte'; |
|
import FeedPost from '../feed/FeedPost.svelte'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { onMount } from 'svelte'; |
|
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) |
|
} |
|
|
|
let { threadId, event }: Props = $props(); |
|
|
|
let comments = $state<NostrEvent[]>([]); |
|
let kind1Replies = $state<NostrEvent[]>([]); |
|
let yakBacks = $state<NostrEvent[]>([]); |
|
let zapReceipts = $state<NostrEvent[]>([]); |
|
let loading = $state(true); |
|
let replyingTo = $state<NostrEvent | null>(null); |
|
|
|
const isKind1 = $derived(event?.kind === 1); |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
loadComments(); |
|
}); |
|
|
|
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) |
|
]; |
|
|
|
// For kind 1 events, also fetch kind 1 replies |
|
if (isKind1) { |
|
replyFilters.push({ kinds: [1], '#e': [threadId] }); |
|
} |
|
|
|
// 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] }); |
|
} |
|
|
|
const allReplies = await nostrClient.fetchEvents( |
|
replyFilters, |
|
allRelays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
// 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); |
|
|
|
// 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 { |
|
loading = false; |
|
} |
|
} |
|
|
|
async function fetchNestedReplies() { |
|
const relays = relayManager.getCommentReadRelays(); |
|
const feedRelays = relayManager.getFeedReadRelays(); |
|
const allRelays = [...new Set([...relays, ...feedRelays])]; |
|
let hasNewReplies = true; |
|
let iterations = 0; |
|
const maxIterations = 10; // Prevent infinite loops |
|
|
|
// 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) |
|
]); |
|
|
|
if (allReplyIds.size > 0) { |
|
const nestedFilters: any[] = [ |
|
{ kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts |
|
{ kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs |
|
]; |
|
|
|
// 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 } |
|
); |
|
|
|
// Add new replies by type |
|
for (const reply of nestedReplies) { |
|
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) { |
|
comments.push(reply); |
|
hasNewReplies = true; |
|
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) { |
|
kind1Replies.push(reply); |
|
hasNewReplies = true; |
|
} 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); |
|
} |
|
} |
|
|
|
// Fetch nested replies to newly found events |
|
await fetchNestedReplies(); |
|
} |
|
} |
|
} |
|
|
|
|
|
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 |
|
for (const item of items) { |
|
eventMap.set(item.event.id, item); |
|
allEventIds.add(item.event.id); |
|
} |
|
|
|
// 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]; |
|
|
|
if (parentId) { |
|
// Check if parent is the thread or another reply we have |
|
if (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) |
|
rootItems.push(item); |
|
} |
|
} |
|
|
|
// Third pass: recursively collect all items in thread order |
|
const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = []; |
|
const processed = new Set<string>(); |
|
|
|
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }) { |
|
if (processed.has(item.event.id)) return; |
|
processed.add(item.event.id); |
|
|
|
result.push(item); |
|
|
|
// Add all replies to this item |
|
const replies = replyMap.get(item.event.id) || []; |
|
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 |
|
|
|
for (const reply of replyItems) { |
|
addThread(reply); |
|
} |
|
} |
|
|
|
// Add all root items sorted by time |
|
rootItems.sort((a, b) => a.event.created_at - b.event.created_at); |
|
for (const root of rootItems) { |
|
addThread(root); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> { |
|
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [ |
|
...comments.map(c => ({ event: c, type: 'comment' as const })), |
|
...kind1Replies.map(r => ({ event: r, type: 'reply' as const })), |
|
...yakBacks.map(y => ({ event: y, type: 'yak' as const })), |
|
...zapReceipts.map(z => ({ event: z, type: 'zap' as const })) |
|
]; |
|
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 handleCommentPublished() { |
|
replyingTo = null; |
|
loadComments(); |
|
} |
|
</script> |
|
|
|
<div class="comment-thread"> |
|
<h2 class="text-xl font-bold mb-4">Comments</h2> |
|
|
|
{#if loading} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p> |
|
{:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0 && zapReceipts.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet. Be the first to reply!</p> |
|
{:else} |
|
<div class="comments-list"> |
|
{#each getThreadItems() as item (item.event.id)} |
|
{@const parent = getParentEvent(item.event)} |
|
{#if item.type === 'comment'} |
|
<Comment |
|
comment={item.event} |
|
parentEvent={parent} |
|
onReply={handleReply} |
|
/> |
|
{:else if item.type === 'reply'} |
|
<!-- Kind 1 reply - render as FeedPost --> |
|
<div class="kind1-reply mb-4"> |
|
<FeedPost post={item.event} /> |
|
</div> |
|
{:else if item.type === 'yak'} |
|
<!-- Yak back (kind 1244) - render as FeedPost --> |
|
<div class="yak-back mb-4"> |
|
<FeedPost post={item.event} /> |
|
</div> |
|
{:else if item.type === 'zap'} |
|
<ZapReceiptReply |
|
zapReceipt={item.event} |
|
parentEvent={parent} |
|
onReply={handleReply} |
|
/> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
{#if replyingTo} |
|
<div class="reply-form-container mt-4"> |
|
<CommentForm |
|
threadId={threadId} |
|
parentEvent={replyingTo} |
|
onPublished={handleCommentPublished} |
|
onCancel={() => (replyingTo = null)} |
|
/> |
|
</div> |
|
{:else} |
|
<div class="new-comment-container mt-4"> |
|
<CommentForm |
|
{threadId} |
|
onPublished={handleCommentPublished} |
|
/> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.comment-thread { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
padding: 1rem; |
|
} |
|
|
|
.comments-list { |
|
margin-bottom: 2rem; |
|
} |
|
</style>
|
|
|