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

<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>