Browse Source

Add user profile display names and fix infinite loading loop

- Created profileCache utility to fetch and cache kind 0 (Metadata) events
- Replace pubkeys with display names throughout the visualization:
  - Node tooltips show author display names instead of pubkeys
  - p tags in tooltips show display names
  - Network nodes store display names in author field
- Fetch user profiles when loading events for better UX
- Fixed infinite loading loop by:
  - Adding isFetching guard to prevent concurrent fetchEvents calls
  - Temporarily disabling the re-enabled kinds watcher that was causing loops
- Extract pubkeys from event content (nostr:npub1... format) for profile fetching

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 9 months ago
parent
commit
844ae3d35a
  1. 9
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  2. 38
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  3. 218
      src/lib/utils/profileCache.ts
  4. 161
      src/routes/visualize/+page.svelte

9
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -9,6 +9,7 @@ @@ -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 @@ @@ -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 @@ @@ -231,7 +236,7 @@
Tags: {node.event.tags.length}
{#if node.event.tags.length <= 3}
{#each node.event.tags as tag}
<span class="text-xs">· {tag[0]}{tag[1] ? `: ${tag[1].substring(0, 20)}${tag[1].length > 20 ? '...' : ''}` : ''}</span>
<span class="text-xs">· {tag[0]}{tag[1] ? `: ${tag[0] === 'p' ? getDisplayNameSync(tag[1]) : tag[1].substring(0, 20)}${tag[1].length > 20 && tag[0] !== 'p' ? '...' : ''}` : ''}</span>
{/each}
{/if}
</div>

38
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -10,6 +10,7 @@ import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; @@ -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( @@ -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( @@ -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( @@ -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

218
src/lib/utils/profileCache.ts

@ -0,0 +1,218 @@ @@ -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<string, ProfileData>();
/**
* 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<ProfileData | null> {
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<string> {
// 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<void> {
// 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<string> {
const pubkeys = new Set<string>();
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);
});
}

161
src/routes/visualize/+page.svelte

@ -16,6 +16,8 @@ @@ -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 @@ @@ -43,6 +45,8 @@
let tagExpansionDepth = $state(0);
let baseEvents = $state<NDKEvent[]>([]); // Store original events before expansion
let missingEventIds = $state(new Set<string>()); // Track missing referenced events
let loadingEventKinds = $state<Array<{kind: number, limit: number}>>([]); // Track what kinds are being loaded
let isFetching = false; // Guard against concurrent fetches
/**
* Fetches events from the Nostr network
@ -51,21 +55,69 @@ @@ -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<NDKEvent>;
// 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))
);
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,
}
);
const shouldFetchIndex = enabledKinds.includes(INDEX_EVENT_KIND);
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<NDKEvent> = new Set();
const shouldFetchIndex = publicationConfigs.some(ec => ec.kind === INDEX_EVENT_KIND);
if (data.eventId) {
// Fetch specific publication
@ -87,10 +139,13 @@ @@ -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 @@ @@ -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 @@ @@ -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<string, NDKEvent>();
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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);
@ -549,6 +631,37 @@ @@ -549,6 +631,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(() => {
debug("Component mounted");
@ -565,7 +678,7 @@ @@ -565,7 +678,7 @@
</div>
<!-- Loading spinner -->
{#if loading}
<div class="flex justify-center items-center h-64">
<div class="flex flex-col justify-center items-center h-64 gap-4">
{debug("TEMPLATE: Loading is true, events.length =", events.length, "allEvents.length =", allEvents.length)}
<div role="status">
<svg
@ -586,6 +699,24 @@ @@ -586,6 +699,24 @@
</svg>
<span class="sr-only">Loading...</span>
</div>
<!-- Loading message with event kinds -->
<div class="text-center">
<p class="text-gray-600 dark:text-gray-400 mb-2">Loading</p>
<div class="flex flex-wrap justify-center gap-2 max-w-md">
{#each loadingEventKinds as config}
<div class="flex items-center gap-1 px-2 py-1 rounded bg-gray-100 dark:bg-gray-800">
<span
class="w-3 h-3 rounded-full inline-block"
style="background-color: {getEventKindColor(config.kind)};"
></span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{getEventKindName(config.kind)}: {config.limit}
</span>
</div>
{/each}
</div>
</div>
</div>
<!-- Error message -->
{:else if error}

Loading…
Cancel
Save