diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index 56fa787..dc6753a 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -9,6 +9,7 @@ import { onMount } from "svelte"; import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getEventKindName } from "$lib/utils/eventColors"; + import { getDisplayNameSync, replacePubkeysWithDisplayNames } from "$lib/utils/profileCache"; // Component props let { @@ -45,7 +46,11 @@ if (node.event) { const authorTags = getMatchingTags(node.event, "author"); if (authorTags.length > 0) { - return authorTags[0][1]; + return getDisplayNameSync(authorTags[0][1]); + } + // Fallback to event pubkey + if (node.event.pubkey) { + return getDisplayNameSync(node.event.pubkey); } } return "Unknown"; @@ -231,7 +236,7 @@ Tags: {node.event.tags.length} {#if node.event.tags.length <= 3} {#each node.event.tags as tag} - · {tag[0]}{tag[1] ? `: ${tag[1].substring(0, 20)}${tag[1].length > 20 ? '...' : ''}` : ''} + · {tag[0]}{tag[1] ? `: ${tag[0] === 'p' ? getDisplayNameSync(tag[1]) : tag[1].substring(0, 20)}${tag[1].length > 20 && tag[0] !== 'p' ? '...' : ''}` : ''} {/each} {/if} diff --git a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts index 236f702..9a534a4 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -10,6 +10,7 @@ import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import { nip19 } from "nostr-tools"; import { standardRelays } from "$lib/consts"; import { getMatchingTags } from '$lib/utils/nostrUtils'; +import { getDisplayNameSync } from '$lib/utils/profileCache'; // Configuration const DEBUG = false; // Set to true to enable debug logging @@ -42,7 +43,7 @@ export function createNetworkNode( debug("Creating network node", { eventId: event.id, kind: event.kind, level }); const isContainer = event.kind === INDEX_EVENT_KIND; - const nodeType = isContainer ? "Index" : "Content"; + const nodeType = isContainer ? "Index" : event.kind === CONTENT_EVENT_KIND || event.kind === 30818 ? "Content" : `Kind ${event.kind}`; // Create the base node with essential properties const node: NetworkNode = { @@ -52,8 +53,8 @@ export function createNetworkNode( level, title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", content: event.content || "", - author: event.pubkey || "", - kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined + author: event.pubkey ? getDisplayNameSync(event.pubkey) : "", + kind: event.kind !== undefined ? event.kind : CONTENT_EVENT_KIND, // Default to content event kind only if truly undefined type: nodeType, }; @@ -307,23 +308,30 @@ export function generateGraph( // Initialize the graph state const state = initializeGraphState(events); - // Find root index events (those not referenced by other events) - const rootIndices = events.filter( - (e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) + // Find root events (index events not referenced by others, and all non-publication events) + const publicationKinds = [30040, 30041, 30818]; + const rootEvents = events.filter( + (e) => e.id && ( + // Index events not referenced by others + (e.kind === INDEX_EVENT_KIND && !state.referencedIds.has(e.id)) || + // All non-publication events are treated as roots + (e.kind !== undefined && !publicationKinds.includes(e.kind)) + ) ); - debug("Found root indices", { - rootCount: rootIndices.length, - rootIds: rootIndices.map(e => e.id) + debug("Found root events", { + rootCount: rootEvents.length, + rootIds: rootEvents.map(e => e.id) }); - // Process each root index - rootIndices.forEach((rootIndex) => { - debug("Processing root index", { - rootId: rootIndex.id, - aTags: getMatchingTags(rootIndex, "a").length + // Process each root event + rootEvents.forEach((rootEvent) => { + debug("Processing root event", { + rootId: rootEvent.id, + kind: rootEvent.kind, + aTags: getMatchingTags(rootEvent, "a").length }); - processIndexEvent(rootIndex, 0, state, maxLevel); + processIndexEvent(rootEvent, 0, state, maxLevel); }); // Create the final graph data diff --git a/src/lib/utils/profileCache.ts b/src/lib/utils/profileCache.ts new file mode 100644 index 0000000..5d73abd --- /dev/null +++ b/src/lib/utils/profileCache.ts @@ -0,0 +1,218 @@ +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import { ndkInstance } from "$lib/ndk"; +import { get } from "svelte/store"; + +interface ProfileData { + display_name?: string; + name?: string; + picture?: string; + about?: string; +} + +// Cache for user profiles +const profileCache = new Map(); + +/** + * Fetches profile data for a pubkey + * @param pubkey - The public key to fetch profile for + * @returns Profile data or null if not found + */ +async function fetchProfile(pubkey: string): Promise { + try { + const ndk = get(ndkInstance); + const profileEvents = await ndk.fetchEvents({ + kinds: [0], + authors: [pubkey], + limit: 1 + }); + + if (profileEvents.size === 0) { + return null; + } + + // Get the most recent profile event + const profileEvent = Array.from(profileEvents)[0]; + + try { + const content = JSON.parse(profileEvent.content); + return content as ProfileData; + } catch (e) { + console.error("Failed to parse profile content:", e); + return null; + } + } catch (e) { + console.error("Failed to fetch profile:", e); + return null; + } +} + +/** + * Gets the display name for a pubkey, using cache + * @param pubkey - The public key to get display name for + * @returns Display name, name, or shortened pubkey + */ +export async function getDisplayName(pubkey: string): Promise { + // Check cache first + if (profileCache.has(pubkey)) { + const profile = profileCache.get(pubkey)!; + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + + // Fetch profile + const profile = await fetchProfile(pubkey); + if (profile) { + profileCache.set(pubkey, profile); + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + + // Fallback to shortened pubkey + return shortenPubkey(pubkey); +} + +/** + * Batch fetches profiles for multiple pubkeys + * @param pubkeys - Array of public keys to fetch profiles for + */ +export async function batchFetchProfiles(pubkeys: string[]): Promise { + // Filter out already cached pubkeys + const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk)); + + if (uncachedPubkeys.length === 0) { + return; + } + + try { + const ndk = get(ndkInstance); + const profileEvents = await ndk.fetchEvents({ + kinds: [0], + authors: uncachedPubkeys + }); + + // Process each profile event + profileEvents.forEach((event: NDKEvent) => { + try { + const content = JSON.parse(event.content); + profileCache.set(event.pubkey, content as ProfileData); + } catch (e) { + console.error("Failed to parse profile content:", e); + } + }); + } catch (e) { + console.error("Failed to batch fetch profiles:", e); + } +} + +/** + * Gets display name synchronously from cache + * @param pubkey - The public key to get display name for + * @returns Display name, name, or shortened pubkey + */ +export function getDisplayNameSync(pubkey: string): string { + if (profileCache.has(pubkey)) { + const profile = profileCache.get(pubkey)!; + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + return shortenPubkey(pubkey); +} + +/** + * Shortens a pubkey for display + * @param pubkey - The public key to shorten + * @returns Shortened pubkey (first 8 chars...last 4 chars) + */ +function shortenPubkey(pubkey: string): string { + if (pubkey.length <= 12) return pubkey; + return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; +} + +/** + * Clears the profile cache + */ +export function clearProfileCache(): void { + profileCache.clear(); +} + +/** + * Extracts all pubkeys from events (authors and p tags) + * @param events - Array of events to extract pubkeys from + * @returns Set of unique pubkeys + */ +export function extractPubkeysFromEvents(events: NDKEvent[]): Set { + const pubkeys = new Set(); + + events.forEach(event => { + // Add author pubkey + if (event.pubkey) { + pubkeys.add(event.pubkey); + } + + // Add pubkeys from p tags + const pTags = event.getMatchingTags("p"); + pTags.forEach(tag => { + if (tag[1]) { + pubkeys.add(tag[1]); + } + }); + + // Extract pubkeys from content (nostr:npub1... format) + const npubPattern = /nostr:npub1[a-z0-9]{58}/g; + const matches = event.content?.match(npubPattern) || []; + matches.forEach(match => { + try { + const npub = match.replace('nostr:', ''); + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + pubkeys.add(decoded.data as string); + } + } catch (e) { + // Invalid npub, ignore + } + }); + }); + + return pubkeys; +} + +/** + * Replaces pubkeys in content with display names + * @param content - The content to process + * @returns Content with pubkeys replaced by display names + */ +export function replaceContentPubkeys(content: string): string { + if (!content) return content; + + // Replace nostr:npub1... references + const npubPattern = /nostr:npub1[a-z0-9]{58}/g; + let result = content; + + const matches = content.match(npubPattern) || []; + matches.forEach(match => { + try { + const npub = match.replace('nostr:', ''); + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + const pubkey = decoded.data as string; + const displayName = getDisplayNameSync(pubkey); + result = result.replace(match, `@${displayName}`); + } + } catch (e) { + // Invalid npub, leave as is + } + }); + + return result; +} + +/** + * Replaces pubkey references in text with display names + * @param text - Text that may contain pubkey references + * @returns Text with pubkeys replaced by display names + */ +export function replacePubkeysWithDisplayNames(text: string): string { + // Match hex pubkeys (64 characters) + const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g; + + return text.replace(pubkeyRegex, (match) => { + return getDisplayNameSync(match); + }); +} \ No newline at end of file diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 1a75cfb..8a08e14 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -16,6 +16,8 @@ import { visualizationConfig } from "$lib/stores/visualizationConfig"; import { filterByDisplayLimits, detectMissingEvents } from "$lib/utils/displayLimits"; import type { PageData } from './$types'; + import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; + import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache"; // Configuration const DEBUG = false; // Set to true to enable debug logging @@ -43,6 +45,8 @@ let tagExpansionDepth = $state(0); let baseEvents = $state([]); // Store original events before expansion let missingEventIds = $state(new Set()); // Track missing referenced events + let loadingEventKinds = $state>([]); // Track what kinds are being loaded + let isFetching = false; // Guard against concurrent fetches /** * Fetches events from the Nostr network @@ -51,21 +55,69 @@ * filters them according to NIP-62, and combines them for visualization. */ async function fetchEvents() { + // Prevent concurrent fetches + if (isFetching) { + debug("Fetch already in progress, skipping"); + return; + } + debug("Fetching events with limit:", $networkFetchLimit); debug("Event ID from URL:", data.eventId); + try { + isFetching = true; loading = true; error = null; - let validIndexEvents: Set; - - // Check if index events (30040) are enabled - // Use get() to read store value in non-reactive context + // Get enabled event configurations const config = get(visualizationConfig); - const enabledKinds = config.allowedKinds.filter( - kind => !config.disabledKinds.includes(kind) + const enabledConfigs = config.eventConfigs.filter( + ec => !(config.disabledKinds?.includes(ec.kind)) ); - const shouldFetchIndex = enabledKinds.includes(INDEX_EVENT_KIND); + + debug("Enabled event configs:", enabledConfigs); + + // Set loading event kinds for display + loadingEventKinds = enabledConfigs.map(ec => ({ + kind: ec.kind, + limit: ec.limit + })); + + // Separate publication kinds from other kinds + const publicationKinds = [30040, 30041, 30818]; + const publicationConfigs = enabledConfigs.filter(ec => publicationKinds.includes(ec.kind)); + const otherConfigs = enabledConfigs.filter(ec => !publicationKinds.includes(ec.kind)); + + let allFetchedEvents: NDKEvent[] = []; + + // First, fetch non-publication events (like kind 0, 1, etc.) + if (otherConfigs.length > 0) { + debug("Fetching non-publication events:", otherConfigs); + + for (const config of otherConfigs) { + try { + const fetchedEvents = await $ndkInstance.fetchEvents( + { + kinds: [config.kind], + limit: config.limit + }, + { + groupable: true, + skipVerification: false, + skipValidation: false, + } + ); + debug(`Fetched ${fetchedEvents.size} events of kind ${config.kind}`); + allFetchedEvents.push(...Array.from(fetchedEvents)); + } catch (e) { + console.error(`Error fetching kind ${config.kind}:`, e); + } + } + } + + // Then handle publication events as before + let validIndexEvents: Set = new Set(); + const shouldFetchIndex = publicationConfigs.some(ec => ec.kind === INDEX_EVENT_KIND); if (data.eventId) { // Fetch specific publication @@ -87,10 +139,13 @@ } else { // Original behavior: fetch all publications debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`); + const indexConfig = publicationConfigs.find(ec => ec.kind === INDEX_EVENT_KIND); + const indexLimit = indexConfig?.limit || 20; + const indexEvents = await $ndkInstance.fetchEvents( { kinds: [INDEX_EVENT_KIND], - limit: $networkFetchLimit + limit: indexLimit }, { groupable: true, @@ -133,7 +188,8 @@ // Step 4: Fetch the referenced content events with author filter // Only fetch content kinds that are enabled - const enabledContentKinds = CONTENT_EVENT_KINDS.filter(kind => enabledKinds.includes(kind)); + const enabledPublicationKinds = publicationConfigs.map(ec => ec.kind); + const enabledContentKinds = CONTENT_EVENT_KINDS.filter(kind => enabledPublicationKinds.includes(kind)); debug(`Fetching content events (enabled kinds: ${enabledContentKinds.join(', ')})`); // Group by author to make more efficient queries @@ -189,10 +245,14 @@ const contentEvents = new Set(eventsByCoordinate.values()); debug("Fetched content events after deduplication:", contentEvents.size); - // Step 5: Combine both sets of events with coordinate-based deduplication + // Step 5: Combine all events (non-publication + publication events) // First, build coordinate map for replaceable events const coordinateMap = new Map(); - const allEventsToProcess = [...Array.from(validIndexEvents), ...Array.from(contentEvents)]; + const allEventsToProcess = [ + ...allFetchedEvents, // Non-publication events fetched earlier + ...Array.from(validIndexEvents), + ...Array.from(contentEvents) + ]; // First pass: identify the most recent version of each replaceable event allEventsToProcess.forEach(event => { @@ -249,10 +309,16 @@ allEvents = Array.from(finalEventMap.values()); baseEvents = [...allEvents]; // Store base events for tag expansion - // Step 6: Apply display limits + // Step 6: Fetch profiles for all pubkeys in events + debug("Fetching profiles for pubkeys in events"); + const pubkeys = extractPubkeysFromEvents(allEvents); + await batchFetchProfiles(Array.from(pubkeys)); + debug("Profile fetch complete for", pubkeys.size, "pubkeys"); + + // Step 7: Apply display limits events = filterByDisplayLimits(allEvents, $displayLimits, $visualizationConfig); - // Step 7: Detect missing events + // Step 8: Detect missing events const eventIds = new Set(allEvents.map(e => e.id)); missingEventIds = detectMissingEvents(events, eventIds); @@ -267,6 +333,7 @@ error = e instanceof Error ? e.message : String(e); } finally { loading = false; + isFetching = false; debug("Loading set to false in fetchEvents"); debug("Final state check - loading:", loading, "events.length:", events.length, "allEvents.length:", allEvents.length); } @@ -455,6 +522,13 @@ allEvents = Array.from(finalEventMap.values()); + // Fetch profiles for new events + const newPubkeys = extractPubkeysFromEvents([...newPublications, ...newContentEvents]); + if (newPubkeys.size > 0) { + debug("Fetching profiles for", newPubkeys.size, "new pubkeys from tag expansion"); + await batchFetchProfiles(Array.from(newPubkeys)); + } + // Apply display limits events = filterByDisplayLimits(allEvents, $displayLimits); @@ -508,8 +582,16 @@ if (fetchedEvents.size > 0) { debug(`Fetched ${fetchedEvents.size} missing events`); + // Fetch profiles for the new events + const newEvents = Array.from(fetchedEvents); + const newPubkeys = extractPubkeysFromEvents(newEvents); + if (newPubkeys.size > 0) { + debug("Fetching profiles for", newPubkeys.size, "pubkeys from missing events"); + await batchFetchProfiles(Array.from(newPubkeys)); + } + // Add to all events - allEvents = [...allEvents, ...Array.from(fetchedEvents)]; + allEvents = [...allEvents, ...newEvents]; // Re-apply display limits events = filterByDisplayLimits(allEvents, $displayLimits); @@ -548,6 +630,37 @@ } } }); + + // TEMPORARILY DISABLED: Track previous disabled kinds without using $state to avoid infinite loops + // let previousDisabledKinds: number[] = []; + // let hasInitialized = false; + + // $effect(() => { + // const currentDisabledKinds = $visualizationConfig.disabledKinds || []; + + // // Initialize on first run + // if (!hasInitialized) { + // previousDisabledKinds = [...currentDisabledKinds]; + // hasInitialized = true; + // return; + // } + + // // Check if any kinds were re-enabled (were in previous but not in current) + // const reEnabledKinds = previousDisabledKinds.filter( + // kind => !currentDisabledKinds.includes(kind) + // ); + + // if (reEnabledKinds.length > 0) { + // debug("Event kinds re-enabled:", reEnabledKinds); + // // Update tracking before fetch to prevent re-trigger + // previousDisabledKinds = [...currentDisabledKinds]; + // // Trigger a fresh fetch to include the newly enabled kinds + // fetchEvents(); + // } else { + // // Just update tracking + // previousDisabledKinds = [...currentDisabledKinds]; + // } + // }); // Fetch events when component mounts onMount(() => { @@ -565,7 +678,7 @@ {#if loading} -
+
{debug("TEMPLATE: Loading is true, events.length =", events.length, "allEvents.length =", allEvents.length)}
Loading...
+ + +
+

Loading

+
+ {#each loadingEventKinds as config} +
+ + + {getEventKindName(config.kind)}: {config.limit} + +
+ {/each} +
+
{:else if error}