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.
928 lines
36 KiB
928 lines
36 KiB
<script lang="ts"> |
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; |
|
import { getNdkContext } from "$lib/ndk"; |
|
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
|
import { ChevronDownOutline, ChevronRightOutline, DotsVerticalOutline, TrashBinOutline, ClipboardCleanOutline, EyeOutline } from "flowbite-svelte-icons"; |
|
import { nip19 } from "nostr-tools"; |
|
import { Button, Popover, Modal, Textarea, P } from "flowbite-svelte"; |
|
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion"; |
|
import { userStore } from "$lib/stores/userStore"; |
|
import { goto } from "$app/navigation"; |
|
|
|
let { |
|
sectionAddress, |
|
comments = [], |
|
visible = true, |
|
}: { |
|
sectionAddress: string; |
|
comments: NDKEvent[]; |
|
visible?: boolean; |
|
} = $props(); |
|
|
|
const ndk = getNdkContext(); |
|
|
|
// State management |
|
let profiles = $state(new Map<string, any>()); |
|
let expandedThreads = $state(new Set<string>()); |
|
let detailsModalOpen = $state<string | null>(null); |
|
let deletingComments = $state(new Set<string>()); |
|
let replyingTo = $state<string | null>(null); |
|
let replyContent = $state(""); |
|
let isSubmittingReply = $state(false); |
|
let replyError = $state<string | null>(null); |
|
let replySuccess = $state<string | null>(null); |
|
|
|
// Subscribe to userStore |
|
let user = $derived($userStore); |
|
|
|
/** |
|
* Parse comment threading structure |
|
* Root comments have no 'e' tag with 'reply' marker |
|
*/ |
|
function buildThreadStructure(allComments: NDKEvent[]) { |
|
const rootComments: NDKEvent[] = []; |
|
const repliesByParent = new Map<string, NDKEvent[]>(); |
|
|
|
for (const comment of allComments) { |
|
// Check if this is a reply by looking for 'e' tags with 'reply' marker |
|
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply'); |
|
|
|
if (replyTag) { |
|
const parentId = replyTag[1]; |
|
if (!repliesByParent.has(parentId)) { |
|
repliesByParent.set(parentId, []); |
|
} |
|
repliesByParent.get(parentId)!.push(comment); |
|
} else { |
|
// This is a root comment (no reply tag) |
|
rootComments.push(comment); |
|
} |
|
} |
|
|
|
return { rootComments, repliesByParent }; |
|
} |
|
|
|
let threadStructure = $derived(buildThreadStructure(comments)); |
|
|
|
/** |
|
* Count replies for a comment thread |
|
*/ |
|
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number { |
|
const directReplies = repliesMap.get(commentId) || []; |
|
let count = directReplies.length; |
|
|
|
// Recursively count nested replies |
|
for (const reply of directReplies) { |
|
count += countReplies(reply.id, repliesMap); |
|
} |
|
|
|
return count; |
|
} |
|
|
|
/** |
|
* Get display name for a pubkey |
|
*/ |
|
function getDisplayName(pubkey: string): string { |
|
const profile = profiles.get(pubkey); |
|
if (profile) { |
|
return profile.displayName || profile.name || profile.pubkey || pubkey; |
|
} |
|
const npub = toNpub(pubkey) || pubkey; |
|
return `${npub.slice(0, 12)}...`; |
|
} |
|
|
|
/** |
|
* Format timestamp |
|
*/ |
|
function formatTimestamp(timestamp: number): string { |
|
const date = new Date(timestamp * 1000); |
|
const now = new Date(); |
|
const diffMs = now.getTime() - date.getTime(); |
|
const diffMins = Math.floor(diffMs / 60000); |
|
const diffHours = Math.floor(diffMs / 3600000); |
|
const diffDays = Math.floor(diffMs / 86400000); |
|
|
|
if (diffMins < 60) { |
|
return `${diffMins}m ago`; |
|
} else if (diffHours < 24) { |
|
return `${diffHours}h ago`; |
|
} else if (diffDays < 7) { |
|
return `${diffDays}d ago`; |
|
} else { |
|
return date.toLocaleDateString(); |
|
} |
|
} |
|
|
|
/** |
|
* Fetch profile for a pubkey |
|
*/ |
|
async function fetchProfile(pubkey: string) { |
|
if (profiles.has(pubkey)) return; |
|
|
|
try { |
|
const npub = toNpub(pubkey); |
|
if (!npub) { |
|
setFallbackProfile(pubkey); |
|
return; |
|
} |
|
|
|
const profile = await getUserMetadata(npub, ndk, true); |
|
const newProfiles = new Map(profiles); |
|
newProfiles.set(pubkey, profile); |
|
profiles = newProfiles; |
|
} catch (err) { |
|
setFallbackProfile(pubkey); |
|
} |
|
} |
|
|
|
function setFallbackProfile(pubkey: string) { |
|
const npub = toNpub(pubkey) || pubkey; |
|
const truncated = `${npub.slice(0, 12)}...`; |
|
const fallbackProfile = { |
|
name: truncated, |
|
displayName: truncated, |
|
picture: null |
|
}; |
|
const newProfiles = new Map(profiles); |
|
newProfiles.set(pubkey, fallbackProfile); |
|
profiles = newProfiles; |
|
} |
|
|
|
/** |
|
* Toggle thread expansion |
|
*/ |
|
function toggleThread(commentId: string) { |
|
const newExpanded = new Set(expandedThreads); |
|
if (newExpanded.has(commentId)) { |
|
newExpanded.delete(commentId); |
|
} else { |
|
newExpanded.add(commentId); |
|
} |
|
expandedThreads = newExpanded; |
|
} |
|
|
|
/** |
|
* Render nested replies recursively |
|
*/ |
|
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) { |
|
const replies = repliesMap.get(parentId) || []; |
|
return replies; |
|
} |
|
|
|
/** |
|
* Copy nevent to clipboard |
|
*/ |
|
async function copyNevent(event: NDKEvent) { |
|
try { |
|
const nevent = nip19.neventEncode({ |
|
id: event.id, |
|
author: event.pubkey, |
|
kind: event.kind, |
|
}); |
|
await navigator.clipboard.writeText(nevent); |
|
console.log('Copied nevent to clipboard:', nevent); |
|
} catch (err) { |
|
console.error('Failed to copy nevent:', err); |
|
} |
|
} |
|
|
|
/** |
|
* Navigate to event details page |
|
*/ |
|
function viewEventDetails(comment: NDKEvent) { |
|
const nevent = nip19.neventEncode({ |
|
id: comment.id, |
|
author: comment.pubkey, |
|
kind: comment.kind, |
|
}); |
|
goto(`/events?id=${encodeURIComponent(nevent)}`); |
|
} |
|
|
|
/** |
|
* Check if user can delete a comment |
|
*/ |
|
function canDelete(comment: NDKEvent): boolean { |
|
return canDeleteEvent(comment, ndk); |
|
} |
|
|
|
/** |
|
* Submit a reply to a comment |
|
*/ |
|
async function submitReply(parentComment: NDKEvent) { |
|
if (!replyContent.trim()) { |
|
replyError = "Reply cannot be empty"; |
|
return; |
|
} |
|
|
|
if (!user.signedIn || !user.signer) { |
|
replyError = "You must be signed in to reply"; |
|
return; |
|
} |
|
|
|
isSubmittingReply = true; |
|
replyError = null; |
|
replySuccess = null; |
|
|
|
try { |
|
const { NDKEvent: NDKEventClass } = await import("@nostr-dev-kit/ndk"); |
|
const { activeOutboxRelays } = await import("$lib/ndk"); |
|
|
|
// Get relay hint |
|
const relays = activeOutboxRelays; |
|
let relayHint = ""; |
|
relays.subscribe((r) => { relayHint = r[0] || ""; })(); |
|
|
|
// Create reply event (kind 1111) |
|
const replyEvent = new NDKEventClass(ndk); |
|
replyEvent.kind = 1111; |
|
replyEvent.content = replyContent; |
|
|
|
// Parse section address to get root event details |
|
const rootParts = sectionAddress.split(":"); |
|
if (rootParts.length !== 3) { |
|
throw new Error("Invalid section address format"); |
|
} |
|
const [rootKindStr, rootAuthorPubkey, rootDTag] = rootParts; |
|
const rootKind = parseInt(rootKindStr); |
|
|
|
// NIP-22 reply tags structure: |
|
// - Root tags (A, K, P) point to the section/article |
|
// - Parent tags (a, k, p) point to the parent comment |
|
// - Add 'e' tag with 'reply' marker for the parent comment |
|
replyEvent.tags = [ |
|
// Root scope - uppercase tags (point to section) |
|
["A", sectionAddress, relayHint, rootAuthorPubkey], |
|
["K", rootKind.toString()], |
|
["P", rootAuthorPubkey, relayHint], |
|
|
|
// Parent scope - lowercase tags (point to parent comment) |
|
["a", `1111:${parentComment.pubkey}:`, relayHint], |
|
["k", "1111"], |
|
["p", parentComment.pubkey, relayHint], |
|
|
|
// Reply marker |
|
["e", parentComment.id, relayHint, "reply"], |
|
]; |
|
|
|
console.log("[SectionComments] Creating reply with tags:", replyEvent.tags); |
|
|
|
// Sign and publish |
|
await replyEvent.sign(); |
|
await replyEvent.publish(); |
|
|
|
console.log("[SectionComments] Reply published:", replyEvent.id); |
|
|
|
replySuccess = parentComment.id; |
|
replyContent = ""; |
|
|
|
// Close reply UI after a delay |
|
setTimeout(() => { |
|
replyingTo = null; |
|
replySuccess = null; |
|
}, 2000); |
|
|
|
} catch (err) { |
|
console.error("[SectionComments] Error submitting reply:", err); |
|
replyError = err instanceof Error ? err.message : "Failed to submit reply"; |
|
} finally { |
|
isSubmittingReply = false; |
|
} |
|
} |
|
|
|
/** |
|
* Delete a comment |
|
*/ |
|
async function handleDeleteComment(comment: NDKEvent) { |
|
if (!canDelete(comment)) return; |
|
|
|
if (!confirm('Are you sure you want to delete this comment?')) { |
|
return; |
|
} |
|
|
|
const newDeleting = new Set(deletingComments); |
|
newDeleting.add(comment.id); |
|
deletingComments = newDeleting; |
|
|
|
try { |
|
const result = await deleteEvent({ |
|
eventId: comment.id, |
|
eventKind: comment.kind, |
|
reason: 'User deleted comment', |
|
}, ndk); |
|
|
|
if (result.success) { |
|
console.log('[SectionComments] Comment deleted successfully'); |
|
// Note: The comment will still show in the UI until the page is refreshed |
|
// or the parent component refetches comments |
|
} else { |
|
alert(`Failed to delete comment: ${result.error}`); |
|
} |
|
} catch (err) { |
|
console.error('[SectionComments] Error deleting comment:', err); |
|
alert('Failed to delete comment'); |
|
} finally { |
|
const newDeleting = new Set(deletingComments); |
|
newDeleting.delete(comment.id); |
|
deletingComments = newDeleting; |
|
} |
|
} |
|
|
|
/** |
|
* Pre-fetch profiles for all comment authors |
|
*/ |
|
$effect(() => { |
|
const uniquePubkeys = new Set(comments.map(c => c.pubkey)); |
|
for (const pubkey of uniquePubkeys) { |
|
fetchProfile(pubkey); |
|
} |
|
}); |
|
</script> |
|
|
|
{#if visible && threadStructure.rootComments.length > 0} |
|
<div class="space-y-1"> |
|
{#each threadStructure.rootComments as rootComment (rootComment.id)} |
|
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)} |
|
{@const isExpanded = expandedThreads.has(rootComment.id)} |
|
|
|
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm"> |
|
<!-- Multi-row collapsed view --> |
|
{#if !isExpanded} |
|
<div class="flex gap-2 px-3 py-2 text-sm"> |
|
<button |
|
class="flex-shrink-0 mt-1" |
|
onclick={() => toggleThread(rootComment.id)} |
|
aria-label="Expand comment" |
|
> |
|
<ChevronRightOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
|
|
<div class="flex-1 min-w-0"> |
|
<p class="line-clamp-3 text-gray-700 dark:text-gray-300 mb-1"> |
|
{rootComment.content} |
|
</p> |
|
<div class="flex items-center gap-2 text-xs"> |
|
<button |
|
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" |
|
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }} |
|
title="Copy nevent to clipboard" |
|
> |
|
{getDisplayName(rootComment.pubkey)} |
|
</button> |
|
{#if replyCount > 0} |
|
<span class="text-gray-400 dark:text-gray-500">•</span> |
|
<span class="text-blue-600 dark:text-blue-400"> |
|
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} |
|
</span> |
|
{/if} |
|
<span class="text-gray-400 dark:text-gray-500">•</span> |
|
<button |
|
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
replyingTo = replyingTo === rootComment.id ? null : rootComment.id; |
|
replyError = null; |
|
replySuccess = null; |
|
// Auto-expand when replying from collapsed view |
|
if (!expandedThreads.has(rootComment.id)) { |
|
toggleThread(rootComment.id); |
|
} |
|
}} |
|
> |
|
Reply |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<!-- Actions menu in collapsed view --> |
|
<div class="flex-shrink-0 mt-1"> |
|
<button |
|
id="comment-actions-collapsed-{rootComment.id}" |
|
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors" |
|
aria-label="Comment actions" |
|
onclick={(e) => { e.stopPropagation(); }} |
|
> |
|
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
<Popover |
|
triggeredBy="#comment-actions-collapsed-{rootComment.id}" |
|
placement="bottom-end" |
|
class="w-48 text-sm" |
|
> |
|
<ul class="space-y-1"> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={() => { |
|
detailsModalOpen = rootComment.id; |
|
}} |
|
> |
|
<EyeOutline class="w-4 h-4" /> |
|
View details |
|
</button> |
|
</li> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={async () => { |
|
await copyNevent(rootComment); |
|
}} |
|
> |
|
<ClipboardCleanOutline class="w-4 h-4" /> |
|
Copy nevent |
|
</button> |
|
</li> |
|
{#if canDelete(rootComment)} |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400" |
|
onclick={() => { |
|
handleDeleteComment(rootComment); |
|
}} |
|
disabled={deletingComments.has(rootComment.id)} |
|
> |
|
<TrashBinOutline class="w-4 h-4" /> |
|
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'} |
|
</button> |
|
</li> |
|
{/if} |
|
</ul> |
|
</Popover> |
|
</div> |
|
</div> |
|
{:else} |
|
<!-- Expanded view --> |
|
<div class="flex flex-col"> |
|
<!-- Expanded header row --> |
|
<div class="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-200 dark:border-gray-700"> |
|
<button |
|
class="flex-shrink-0" |
|
onclick={() => toggleThread(rootComment.id)} |
|
aria-label="Collapse comment" |
|
> |
|
<ChevronDownOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
|
|
<button |
|
class="flex-shrink-0 font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
|
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }} |
|
title="Copy nevent to clipboard" |
|
> |
|
{getDisplayName(rootComment.pubkey)} |
|
</button> |
|
|
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
|
{formatTimestamp(rootComment.created_at || 0)} |
|
</span> |
|
|
|
{#if replyCount > 0} |
|
<span class="text-xs text-blue-600 dark:text-blue-400"> |
|
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} |
|
</span> |
|
{/if} |
|
|
|
<!-- Actions menu --> |
|
<div class="ml-auto"> |
|
<button |
|
id="comment-actions-{rootComment.id}" |
|
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors" |
|
aria-label="Comment actions" |
|
> |
|
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
<Popover |
|
triggeredBy="#comment-actions-{rootComment.id}" |
|
placement="bottom-end" |
|
class="w-48 text-sm" |
|
> |
|
<ul class="space-y-1"> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={() => { |
|
detailsModalOpen = rootComment.id; |
|
}} |
|
> |
|
<EyeOutline class="w-4 h-4" /> |
|
View details |
|
</button> |
|
</li> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={async () => { |
|
await copyNevent(rootComment); |
|
}} |
|
> |
|
<ClipboardCleanOutline class="w-4 h-4" /> |
|
Copy nevent |
|
</button> |
|
</li> |
|
{#if canDelete(rootComment)} |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400" |
|
onclick={() => { |
|
handleDeleteComment(rootComment); |
|
}} |
|
disabled={deletingComments.has(rootComment.id)} |
|
> |
|
<TrashBinOutline class="w-4 h-4" /> |
|
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'} |
|
</button> |
|
</li> |
|
{/if} |
|
</ul> |
|
</Popover> |
|
</div> |
|
</div> |
|
|
|
<!-- Full content --> |
|
<div class="px-3 py-3"> |
|
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-3"> |
|
{@render basicMarkup(rootComment.content)} |
|
</div> |
|
|
|
<!-- Reply button --> |
|
<div class="mb-3"> |
|
<Button |
|
size="xs" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = replyingTo === rootComment.id ? null : rootComment.id; |
|
replyError = null; |
|
replySuccess = null; |
|
}} |
|
> |
|
{replyingTo === rootComment.id ? 'Cancel Reply' : 'Reply'} |
|
</Button> |
|
</div> |
|
|
|
<!-- Reply UI --> |
|
{#if replyingTo === rootComment.id} |
|
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-700"> |
|
<Textarea |
|
bind:value={replyContent} |
|
placeholder="Write your reply..." |
|
rows={3} |
|
disabled={isSubmittingReply} |
|
class="mb-2" |
|
/> |
|
|
|
{#if replyError} |
|
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P> |
|
{/if} |
|
|
|
{#if replySuccess === rootComment.id} |
|
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P> |
|
{/if} |
|
|
|
<div class="flex gap-2"> |
|
<Button |
|
size="sm" |
|
onclick={() => submitReply(rootComment)} |
|
disabled={isSubmittingReply || !replyContent.trim()} |
|
> |
|
{isSubmittingReply ? 'Posting...' : 'Post Reply'} |
|
</Button> |
|
<Button |
|
size="sm" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = null; |
|
replyContent = ""; |
|
replyError = null; |
|
}} |
|
> |
|
Cancel |
|
</Button> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Replies --> |
|
{#if replyCount > 0} |
|
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2"> |
|
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)} |
|
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3"> |
|
<div class="flex items-center gap-2 mb-2"> |
|
<button |
|
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
|
onclick={(e) => { e.stopPropagation(); copyNevent(reply); }} |
|
title="Copy nevent to clipboard" |
|
> |
|
{getDisplayName(reply.pubkey)} |
|
</button> |
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
|
{formatTimestamp(reply.created_at || 0)} |
|
</span> |
|
|
|
<!-- Three-dot menu for reply --> |
|
<div class="ml-auto flex items-center gap-2"> |
|
<button |
|
id="reply-actions-{reply.id}" |
|
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors" |
|
aria-label="Reply actions" |
|
onclick={(e) => { e.stopPropagation(); }} |
|
> |
|
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
<Popover |
|
triggeredBy="#reply-actions-{reply.id}" |
|
placement="bottom-end" |
|
class="w-48 text-sm" |
|
> |
|
<ul class="space-y-1"> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={() => { |
|
detailsModalOpen = reply.id; |
|
}} |
|
> |
|
<EyeOutline class="w-4 h-4" /> |
|
View details |
|
</button> |
|
</li> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={async () => { |
|
await copyNevent(reply); |
|
}} |
|
> |
|
<ClipboardCleanOutline class="w-4 h-4" /> |
|
Copy nevent |
|
</button> |
|
</li> |
|
{#if canDelete(reply)} |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400" |
|
onclick={() => { |
|
handleDeleteComment(reply); |
|
}} |
|
disabled={deletingComments.has(reply.id)} |
|
> |
|
<TrashBinOutline class="w-4 h-4" /> |
|
{deletingComments.has(reply.id) ? 'Deleting...' : 'Delete comment'} |
|
</button> |
|
</li> |
|
{/if} |
|
</ul> |
|
</Popover> |
|
</div> |
|
</div> |
|
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-2"> |
|
{@render basicMarkup(reply.content)} |
|
</div> |
|
|
|
<!-- Reply button for first-level reply --> |
|
<div class="mb-2"> |
|
<Button |
|
size="xs" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = replyingTo === reply.id ? null : reply.id; |
|
replyError = null; |
|
replySuccess = null; |
|
}} |
|
> |
|
{replyingTo === reply.id ? 'Cancel Reply' : 'Reply'} |
|
</Button> |
|
</div> |
|
|
|
<!-- Reply UI for first-level reply --> |
|
{#if replyingTo === reply.id} |
|
<div class="mb-3 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800"> |
|
<Textarea |
|
bind:value={replyContent} |
|
placeholder="Write your reply..." |
|
rows={3} |
|
disabled={isSubmittingReply} |
|
class="mb-2" |
|
/> |
|
|
|
{#if replyError} |
|
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P> |
|
{/if} |
|
|
|
{#if replySuccess === reply.id} |
|
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P> |
|
{/if} |
|
|
|
<div class="flex gap-2"> |
|
<Button |
|
size="sm" |
|
onclick={() => submitReply(reply)} |
|
disabled={isSubmittingReply || !replyContent.trim()} |
|
> |
|
{isSubmittingReply ? 'Posting...' : 'Post Reply'} |
|
</Button> |
|
<Button |
|
size="sm" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = null; |
|
replyContent = ""; |
|
replyError = null; |
|
}} |
|
> |
|
Cancel |
|
</Button> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Nested replies (one level deep) --> |
|
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)} |
|
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2"> |
|
<div class="flex items-center gap-2 mb-1"> |
|
<button |
|
class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
|
onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }} |
|
title="Copy nevent to clipboard" |
|
> |
|
{getDisplayName(nestedReply.pubkey)} |
|
</button> |
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
|
{formatTimestamp(nestedReply.created_at || 0)} |
|
</span> |
|
|
|
<!-- Three-dot menu for nested reply --> |
|
<div class="ml-auto flex items-center gap-2"> |
|
<button |
|
id="nested-reply-actions-{nestedReply.id}" |
|
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors" |
|
aria-label="Nested reply actions" |
|
onclick={(e) => { e.stopPropagation(); }} |
|
> |
|
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
|
</button> |
|
<Popover |
|
triggeredBy="#nested-reply-actions-{nestedReply.id}" |
|
placement="bottom-end" |
|
class="w-48 text-sm" |
|
> |
|
<ul class="space-y-1"> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={() => { |
|
detailsModalOpen = nestedReply.id; |
|
}} |
|
> |
|
<EyeOutline class="w-4 h-4" /> |
|
View details |
|
</button> |
|
</li> |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" |
|
onclick={async () => { |
|
await copyNevent(nestedReply); |
|
}} |
|
> |
|
<ClipboardCleanOutline class="w-4 h-4" /> |
|
Copy nevent |
|
</button> |
|
</li> |
|
{#if canDelete(nestedReply)} |
|
<li> |
|
<button |
|
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400" |
|
onclick={() => { |
|
handleDeleteComment(nestedReply); |
|
}} |
|
disabled={deletingComments.has(nestedReply.id)} |
|
> |
|
<TrashBinOutline class="w-4 h-4" /> |
|
{deletingComments.has(nestedReply.id) ? 'Deleting...' : 'Delete comment'} |
|
</button> |
|
</li> |
|
{/if} |
|
</ul> |
|
</Popover> |
|
</div> |
|
</div> |
|
<div class="text-xs text-gray-700 dark:text-gray-300 mb-2"> |
|
{@render basicMarkup(nestedReply.content)} |
|
</div> |
|
|
|
<!-- Reply button for nested reply --> |
|
<div class="mb-1"> |
|
<Button |
|
size="xs" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = replyingTo === nestedReply.id ? null : nestedReply.id; |
|
replyError = null; |
|
replySuccess = null; |
|
}} |
|
> |
|
{replyingTo === nestedReply.id ? 'Cancel Reply' : 'Reply'} |
|
</Button> |
|
</div> |
|
|
|
<!-- Reply UI for nested reply --> |
|
{#if replyingTo === nestedReply.id} |
|
<div class="mb-2 border border-gray-300 dark:border-gray-600 rounded-lg p-2 bg-white dark:bg-gray-800"> |
|
<Textarea |
|
bind:value={replyContent} |
|
placeholder="Write your reply..." |
|
rows={2} |
|
disabled={isSubmittingReply} |
|
class="mb-2 text-xs" |
|
/> |
|
|
|
{#if replyError} |
|
<P class="text-red-600 dark:text-red-400 text-xs mb-1">{replyError}</P> |
|
{/if} |
|
|
|
{#if replySuccess === nestedReply.id} |
|
<P class="text-green-600 dark:text-green-400 text-xs mb-1">Reply posted successfully!</P> |
|
{/if} |
|
|
|
<div class="flex gap-2"> |
|
<Button |
|
size="xs" |
|
onclick={() => submitReply(nestedReply)} |
|
disabled={isSubmittingReply || !replyContent.trim()} |
|
> |
|
{isSubmittingReply ? 'Posting...' : 'Post Reply'} |
|
</Button> |
|
<Button |
|
size="xs" |
|
color="light" |
|
onclick={() => { |
|
replyingTo = null; |
|
replyContent = ""; |
|
replyError = null; |
|
}} |
|
> |
|
Cancel |
|
</Button> |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
<!-- Details Modal --> |
|
{#if detailsModalOpen} |
|
{@const comment = comments.find(c => c.id === detailsModalOpen)} |
|
{#if comment} |
|
<Modal |
|
title="Comment Details" |
|
open={true} |
|
autoclose |
|
outsideclose |
|
size="lg" |
|
class="modal-leather" |
|
onclose={() => detailsModalOpen = null} |
|
> |
|
<div class="space-y-4"> |
|
<div class="flex justify-center pb-2"> |
|
<Button |
|
color="primary" |
|
onclick={() => { |
|
viewEventDetails(comment); |
|
}} |
|
> |
|
View on Event Page |
|
</Button> |
|
</div> |
|
<div> |
|
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto max-h-[500px] overflow-y-auto">{JSON.stringify({ |
|
id: comment.id, |
|
pubkey: comment.pubkey, |
|
created_at: comment.created_at, |
|
kind: comment.kind, |
|
tags: comment.tags, |
|
content: comment.content, |
|
sig: comment.sig |
|
}, null, 2)}</pre> |
|
</div> |
|
</div> |
|
</Modal> |
|
{/if} |
|
{/if} |
|
|
|
<style> |
|
/* Ensure proper text wrapping */ |
|
.prose { |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
} |
|
</style> |