clone of repo on github
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.
 
 
 
 

280 lines
8.7 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 } from "flowbite-svelte-icons";
let {
comments = [],
sectionTitles = new Map<string, string>(),
}: {
comments: NDKEvent[];
sectionTitles?: Map<string, string>;
} = $props();
const ndk = getNdkContext();
// State management
let profiles = $state(new Map<string, any>());
let expandedSections = $state(new Set<string>());
/**
* Group comments by their target event address
* Extracts the target from #a or #e tags
*/
let groupedComments = $derived.by(() => {
const groups = new Map<string, NDKEvent[]>();
for (const comment of comments) {
// Look for #a tag first (addressable events - preferred)
const aTag = comment.tags.find(t => t[0] === "a");
if (aTag && aTag[1]) {
const address = aTag[1];
if (!groups.has(address)) {
groups.set(address, []);
}
groups.get(address)!.push(comment);
continue;
}
// Fallback to #e tag (event ID)
const eTag = comment.tags.find(t => t[0] === "e");
if (eTag && eTag[1]) {
const eventId = eTag[1];
if (!groups.has(eventId)) {
groups.set(eventId, []);
}
groups.get(eventId)!.push(comment);
}
}
console.log(`[CommentPanel] Grouped ${comments.length} comments into ${groups.size} sections`);
return groups;
});
/**
* Get a display label for a target address/id
* Uses provided section titles, or falls back to address/id
*/
function getTargetLabel(target: string): string {
// Check if we have a title for this address
if (sectionTitles.has(target)) {
return sectionTitles.get(target)!;
}
// Parse address format: kind:pubkey:d-tag
const parts = target.split(":");
if (parts.length === 3) {
const [kind, _pubkey, dTag] = parts;
if (kind === "30040") {
return "Comments on Collection";
}
if (kind === "30041" && dTag) {
return `Section: ${dTag}`;
}
}
// Fallback to truncated address/id
return target.length > 20 ? `${target.substring(0, 20)}...` : target;
}
/**
* 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;
console.log(`[CommentPanel] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`[CommentPanel] Failed to fetch profile for ${pubkey}:`, err);
setFallbackProfile(pubkey);
}
}
/**
* Set fallback profile using truncated npub
*/
function setFallbackProfile(pubkey: string) {
const npub = toNpub(pubkey) || pubkey;
const truncated = `${npub.slice(0, 12)}...${npub.slice(-4)}`;
const fallbackProfile = {
name: truncated,
displayName: truncated,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
/**
* 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;
}
// Return truncated npub while loading
const npub = toNpub(pubkey) || pubkey;
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
/**
* Toggle section expansion
*/
function toggleSection(target: string) {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(target)) {
newExpanded.delete(target);
} else {
newExpanded.add(target);
}
expandedSections = newExpanded;
}
/**
* 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();
}
}
/**
* Pre-fetch all profiles when comments change
*/
$effect(() => {
const uniquePubkeys = new Set(comments.map(c => c.pubkey));
console.log(`[CommentPanel] Pre-fetching ${uniquePubkeys.size} profiles`);
for (const pubkey of uniquePubkeys) {
fetchProfile(pubkey);
}
});
</script>
{#if comments.length > 0}
<div class="fixed right-4 top-20 bottom-4 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col z-40">
<!-- Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Comments ({comments.length})
</h3>
</div>
<!-- Comment groups -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{#each Array.from(groupedComments.entries()) as [target, targetComments]}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Section header -->
<button
class="w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onclick={() => toggleSection(target)}
>
<div class="flex items-center gap-2">
{#if expandedSections.has(target)}
<ChevronDownOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{:else}
<ChevronRightOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{getTargetLabel(target)}
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{targetComments.length} {targetComments.length === 1 ? 'comment' : 'comments'}
</span>
</button>
<!-- Comment list -->
{#if expandedSections.has(target)}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{#each targetComments as comment (comment.id)}
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<!-- Comment header -->
<div class="flex items-start gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{getDisplayName(comment.pubkey)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(comment.created_at || 0)}
</span>
</div>
</div>
</div>
<!-- Comment content -->
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none">
{@render basicMarkup(comment.content)}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if groupedComments.size === 0 && comments.length > 0}
<div class="text-center py-8">
<p class="text-sm text-gray-500 dark:text-gray-400">
Comments loaded but couldn't determine their targets
</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
/* Custom scrollbar for comment panel */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>