Browse Source

Merges pull request #39

Visualization improvements
master
silberengel 8 months ago
parent
commit
5a52e21d2a
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 124
      doc/settings_panel.org
  2. 543
      package-lock.json
  3. 14
      src/app.css
  4. 204
      src/lib/components/EventKindFilter.svelte
  5. 52
      src/lib/components/EventLimitControl.svelte
  6. 274
      src/lib/components/EventTypeConfig.svelte
  7. 17
      src/lib/components/util/ArticleNav.svelte
  8. 8
      src/lib/consts.ts
  9. 459
      src/lib/navigator/EventNetwork/Legend.svelte
  10. 112
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  11. 132
      src/lib/navigator/EventNetwork/Settings.svelte
  12. 82
      src/lib/navigator/EventNetwork/TagTable.svelte
  13. 940
      src/lib/navigator/EventNetwork/index.svelte
  14. 14
      src/lib/navigator/EventNetwork/types.ts
  15. 41
      src/lib/navigator/EventNetwork/utils/common.ts
  16. 92
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  17. 106
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  18. 337
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  19. 308
      src/lib/navigator/EventNetwork/utils/starForceSimulation.ts
  20. 353
      src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts
  21. 314
      src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
  22. 2
      src/lib/state.ts
  23. 2
      src/lib/stores/index.ts
  24. 182
      src/lib/stores/visualizationConfig.ts
  25. 18
      src/lib/types.ts
  26. 142
      src/lib/utils/displayLimits.ts
  27. 82
      src/lib/utils/eventColors.ts
  28. 214
      src/lib/utils/eventDeduplication.ts
  29. 98
      src/lib/utils/event_kind_utils.ts
  30. 3
      src/lib/utils/event_search.ts
  31. 88
      src/lib/utils/nostr_identifiers.ts
  32. 252
      src/lib/utils/profileCache.ts
  33. 3
      src/lib/utils/search_types.ts
  34. 206
      src/lib/utils/tag_event_fetch.ts
  35. 774
      src/routes/visualize/+page.svelte
  36. 9
      src/routes/visualize/+page.ts
  37. 66
      src/styles/visualize.css
  38. 20
      tests/e2e/example.pw.spec.ts
  39. 115
      tests/integration/markupIntegration.test.ts
  40. 267
      tests/integration/markupTestfile.md
  41. 131
      tests/unit/advancedMarkupParser.test.ts
  42. 92
      tests/unit/basicMarkupParser.test.ts
  43. 106
      tests/unit/nostr_identifiers.test.ts
  44. 457
      tests/unit/relayDeduplication.test.ts
  45. 420
      tests/unit/tagExpansion.test.ts

124
doc/settings_panel.org

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
* Settings Panel Documentation
** Overview
The settings panel controls how events are fetched and displayed in the visualization. It has several sections that work together to create an efficient and user-friendly experience.
** Event Types Configuration
*** Purpose
Controls which types of Nostr events are fetched and how many of each type.
*** Key Event Types
- *Kind 30040* (Index Events): Publication indices
- *Kind 30041* (Content Events): Publication content
- *Kind 30818* (Content Events): Alternative content format
- *Kind 30023* (Content Events): Alternative content format
*** How Limits Work
Each event kind has a limit number that controls different things:
**** For Kind 0 (Profiles)
- Limit controls how many profiles to fetch from discovered pubkeys
- These profiles are used for:
- Displaying names instead of pubkeys
- Showing profile pictures in tooltips
- When "People" tag anchors are selected, this limit controls how many people anchors to display
**** For Kind 3 (Follow Lists)
- =limit = 1=: Only fetch the current user's follow list
- =limit > 1=: Fetch the user's follow list PLUS (limit-1) follow lists from people they follow
- The depth selector controls traversal:
- =Direct= (0): Just the immediate follows
- =2 degrees= (1): Follows of follows
- =3 degrees= (2): Three levels deep
**** For Kind 30040/30041/30818
- Limit controls maximum number of these events to fetch
** Tag Anchors
*** What Are Tag Anchors?
Tag anchors are special nodes in the graph that act as gravity points for events sharing common attributes. They help organize the visualization by grouping related content.
*** Tag Types Available
- *Hashtags* (t): Groups events by hashtag
- *Authors*: Groups events by author
- *People* (p): Shows people from follow lists as anchor points
- *Event References* (e): Groups events that reference each other
- *Titles*: Groups events by title
- *Summaries*: Groups events by summary
*** How People Tag Anchors Work
When "People" is selected as the tag type:
1. The system looks at all loaded follow lists (kind 3 events)
2. Extracts all pubkeys (people) from those follow lists
3. Creates tag anchors for those people (up to the kind 0 limit)
4. Connects each person anchor to:
- Events they authored (where pubkey matches)
- Events where they're mentioned in "p" tags
*** Display Limiting and Auto-Disable
- Tag anchors are created for ALL discovered tags
- But only displayed up to the configured limit
- When > 20 tag anchors exist, they're all auto-disabled
- Users can selectively enable specific anchors
- The legend becomes scrollable for many anchors
*** "Only show people with publications" Checkbox
When checked (default):
- Only shows people who have events in the current visualization
When unchecked:
- Shows ALL people from follow lists, even if they have no events displayed
- Useful for seeing your complete social graph
** Display Limits Section
*** Max Publication Indices (30040)
Controls display filtering for publication indices after they're fetched.
*** Max Events per Index
Limits how many content events to show per publication index.
*** Fetch if not found
When enabled, automatically fetches missing referenced events.
** Graph Traversal Section
*** Search through already fetched
When enabled, tag expansion only searches through events already loaded (more efficient).
*** Append mode
When enabled, new fetches add to the existing graph instead of replacing it.
** Current Implementation Questions
1. *Profile Fetching*: Should we fetch profiles for:
- Only event authors?
- All pubkeys in follow lists?
- All pubkeys mentioned anywhere?
2. *People Tag Anchors*: Should they connect to:
- Only events where the person is tagged with "p"?
- Events they authored?
- Both?
3. *Display Limits*: Should limits control:
- How many to fetch from relays?
- How many to display (fetch all, display subset)?
- Both with separate controls?
4. *Auto-disable Threshold*: Is 20 the right number for auto-disabling tag anchors?
** Ideal User Flow
1. User loads the visualization
2. Their follow list is fetched (kind 3, limit 1)
3. Profiles are fetched for people they follow (kind 0, respecting limit)
4. Publications are fetched (kind 30040/30041/30818)
5. User enables "People" tag anchors
6. Sees their follows as anchor points
7. Can see which follows have authored content
8. Can selectively enable/disable specific people
9. Can increase limits to see more content/people

543
package-lock.json generated

File diff suppressed because it is too large Load Diff

14
src/app.css

@ -201,6 +201,20 @@ @@ -201,6 +201,20 @@
.network-node-content {
@apply fill-primary-100;
}
/* Person link colors */
.person-link-signed {
@apply stroke-green-500;
}
.person-link-referenced {
@apply stroke-blue-400;
}
/* Person anchor node */
.person-anchor-node {
@apply fill-green-400 stroke-green-600;
}
}
/* Utilities can be applied via the @apply directive. */

204
src/lib/components/EventKindFilter.svelte

@ -0,0 +1,204 @@ @@ -0,0 +1,204 @@
<script lang="ts">
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig';
import { Button, Badge } from 'flowbite-svelte';
import { CloseCircleOutline } from 'flowbite-svelte-icons';
import type { EventCounts } from "$lib/types";
import { NostrKind } from '$lib/types';
let {
onReload = () => {},
eventCounts = {}
} = $props<{
onReload?: () => void;
eventCounts?: EventCounts;
}>();
let newKind = $state('');
let showAddInput = $state(false);
let inputError = $state('');
function validateKind(value: string): number | null {
if (!value || value.trim() === '') {
inputError = '';
return null;
}
const kind = parseInt(value.trim());
if (isNaN(kind)) {
inputError = 'Must be a number';
return null;
}
if (kind < 0) {
inputError = 'Must be positive';
return null;
}
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) {
inputError = 'Already added';
return null;
}
inputError = '';
return kind;
}
function handleAddKind() {
const kind = validateKind(newKind);
if (kind != null) {
visualizationConfig.addEventKind(kind);
newKind = '';
showAddInput = false;
inputError = '';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleAddKind();
} else if (e.key === 'Escape') {
showAddInput = false;
newKind = '';
inputError = '';
}
}
function removeKind(kind: number) {
visualizationConfig.removeEventKind(kind);
}
function toggleKind(kind: number) {
visualizationConfig.toggleKind(kind);
}
function getKindName(kind: number): string {
switch (kind) {
case NostrKind.PublicationIndex: return 'Publication Index';
case NostrKind.PublicationContent: return 'Publication Content';
case NostrKind.Wiki: return 'Wiki';
case NostrKind.TextNote: return 'Text Note';
case NostrKind.UserMetadata: return 'Metadata';
default: return `Kind ${kind}`;
}
}
</script>
<div class="space-y-3">
<div class="flex flex-wrap gap-2 items-center">
{#each $visualizationConfig.eventConfigs as ec}
{@const isEnabled = ec.enabled !== false}
{@const isLoaded = (eventCounts[ec.kind] || 0) > 0}
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'}
<button
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}"
onclick={() => toggleKind(ec.kind)}
title={isEnabled ? `Click to disable ${getKindName(ec.kind)}` : `Click to enable ${getKindName(ec.kind)}`}
>
<Badge
color="dark"
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}"
>
<span class="text-xs">{ec.kind}</span>
{#if isLoaded}
<span class="text-xs text-gray-400">({eventCounts[ec.kind]})</span>
{/if}
<button
onclick={(e) => {
e.stopPropagation();
removeKind(ec.kind);
}}
class="ml-1 text-red-500 hover:text-red-700 transition-colors"
title="Remove {getKindName(ec.kind)}"
>
<CloseCircleOutline class="w-3 h-3" />
</button>
</Badge>
</button>
{/each}
{#if !showAddInput}
<Button
size="xs"
color="light"
onclick={() => showAddInput = true}
class="gap-1"
>
<span>+</span>
<span>Add Kind</span>
</Button>
{/if}
<Button
size="xs"
color="blue"
onclick={onReload}
class="gap-1"
title="Reload graph with current event type filters"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Reload</span>
</Button>
</div>
{#if showAddInput}
<div class="flex items-center gap-2">
<input
bind:value={newKind}
type="number"
placeholder="Enter event kind number (e.g. 1)"
class="flex-1 px-3 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
onkeydown={handleKeydown}
oninput={(e) => {
const value = (e.target as HTMLInputElement).value;
validateKind(value);
}}
/>
<Button size="xs" onclick={handleAddKind} disabled={!newKind}>
Add
</Button>
<Button
size="xs"
color="light"
onclick={() => {
showAddInput = false;
newKind = '';
inputError = '';
}}
>
Cancel
</Button>
</div>
{#if inputError}
<p class="text-xs text-red-500 -mt-2">
{inputError}
</p>
{/if}
{/if}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span>
<span>Green border = Events loaded</span>
</p>
<p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-red-500 rounded"></span>
<span>Red border = Not loaded (click Reload to fetch)</span>
</p>
</div>
</div>
<style>
.badge-container {
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease;
}
.badge-container:hover:not(.disabled) {
transform: scale(1.05);
}
.badge-container.disabled {
cursor: pointer;
}
</style>

52
src/lib/components/EventLimitControl.svelte

@ -1,52 +0,0 @@ @@ -1,52 +0,0 @@
<script lang="ts">
import { networkFetchLimit } from "$lib/state";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher<{
update: { limit: number };
}>();
let inputValue = $networkFetchLimit;
function handleInput(event: Event) {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value);
// Ensure value is between 1 and 50
if (value >= 1 && value <= 50) {
inputValue = value;
}
}
function handleUpdate() {
$networkFetchLimit = inputValue;
dispatch("update", { limit: inputValue });
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleUpdate();
}
}
</script>
<div class="flex items-center gap-2 mb-4">
<label for="event-limit" class="leather bg-transparent text-sm font-medium"
>Number of root events:
</label>
<input
type="number"
id="event-limit"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
on:input={handleInput}
on:keydown={handleKeyDown}
/>
<button
on:click={handleUpdate}
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>
</div>

274
src/lib/components/EventTypeConfig.svelte

@ -0,0 +1,274 @@ @@ -0,0 +1,274 @@
<script lang="ts">
import { visualizationConfig } from '$lib/stores/visualizationConfig';
import { Button, Input } from 'flowbite-svelte';
import { CloseCircleOutline } from 'flowbite-svelte-icons';
import { getEventKindName, getEventKindColor } from '$lib/utils/eventColors';
import {
validateEventKind,
handleAddEventKind,
handleEventKindKeydown
} from '$lib/utils/event_kind_utils';
import type { EventCounts } from "$lib/types";
let {
onReload = () => {},
eventCounts = {},
profileStats = { totalFetched: 0, displayLimit: 50 }
} = $props<{
onReload?: () => void;
eventCounts?: EventCounts;
profileStats?: { totalFetched: number; displayLimit: number };
}>();
let newKind = $state('');
let showAddInput = $state(false);
let inputError = $state('');
// Get existing kinds for validation
let existingKinds = $derived($visualizationConfig.eventConfigs.map((ec: any) => ec.kind));
function handleAddKind() {
const result = handleAddEventKind(
newKind,
existingKinds,
(kind) => visualizationConfig.addEventKind(kind),
() => {
newKind = '';
showAddInput = false;
inputError = '';
}
);
if (!result.success) {
inputError = result.error;
}
}
function handleKeydown(e: KeyboardEvent) {
handleEventKindKeydown(
e,
handleAddKind,
() => {
showAddInput = false;
newKind = '';
inputError = '';
}
);
}
function handleLimitChange(kind: number, value: string) {
const limit = parseInt(value);
if (!isNaN(limit) && limit > 0) {
visualizationConfig.updateEventLimit(kind, limit);
// Update profile stats display limit if it's kind 0
if (kind === 0) {
profileStats = { ...profileStats, displayLimit: limit };
}
}
}
function handleNestedLevelsChange(value: string) {
const levels = parseInt(value);
if (!isNaN(levels) && levels >= 0) {
visualizationConfig.updateNestedLevels(levels);
}
}
function handleFollowDepthChange(value: string) {
const depth = parseInt(value);
if (!isNaN(depth) && depth >= 0 && depth <= 2) {
visualizationConfig.updateFollowDepth(depth);
}
}
</script>
<div class="space-y-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
Showing {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} of {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} events
</span>
<!-- Event configurations -->
<div class="space-y-2">
{#each $visualizationConfig.eventConfigs as config}
{@const isLoaded = (eventCounts[config.kind] || 0) > 0}
{@const isDisabled = config.enabled === false}
{@const color = getEventKindColor(config.kind)}
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'}
<div class="flex items-center gap-2">
<!-- Kind badge with color indicator and load status border -->
<button
class="flex items-center gap-1 min-w-[140px] px-2 py-1 border-2 rounded {borderColor} {isDisabled ? 'opacity-50' : ''} hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
onclick={() => visualizationConfig.toggleKind(config.kind)}
title={isDisabled ? `Click to enable ${getEventKindName(config.kind)}` : `Click to disable ${getEventKindName(config.kind)}`}
>
<span
class="inline-block w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {color}"
></span>
<span class="text-sm font-medium dark:text-white">
{config.kind}
</span>
</button>
<button
onclick={() => visualizationConfig.removeEventKind(config.kind)}
class="text-red-500 hover:text-red-700 transition-colors"
title="Remove {getEventKindName(config.kind)}"
>
<CloseCircleOutline class="w-4 h-4" />
</button>
<!-- Special format for kind 0 (profiles) -->
{#if config.kind === 0}
<input
type="number"
value={config.limit}
min="1"
max={profileStats.totalFetched || 1000}
class="w-16 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
oninput={(e) => handleLimitChange(config.kind, e.currentTarget.value)}
title="Max profiles to display"
/>
<span class="text-xs text-gray-600 dark:text-gray-400">
of {profileStats.totalFetched} fetched
</span>
{:else}
<!-- Limit input for other kinds -->
<input
type="number"
value={config.limit}
min="1"
max="1000"
class="w-16 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white {(config.kind === 30041 || config.kind === 30818) && config.showAll ? 'opacity-50' : ''}"
oninput={(e) => handleLimitChange(config.kind, e.currentTarget.value)}
title="Max to display"
disabled={(config.kind === 30041 || config.kind === 30818) && config.showAll}
/>
<!-- Show All checkbox for content kinds (30041, 30818) -->
{#if config.kind === 30041 || config.kind === 30818}
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={config.showAll || false}
onchange={() => visualizationConfig.toggleShowAllContent(config.kind)}
class="w-3 h-3"
/>
All
</label>
{/if}
{/if}
<!-- Nested levels for 30040 -->
{#if config.kind === 30040}
<span class="text-xs text-gray-600 dark:text-gray-400">Nested Levels:</span>
<input
type="number"
value={config.nestedLevels || 1}
min="0"
max="10"
class="w-14 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
oninput={(e) => handleNestedLevelsChange(e.currentTarget.value)}
title="Levels to traverse"
/>
{/if}
<!-- Additional settings for kind 3 (follow lists) -->
{#if config.kind === 3}
<select
value={config.depth || 0}
onchange={(e) => handleFollowDepthChange(e.currentTarget.value)}
class="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
title="How many degrees of separation to traverse"
>
<option value="0">Direct</option>
<option value="1">2 degrees</option>
<option value="2">3 degrees</option>
</select>
{/if}
<!-- Load indicator -->
{#if config.kind !== 0 && isLoaded}
<span class="text-xs text-green-600 dark:text-green-400">
({eventCounts[config.kind]})
</span>
{:else if config.kind !== 0}
<span class="text-xs text-red-600 dark:text-red-400">
(not loaded)
</span>
{/if}
</div>
{/each}
</div>
<!-- Add kind button/input -->
{#if showAddInput}
<div class="flex items-center gap-2">
<input
bind:value={newKind}
type="number"
placeholder="Enter event kind number (e.g. 1)"
class="flex-1 px-3 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
onkeydown={handleKeydown}
oninput={(e) => {
const validation = validateEventKind(e.currentTarget.value, existingKinds);
inputError = validation.error;
}}
/>
<Button size="xs" onclick={handleAddKind} disabled={newKind === '' || !!inputError}>
Add
</Button>
<Button
size="xs"
color="light"
onclick={() => {
showAddInput = false;
newKind = '';
inputError = '';
}}
>
Cancel
</Button>
</div>
{#if inputError}
<p class="text-xs text-red-500 -mt-2">
{inputError}
</p>
{/if}
{:else}
<Button
size="xs"
color="light"
onclick={() => showAddInput = true}
class="gap-1"
>
<span>+</span>
<span>Add Event Type</span>
</Button>
{/if}
<!-- Reload button -->
<Button
size="xs"
color="blue"
onclick={onReload}
class="gap-1"
title="Reload graph with current settings"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Reload</span>
</Button>
<!-- Border legend -->
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2">
<p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span>
<span>Green = Events loaded</span>
</p>
<p class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-red-500 rounded"></span>
<span>Red = Not loaded (click Reload)</span>
</p>
</div>
</div>

17
src/lib/components/util/ArticleNav.svelte

@ -4,12 +4,14 @@ @@ -4,12 +4,14 @@
CaretLeftOutline,
CloseOutline,
GlobeOutline,
ChartOutline,
} from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte";
import { goto } from "$app/navigation";
let { publicationType, indexEvent } = $props<{
rootId: any;
@ -102,6 +104,11 @@ @@ -102,6 +104,11 @@
}
}
function visualizePublication() {
const eventId = indexEvent.id;
goto(`/visualize?event=${eventId}`);
}
let unsubscribe: () => void;
onMount(() => {
window.addEventListener("scroll", handleScroll);
@ -186,6 +193,16 @@ @@ -186,6 +193,16 @@
<span class="hidden sm:inline">Discussion</span>
</Button>
{/if}
<Button
class="btn-leather !w-auto"
outline={true}
onclick={visualizePublication}
title="Visualize publication network"
>
<ChartOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Visualize Publication</span
>
</Button>
</div>
</div>
</nav>

8
src/lib/consts.ts

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
export const zettelKinds = [30041, 30818, 30023];
export const communityRelays = [
"wss://theforest.nostr1.com",
@ -29,18 +29,18 @@ export const secondaryRelays = [ @@ -29,18 +29,18 @@ export const secondaryRelays = [
export const anonymousRelays = [
"wss://freelay.sovbit.host",
"wss://thecitadel.nostr1.com"
"wss://thecitadel.nostr1.com",
];
export const lowbandwidthRelays = [
"wss://theforest.nostr1.com",
"wss://thecitadel.nostr1.com",
"wss://aggr.nostr.land"
"wss://aggr.nostr.land",
];
export const localRelays: string[] = [
"wss://localhost:8080",
"wss://localhost:4869"
"wss://localhost:4869",
];
export enum FeedType {

459
src/lib/navigator/EventNetwork/Legend.svelte

@ -1,14 +1,61 @@ @@ -1,14 +1,61 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts">
import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let { collapsedOnInteraction = false, className = "" } = $props<{
import { getEventKindColor, getEventKindName } from '$lib/utils/eventColors';
import type { EventCounts } from "$lib/types";
const TAG_LEGEND_COLUMNS = 3; // Number of columns for tag anchor table
let {
collapsedOnInteraction = false,
className = "",
starMode = false,
showTags = false,
tagAnchors = [],
eventCounts = {},
disabledTags = new Set<string>(),
onTagToggle = (tagId: string) => {},
autoDisabledTags = false,
showTagAnchors = $bindable(false),
selectedTagType = $bindable("t"),
onTagSettingsChange = () => {},
showPersonNodes = $bindable(false),
personAnchors = [],
disabledPersons = new Set<string>(),
onPersonToggle = (pubkey: string) => {},
onPersonSettingsChange = () => {},
showSignedBy = $bindable(true),
showReferenced = $bindable(true),
totalPersonCount = 0,
displayedPersonCount = 0,
} = $props<{
collapsedOnInteraction: boolean;
className: string;
starMode?: boolean;
showTags?: boolean;
tagAnchors?: any[];
eventCounts?: EventCounts;
disabledTags?: Set<string>;
onTagToggle?: (tagId: string) => void;
autoDisabledTags?: boolean;
showTagAnchors?: boolean;
selectedTagType?: string;
onTagSettingsChange?: () => void;
showPersonNodes?: boolean;
personAnchors?: any[];
disabledPersons?: Set<string>;
onPersonToggle?: (pubkey: string) => void;
onPersonSettingsChange?: () => void;
showSignedBy?: boolean;
showReferenced?: boolean;
totalPersonCount?: number;
displayedPersonCount?: number;
}>();
let expanded = $state(true);
let nodeTypesExpanded = $state(true);
let tagAnchorsExpanded = $state(true);
let tagControlsExpanded = $state(true);
let personVisualizerExpanded = $state(true);
let tagSortMode = $state<'count' | 'alphabetical'>('count');
$effect(() => {
if (collapsedOnInteraction) {
@ -19,57 +66,89 @@ @@ -19,57 +66,89 @@
function toggle() {
expanded = !expanded;
}
function toggleNodeTypes() {
nodeTypesExpanded = !nodeTypesExpanded;
}
function toggleTagAnchors() {
tagAnchorsExpanded = !tagAnchorsExpanded;
}
function invertTagSelection() {
// Invert selection - toggle all tags one by one
const allTagIds = tagAnchors.map((anchor: any) => `${anchor.type}-${anchor.label}`);
// Process all tags
allTagIds.forEach((tagId: string) => {
onTagToggle(tagId);
});
}
function invertPersonSelection() {
// Invert selection - toggle all person nodes
const allPubkeys = personAnchors.map((person: any) => person.pubkey);
// Process all persons
allPubkeys.forEach((pubkey: string) => {
onPersonToggle(pubkey);
});
}
</script>
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3">
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}>
<h3 class="h-leather">Legend</h3>
<Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
<div class="pointer-events-none">
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
</div>
{#if expanded}
<ul class="legend-list">
<!-- Index event node -->
<li class="legend-item">
<div class="legend-icon">
<div class="space-y-4">
<!-- Node Types Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleNodeTypes}>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Node Types</h4>
<div class="pointer-events-none">
{#if nodeTypesExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if nodeTypesExpanded}
<ul class="space-y-2">
<!-- Dynamic event kinds -->
{#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]}
{@const kind = Number(kindStr)}
{@const countNum = count as number}
{@const color = getEventKindColor(kind)}
{@const name = getEventKindName(kind)}
{#if countNum > 0}
<li class="flex items-center mb-2 last:mb-0">
<div class="flex items-center mr-2">
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
class="w-4 h-4 rounded-full"
style="background-color: {color}"
>
<span class="legend-letter">I</span>
</span>
</div>
<span class="legend-text"
>Index events (kind 30040) - Each with a unique pastel color</span
>
</li>
<!-- Content event node -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content">
<span class="legend-letter">C</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{kind} - {name} ({countNum})
</span>
</div>
<span class="legend-text"
>Content events (kinds 30041, 30818) - Publication sections</span
>
</li>
{/if}
{/each}
<!-- Link arrow -->
<li class="legend-item">
<!-- Connection lines -->
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16M16 6l6 6-6 6"
@ -78,8 +157,314 @@ @@ -78,8 +157,314 @@
fill="none"
/>
</svg>
<span class="legend-text">Arrows indicate reading/sequence order</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{#if starMode}
Radial connections from centers to related events
{:else}
Arrows indicate relationships and sequence
{/if}
</span>
</li>
<!-- Edge colors for person connections -->
{#if showPersonNodes && personAnchors.length > 0}
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16"
class="person-link-signed"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-xs text-gray-700 dark:text-gray-300">
Authored by person
</span>
</li>
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16"
class="person-link-referenced"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-xs text-gray-700 dark:text-gray-300">
References person
</span>
</li>
{/if}
</ul>
{/if}
</div>
<!-- Tag Anchor Controls Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => tagControlsExpanded = !tagControlsExpanded}>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Tag Anchor Controls</h4>
<div class="pointer-events-none">
{#if tagControlsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if tagControlsExpanded}
<div class="space-y-3">
<!-- Show Tag Anchors Toggle -->
<div class="flex items-center space-x-2">
<button
onclick={() => {
showTagAnchors = !showTagAnchors;
onTagSettingsChange();
}}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showTagAnchors ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
>
{showTagAnchors ? 'ON' : 'OFF'}
</button>
<span class="text-sm">Show Tag Anchors</span>
</div>
{#if showTagAnchors}
<!-- Tag Type Selection -->
<div>
<label class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label>
<select
bind:value={selectedTagType}
onchange={onTagSettingsChange}
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
>
<option value="t">Hashtags</option>
<option value="author">Authors</option>
<option value="e">Event References</option>
<option value="title">Titles</option>
<option value="summary">Summaries</option>
</select>
</div>
{/if}
</div>
{/if}
</div>
<!-- Tag Anchors section -->
{#if showTags && tagAnchors.length > 0}
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleTagAnchors}>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Active Tag Anchors: {tagAnchors[0].type}</h4>
<div class="pointer-events-none">
{#if tagAnchorsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if tagAnchorsExpanded}
{@const sortedAnchors = tagSortMode === 'count'
? [...tagAnchors].sort((a, b) => b.count - a.count)
: [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label))
}
{#if autoDisabledTags}
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded">
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them.
</div>
{/if}
<!-- Sort options and controls -->
<div class="flex items-center justify-between gap-4 mb-3">
<div class="flex items-center gap-4">
<span class="text-xs text-gray-600 dark:text-gray-400">Sort by:</span>
<label class="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="tagSort"
value="count"
bind:group={tagSortMode}
class="w-3 h-3"
/>
<span class="text-xs">Count</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="tagSort"
value="alphabetical"
bind:group={tagSortMode}
class="w-3 h-3"
/>
<span class="text-xs">Alphabetical</span>
</label>
</div>
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
onclick={invertTagSelection}
class="w-3 h-3"
/>
<span class="text-xs">Invert Selection</span>
</label>
</div>
<div
class="grid gap-1 {tagAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
>
{#each sortedAnchors as anchor}
{@const tagId = `${anchor.type}-${anchor.label}`}
{@const isDisabled = disabledTags.has(tagId)}
<button
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => onTagToggle(tagId)}
title={isDisabled ? `Click to show ${anchor.label}` : `Click to hide ${anchor.label}`}
>
<div class="flex items-center">
<span
class="w-4.5 h-4.5 rounded-full border-2 border-white flex items-center justify-center"
style="background-color: {anchor.color}; opacity: {isDisabled ? 0.3 : 1};"
>
<span class="text-xs text-white font-bold">
{anchor.type === "t"
? "#"
: anchor.type === "author"
? "A"
: anchor.type.charAt(0).toUpperCase()}
</span>
</span>
</div>
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" style="opacity: {isDisabled ? 0.5 : 1};" title={anchor.label}>
{anchor.label.length > 25 ? anchor.label.slice(0, 22) + '...' : anchor.label}
{#if !isDisabled}
<span class="text-gray-500 dark:text-gray-400">({anchor.count})</span>
{/if}
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Person Visualizer Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => personVisualizerExpanded = !personVisualizerExpanded}>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Person Visualizer</h4>
<div class="pointer-events-none">
{#if personVisualizerExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if personVisualizerExpanded}
<div class="space-y-3">
<!-- Show Person Nodes Toggle -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button
onclick={() => {
showPersonNodes = !showPersonNodes;
onPersonSettingsChange();
}}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showPersonNodes ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
>
{showPersonNodes ? 'ON' : 'OFF'}
</button>
<span class="text-sm">Show Person Nodes</span>
</div>
{#if showPersonNodes}
<div class="flex items-center space-x-3 text-xs">
<label class="flex items-center space-x-1">
<input
type="checkbox"
bind:checked={showSignedBy}
onchange={onPersonSettingsChange}
class="w-3 h-3"
/>
<span>Signed by</span>
</label>
<label class="flex items-center space-x-1">
<input
type="checkbox"
bind:checked={showReferenced}
onchange={onPersonSettingsChange}
class="w-3 h-3"
/>
<span>Referenced</span>
</label>
</div>
{/if}
</div>
{#if showPersonNodes && personAnchors.length > 0}
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-gray-600 dark:text-gray-400">
{#if totalPersonCount > displayedPersonCount}
Displaying {displayedPersonCount} of {totalPersonCount} people found:
{:else}
{personAnchors.length} people found:
{/if}
</p>
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
onclick={invertPersonSelection}
class="w-3 h-3"
/>
<span class="text-xs">Invert Selection</span>
</label>
</div>
<div
class="grid gap-1 {personAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}"
style="grid-template-columns: repeat(2, 1fr);"
>
{#each personAnchors as person}
{@const isDisabled = disabledPersons.has(person.pubkey)}
<button
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => {
if (showPersonNodes) {
onPersonToggle(person.pubkey);
}
}}
disabled={!showPersonNodes}
title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`}
>
<div class="flex items-center">
<span
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white"
style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};"
/>
</div>
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
{person.displayName || person.pubkey.slice(0, 8) + '...'}
{#if !isDisabled}
<span class="text-gray-500 dark:text-gray-400">
({person.signedByCount || 0}s/{person.referencedCount || 0}r)
</span>
{/if}
</span>
</button>
{/each}
</div>
{:else if showPersonNodes}
<p class="text-xs text-gray-500 dark:text-gray-400">
No people found in the current events.
</p>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>

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

@ -8,6 +8,12 @@ @@ -8,6 +8,12 @@
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors";
import {
getDisplayNameSync,
replacePubkeysWithDisplayNames,
} from "$lib/utils/profileCache";
import {indexKind, zettelKinds, wikiKind} from "$lib/consts";
// Component props
let {
@ -16,12 +22,14 @@ @@ -16,12 +22,14 @@
x,
y,
onclose,
starMode = false,
} = $props<{
node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip
y: number; // Y position for the tooltip
onclose: () => void; // Function to call when closing the tooltip
starMode?: boolean; // Whether we're in star visualization mode
}>();
// DOM reference and positioning
@ -32,6 +40,9 @@ @@ -32,6 +40,9 @@
// Maximum content length to display
const MAX_CONTENT_LENGTH = 200;
// Publication event kinds (text/article based)
const PUBLICATION_KINDS = [wikiKind, indexKind, ...zettelKinds];
/**
* Gets the author name from the event tags
*/
@ -39,7 +50,11 @@ @@ -39,7 +50,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";
@ -71,6 +86,34 @@ @@ -71,6 +86,34 @@
return "View Publication";
}
/**
* Checks if this is a publication event
*/
function isPublicationEvent(kind: number): boolean {
return PUBLICATION_KINDS.includes(kind);
}
/**
* Gets the appropriate URL for the event
*/
function getEventUrl(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) {
return `/publication?id=${node.id}`;
}
return `/events?id=${node.id}`;
}
/**
* Gets display text for the link
*/
function getLinkText(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) {
return node.title || "Untitled Publication";
}
// For arbitrary events, show event kind name
return node.title || `Event ${node.kind}`;
}
/**
* Truncates content to a maximum length
*/
@ -145,22 +188,42 @@ @@ -145,22 +188,42 @@
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a href="/publication?id={node.id}" class="tooltip-title-link">
{node.title || "Untitled"}
<a href={getEventUrl(node)} class="tooltip-title-link">
{getLinkText(node)}
</a>
</div>
<!-- Node type and kind -->
<div class="tooltip-metadata">
{#if isPublicationEvent(node.kind)}
{node.type} (kind: {node.kind})
{:else}
{getEventKindName(node.kind)}
{#if node.event?.created_at}
· {new Date(node.event.created_at * 1000).toLocaleDateString()}
{/if}
{/if}
</div>
<!-- Author -->
<!-- Pub Author -->
<div class="tooltip-metadata">
Author: {getAuthorTag(node)}
Pub Author: {getAuthorTag(node)}
</div>
<!-- Summary (for index nodes) -->
<!-- Published by (from node.author) -->
{#if node.author}
<div class="tooltip-metadata">
published_by: {node.author}
</div>
{:else}
<!-- Fallback to author tag -->
<div class="tooltip-metadata">
published_by: {getAuthorTag(node)}
</div>
{/if}
{#if isPublicationEvent(node.kind)}
<!-- Summary (for publication index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span>
@ -168,16 +231,49 @@ @@ -168,16 +231,49 @@
</div>
{/if}
<!-- Content preview -->
<!-- Content preview for publications -->
{#if node.content}
<div class="tooltip-content-preview">
{truncateContent(node.content)}
</div>
{/if}
{:else}
<!-- For arbitrary events, show raw content or tags -->
{#if node.event?.content}
<div class="tooltip-content-preview">
<span class="font-semibold">Content:</span>
<pre class="whitespace-pre-wrap">{truncateContent(
node.event.content,
)}</pre>
</div>
{/if}
<!-- Show some relevant tags for non-publication events -->
{#if node.event?.tags && node.event.tags.length > 0}
<div class="tooltip-metadata">
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[0] === "p" ? getDisplayNameSync(tag[1]) : tag[1].substring(0, 20)}${tag[1].length > 20 && tag[0] !== "p" ? "..." : ""}`
: ""}</span
>
{/each}
{/if}
</div>
{/if}
{/if}
<!-- Help text for selected nodes -->
{#if selected}
<div class="tooltip-help-text">Click node again to dismiss</div>
<div class="tooltip-help-text">
{#if isPublicationEvent(node.kind)}
Click to view publication · Click node again to dismiss
{:else}
Click to view event details · Click node again to dismiss
{/if}
</div>
{/if}
</div>
</div>

132
src/lib/navigator/EventNetwork/Settings.svelte

@ -1,58 +1,136 @@ @@ -1,58 +1,136 @@
<!--
Settings Component
-->
<script lang="ts">
import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
import EventTypeConfig from "$lib/components/EventTypeConfig.svelte";
import { visualizationConfig } from "$lib/stores/visualizationConfig";
import { Toggle } from "flowbite-svelte";
import type { EventCounts } from "$lib/types";
let { count = 0, onupdate } = $props<{
let {
count = 0,
totalCount = 0,
onupdate,
onclear = () => {},
starVisualization = $bindable(true),
eventCounts = {},
profileStats = { totalFetched: 0, displayLimit: 50 },
} = $props<{
count: number;
totalCount: number;
onupdate: () => void;
onclear?: () => void;
starVisualization?: boolean;
eventCounts?: EventCounts;
profileStats?: { totalFetched: number; displayLimit: number };
}>();
let expanded = $state(false);
let eventTypesExpanded = $state(true);
let visualSettingsExpanded = $state(true);
function toggle() {
expanded = !expanded;
}
/**
* Handles updates to visualization settings
*/
function handleLimitUpdate() {
onupdate();
function toggleEventTypes() {
eventTypesExpanded = !eventTypesExpanded;
}
function toggleVisualSettings() {
visualSettingsExpanded = !visualSettingsExpanded;
}
</script>
<div class="leather-legend sm:!right-1 sm:!left-auto">
<div class="flex items-center justify-between space-x-3">
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}>
<h3 class="h-leather">Settings</h3>
<Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
<div class="pointer-events-none">
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
</div>
{#if expanded}
<div class="space-y-4">
<span class="leather bg-transparent legend-text">
Showing {count} events from {$networkFetchLimit} headers
Showing {count} of {totalCount} events
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
<!-- Event Configuration Section (combines types and limits) -->
<div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
>
<div
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2"
onclick={toggleEventTypes}
>
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Event Configuration
</h4>
<div class="pointer-events-none">
{#if eventTypesExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if eventTypesExpanded}
<EventTypeConfig onReload={onupdate} {eventCounts} {profileStats} />
{/if}
</div>
<!-- Visual Settings Section -->
<div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
>
<div
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2"
onclick={toggleVisualSettings}
>
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Visual Settings
</h4>
<div class="pointer-events-none">
{#if visualSettingsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
{#if visualSettingsExpanded}
<div class="space-y-4">
<div class="space-y-2">
<label
class="leather bg-transparent legend-text flex items-center space-x-2"
>
<Toggle
checked={starVisualization}
onchange={(e: Event) => {
const target = e.target as HTMLInputElement;
starVisualization = target.checked;
}}
class="text-xs"
/>
<span>Star Network View</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle between star clusters (on) and linear sequence (off)
visualization
</p>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>

82
src/lib/navigator/EventNetwork/TagTable.svelte

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
<!--
TagTable Component
Displays a table of unique tags found in the event network
-->
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Table } from "flowbite-svelte";
let { events = [], selectedTagType = "t" } = $props<{
events: NDKEvent[];
selectedTagType: string;
}>();
// Computed property for unique tags
let uniqueTags = $derived(() => {
const tagMap = new Map();
events.forEach(event => {
const tags = event.tags || [];
tags.forEach(tag => {
if (tag[0] === selectedTagType) {
const tagValue = tag[1];
const count = tagMap.get(tagValue)?.count || 0;
tagMap.set(tagValue, {
value: tagValue,
count: count + 1,
// Store first event that references this tag
firstEvent: tagMap.get(tagValue)?.firstEvent || event.id
});
}
});
});
return Array.from(tagMap.values())
.sort((a, b) => b.count - a.count); // Sort by frequency
});
// Tag type labels for display
const tagTypeLabels: Record<string, string> = {
't': 'Hashtags',
'author': 'Authors',
'p': 'People',
'e': 'Events',
'title': 'Titles',
'summary': 'Summaries'
};
</script>
{#if uniqueTags.length > 0}
<div class="tag-table-container p-4">
<h3 class="text-lg font-semibold mb-2">
{tagTypeLabels[selectedTagType] || 'Tags'}
</h3>
<Table hoverable>
<thead>
<tr>
<th>Tag</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{#each uniqueTags as tag}
<tr>
<td>{tag.value}</td>
<td>{tag.count}</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{:else}
<div class="p-4 text-gray-500">
No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found
</div>
{/if}
<style>
.tag-table-container {
max-height: 300px;
overflow-y: auto;
}
</style>

940
src/lib/navigator/EventNetwork/index.svelte

File diff suppressed because it is too large Load Diff

14
src/lib/navigator/EventNetwork/types.ts

@ -43,10 +43,22 @@ export interface NetworkNode extends SimulationNodeDatum { @@ -43,10 +43,22 @@ export interface NetworkNode extends SimulationNodeDatum {
title: string; // Event title
content: string; // Event content
author: string; // Author's public key
type: "Index" | "Content"; // Node type classification
type: "Index" | "Content" | "TagAnchor" | "PersonAnchor"; // Node type classification
naddr?: string; // NIP-19 naddr identifier
nevent?: string; // NIP-19 nevent identifier
isContainer?: boolean; // Whether this node is a container (index)
// Tag anchor specific fields
isTagAnchor?: boolean; // Whether this is a tag anchor node
tagType?: string; // Type of tag (t, p, e, etc.)
tagValue?: string; // The tag value
connectedNodes?: string[]; // IDs of nodes that have this tag
// Person anchor specific fields
isPersonAnchor?: boolean; // Whether this is a person anchor node
pubkey?: string; // The person's public key
displayName?: string; // The person's display name from kind 0
isFromFollowList?: boolean; // Whether this person comes from follow lists
}
/**

41
src/lib/navigator/EventNetwork/utils/common.ts

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
/**
* Common utilities shared across network builders
*/
/**
* Seeded random number generator for deterministic layouts
*/
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
const x = Math.sin(this.seed++) * 10000;
return x - Math.floor(x);
}
nextFloat(min: number, max: number): number {
return min + this.next() * (max - min);
}
nextInt(min: number, max: number): number {
return Math.floor(this.nextFloat(min, max + 1));
}
}
/**
* Creates a debug function with a prefix
* @param prefix - The prefix to add to all debug messages
* @returns A debug function that can be toggled on/off
*/
export function createDebugFunction(prefix: string) {
const DEBUG = false;
return function debug(...args: any[]) {
if (DEBUG) {
console.log(`[${prefix}]`, ...args);
}
};
}

92
src/lib/navigator/EventNetwork/utils/forceSimulation.ts

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
// deno-lint-ignore-file no-explicit-any
/**
* D3 Force Simulation Utilities
*
@ -6,22 +5,16 @@ @@ -6,22 +5,16 @@
* graph simulations for the event network visualization.
*/
import type { NetworkNode, NetworkLink } from "../types.ts";
import type { NetworkNode, NetworkLink } from "../types";
import * as d3 from "d3";
import { createDebugFunction } from "./common";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const GRAVITY_STRENGTH = 0.05; // Strength of global gravity
const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[ForceSimulation]", ...args);
}
}
// Debug function
const debug = createDebugFunction("ForceSimulation");
/**
* Type definition for D3 force simulation
@ -67,14 +60,14 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> { @@ -67,14 +60,14 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> {
export function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number,
deltaVy: number
) {
debug("Updating node velocity", {
nodeId: node.id,
currentVx: node.vx,
currentVy: node.vy,
deltaVx,
deltaVy,
deltaVy
});
if (typeof node.vx === "number" && typeof node.vy === "number") {
@ -103,6 +96,9 @@ export function applyGlobalLogGravity( @@ -103,6 +96,9 @@ export function applyGlobalLogGravity(
centerY: number,
alpha: number,
) {
// Tag anchors and person anchors should not be affected by gravity
if (node.isTagAnchor || node.isPersonAnchor) return;
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
@ -128,10 +124,14 @@ export function applyConnectedGravity( @@ -128,10 +124,14 @@ export function applyConnectedGravity(
links: NetworkLink[],
alpha: number,
) {
// Find all nodes connected to this node
// Tag anchors and person anchors should not be affected by connected gravity
if (node.isTagAnchor || node.isPersonAnchor) return;
// Find all nodes connected to this node (excluding tag anchors and person anchors)
const connectedNodes = links
.filter((link) => link.source.id === node.id || link.target.id === node.id)
.map((link) => (link.source.id === node.id ? link.target : link.source));
.filter(link => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source)
.filter(n => !n.isTagAnchor && !n.isPersonAnchor);
if (connectedNodes.length === 0) return;
@ -164,16 +164,19 @@ export function applyConnectedGravity( @@ -164,16 +164,19 @@ export function applyConnectedGravity(
*/
export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9,
warmupClickEnergy: number = 0.9
) {
return d3
.drag()
.on(
"start",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Tag anchors and person anchors retain their anchor behavior
if (d.isTagAnchor || d.isPersonAnchor) {
// Still allow dragging but maintain anchor status
d.fx = d.x;
d.fy = d.y;
return;
}
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
@ -181,34 +184,26 @@ export function setupDragHandlers( @@ -181,34 +184,26 @@ export function setupDragHandlers(
// Fix node position at current location
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update position for all nodes including anchors
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);
}
// Release fixed position
d.fx = null;
d.fy = null;
},
);
// Keep all nodes fixed after dragging
// This allows users to manually position any node type
d.fx = d.x;
d.fy = d.y;
});
}
/**
@ -224,13 +219,13 @@ export function createSimulation( @@ -224,13 +219,13 @@ export function createSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number,
linkDistance: number
): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", {
nodeCount: nodes.length,
linkCount: links.length,
nodeRadius,
linkDistance,
linkDistance
});
try {
@ -239,10 +234,9 @@ export function createSimulation( @@ -239,10 +234,9 @@ export function createSimulation(
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1),
.distance(linkDistance * 0.1)
)
.force("collide", d3.forceCollide().radius(nodeRadius * 4));

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

@ -6,25 +6,19 @@ @@ -6,25 +6,19 @@
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, GraphData, GraphState } from "../types.ts";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { activeInboxRelays, activeOutboxRelays } from "../../../ndk.ts";
import { getMatchingTags } from "../../../utils/nostrUtils.ts";
import { get } from "svelte/store";
import { communityRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { getDisplayNameSync } from '$lib/utils/profileCache';
import { createDebugFunction } from "./common";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KIND = 30041;
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: unknown[]) {
if (DEBUG) {
console.log("[NetworkBuilder]", ...args);
}
}
// Debug function
const debug = createDebugFunction("NetworkBuilder");
/**
* Creates a NetworkNode from an NDKEvent
@ -38,16 +32,12 @@ function debug(...args: unknown[]) { @@ -38,16 +32,12 @@ function debug(...args: unknown[]) {
*/
export function createNetworkNode(
event: NDKEvent,
level: number = 0,
level: number = 0
): NetworkNode {
debug("Creating network node", {
eventId: event.id,
kind: event.kind,
level,
});
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 = {
@ -57,9 +47,9 @@ export function createNetworkNode( @@ -57,9 +47,9 @@ 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
type: nodeType,
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 as "Index" | "Content" | "TagAnchor",
};
// Add NIP-19 identifiers if possible
@ -72,13 +62,13 @@ export function createNetworkNode( @@ -72,13 +62,13 @@ export function createNetworkNode(
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)],
relays: communityRelays,
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)],
relays: communityRelays,
kind: event.kind,
});
} catch (error) {
@ -164,24 +154,13 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -164,24 +154,13 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
debug("Processing tags for event", {
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
tagCount: tags.length,
tagType:
tags.length > 0
? getMatchingTags(event, "a").length > 0
? "a"
: "e"
: "none",
aTagCount: aTags.length
});
tags.forEach((tag) => {
aTags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
@ -296,13 +275,7 @@ export function processIndexEvent( @@ -296,13 +275,7 @@ export function processIndexEvent(
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(indexEvent, "a");
if (tags.length === 0) {
tags = getMatchingTags(indexEvent, "e");
}
const sequence = tags
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
@ -320,30 +293,39 @@ export function processIndexEvent( @@ -320,30 +293,39 @@ export function processIndexEvent(
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data for visualization
*/
export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData {
export function generateGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// 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
@ -354,7 +336,7 @@ export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData { @@ -354,7 +336,7 @@ export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData {
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length,
linkCount: result.links.length
});
return result;

337
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -0,0 +1,337 @@ @@ -0,0 +1,337 @@
/**
* Person Network Builder
*
* Creates person anchor nodes for event authors in the network visualization
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache";
import { SeededRandom, createDebugFunction } from "./common";
const PERSON_ANCHOR_RADIUS = 15;
const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000;
const MAX_PERSON_NODES = 20; // Default limit for person nodes
// Debug function
const debug = createDebugFunction("PersonNetworkBuilder");
/**
* Creates a deterministic seed from a string
*/
function createSeed(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
export interface PersonConnection {
signedByEventIds: Set<string>;
referencedInEventIds: Set<string>;
isFromFollowList?: boolean; // Track if this person comes from follow lists
}
/**
* Extracts unique persons (pubkeys) from events
* Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags)
*/
export function extractUniquePersons(
events: NDKEvent[],
followListEvents?: NDKEvent[]
): Map<string, PersonConnection> {
// Map of pubkey -> PersonConnection
const personMap = new Map<string, PersonConnection>();
debug("Extracting unique persons", { eventCount: events.length, followListCount: followListEvents?.length || 0 });
// First collect pubkeys from follow list events
const followListPubkeys = new Set<string>();
if (followListEvents && followListEvents.length > 0) {
followListEvents.forEach((event) => {
// Follow list author
if (event.pubkey) {
followListPubkeys.add(event.pubkey);
}
// People in follow lists (p tags)
if (event.tags) {
event.tags
.filter(tag => {
tag[0] === 'p'
})
.forEach(tag => {
followListPubkeys.add(tag[1]);
});
}
});
}
events.forEach((event) => {
if (!event.id) return;
// Track signed-by connections
if (event.pubkey) {
if (!personMap.has(event.pubkey)) {
personMap.set(event.pubkey, {
signedByEventIds: new Set(),
referencedInEventIds: new Set(),
isFromFollowList: followListPubkeys.has(event.pubkey)
});
}
personMap.get(event.pubkey)!.signedByEventIds.add(event.id);
}
// Track referenced connections from "p" tags
if (event.tags) {
event.tags.forEach(tag => {
if (tag[0] === "p" && tag[1]) {
const referencedPubkey = tag[1];
if (!personMap.has(referencedPubkey)) {
personMap.set(referencedPubkey, {
signedByEventIds: new Set(),
referencedInEventIds: new Set(),
isFromFollowList: followListPubkeys.has(referencedPubkey)
});
}
personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id);
}
});
}
});
debug("Extracted persons", { personCount: personMap.size });
return personMap;
}
/**
* Helper to build eligible person info for anchor nodes.
*/
function buildEligiblePerson(
pubkey: string,
connection: PersonConnection,
showSignedBy: boolean,
showReferenced: boolean
): {
pubkey: string;
connection: PersonConnection;
connectedEventIds: Set<string>;
totalConnections: number;
} | null {
const connectedEventIds = new Set<string>();
if (showSignedBy) {
connection.signedByEventIds.forEach(id => connectedEventIds.add(id));
}
if (showReferenced) {
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id));
}
if (connectedEventIds.size === 0) {
return null;
}
return {
pubkey,
connection,
connectedEventIds,
totalConnections: connectedEventIds.size
};
}
type EligiblePerson = {
pubkey: string;
connection: PersonConnection;
totalConnections: number;
connectedEventIds: Set<string>;
};
function getEligiblePersons(
personMap: Map<string, PersonConnection>,
showSignedBy: boolean,
showReferenced: boolean,
limit: number
): EligiblePerson[] {
// Build eligible persons and keep only top N using a min-heap or partial sort
const eligible: EligiblePerson[] = [];
for (const [pubkey, connection] of personMap) {
let totalConnections = 0;
if (showSignedBy) totalConnections += connection.signedByEventIds.size;
if (showReferenced) totalConnections += connection.referencedInEventIds.size;
if (totalConnections === 0) continue;
// Only build the set if this person is eligible
const connectedEventIds = new Set<string>();
if (showSignedBy) {
connection.signedByEventIds.forEach(id => connectedEventIds.add(id));
}
if (showReferenced) {
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id));
}
eligible.push({ pubkey, connection, totalConnections, connectedEventIds });
}
// Partial sort: get top N by totalConnections
eligible.sort((a, b) => b.totalConnections - a.totalConnections);
return eligible.slice(0, limit);
}
/**
* Creates person anchor nodes
*/
export function createPersonAnchorNodes(
personMap: Map<string, PersonConnection>,
width: number,
height: number,
showSignedBy: boolean,
showReferenced: boolean,
limit: number = MAX_PERSON_NODES
): { nodes: NetworkNode[], totalCount: number } {
const anchorNodes: NetworkNode[] = [];
const centerX = width / 2;
const centerY = height / 2;
// Calculate eligible persons and their connection counts
const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit);
// Create nodes for the limited set
debug("Creating person anchor nodes", {
eligibleCount: eligiblePersons.length,
limitedCount: eligiblePersons.length,
showSignedBy,
showReferenced
});
eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => {
// Create seeded random generator for consistent positioning
const rng = new SeededRandom(createSeed(pubkey));
// Generate deterministic position
const angle = rng.next() * 2 * Math.PI;
const distance = rng.next() * PERSON_ANCHOR_PLACEMENT_RADIUS;
const x = centerX + distance * Math.cos(angle);
const y = centerY + distance * Math.sin(angle);
// Get display name
const displayName = getDisplayNameSync(pubkey);
const anchorNode: NetworkNode = {
id: `person-anchor-${pubkey}`,
title: displayName,
content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`,
author: "",
kind: 0, // Special kind for anchors
type: "PersonAnchor",
level: -1,
isPersonAnchor: true,
pubkey,
displayName,
connectedNodes: Array.from(connectedEventIds),
isFromFollowList: connection.isFromFollowList,
x,
y,
fx: x, // Fix position
fy: y,
};
anchorNodes.push(anchorNode);
});
debug("Created person anchor nodes", { count: anchorNodes.length, totalEligible: eligiblePersons.length });
return {
nodes: anchorNodes,
totalCount: eligiblePersons.length
};
}
export interface PersonLink extends NetworkLink {
connectionType?: "signed-by" | "referenced";
}
/**
* Creates links between person anchors and their events
* Adds connection type for coloring
*/
export function createPersonLinks(
personAnchors: NetworkNode[],
nodes: NetworkNode[],
personMap: Map<string, PersonConnection>
): PersonLink[] {
debug("Creating person links", { anchorCount: personAnchors.length, nodeCount: nodes.length });
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const links: PersonLink[] = personAnchors.flatMap((anchor) => {
if (!anchor.connectedNodes || !anchor.pubkey) {
return [];
}
const connection = personMap.get(anchor.pubkey);
if (!connection) {
return [];
}
return anchor.connectedNodes.map((nodeId) => {
const node = nodeMap.get(nodeId);
if (!node) {
return undefined;
}
let connectionType: 'signed-by' | 'referenced' | undefined;
if (connection.signedByEventIds.has(nodeId)) {
connectionType = 'signed-by';
} else if (connection.referencedInEventIds.has(nodeId)) {
connectionType = 'referenced';
}
return {
source: anchor,
target: node,
isSequential: false,
connectionType,
};
}).filter(Boolean); // Remove undefineds
});
debug("Created person links", { linkCount: links.length });
return links;
}
/**
* Formats person anchor info for display in Legend
*/
export interface PersonAnchorInfo {
pubkey: string;
displayName: string;
signedByCount: number;
referencedCount: number;
isFromFollowList: boolean;
}
/**
* Extracts person info for Legend display
*/
export function extractPersonAnchorInfo(
personAnchors: NetworkNode[],
personMap: Map<string, PersonConnection>
): PersonAnchorInfo[] {
return personAnchors.map(anchor => {
const connection = personMap.get(anchor.pubkey || "");
return {
pubkey: anchor.pubkey || "",
displayName: anchor.displayName || "",
signedByCount: connection?.signedByEventIds.size || 0,
referencedCount: connection?.referencedInEventIds.size || 0,
isFromFollowList: connection?.isFromFollowList || false,
};
});
}

308
src/lib/navigator/EventNetwork/utils/starForceSimulation.ts

@ -0,0 +1,308 @@ @@ -0,0 +1,308 @@
/**
* Star Network Force Simulation
*
* Custom force simulation optimized for star network layouts.
* Provides stronger connections between star centers and their content nodes,
* with specialized forces to maintain hierarchical structure.
*/
import * as d3 from "d3";
import type { NetworkNode, NetworkLink } from "../types";
import type { Simulation } from "./forceSimulation";
import { createTagGravityForce } from "./tagNetworkBuilder";
// Configuration for star network forces
const STAR_CENTER_CHARGE = -300; // Stronger repulsion between star centers
const CONTENT_NODE_CHARGE = -50; // Weaker repulsion for content nodes
const STAR_LINK_STRENGTH = 0.5; // Moderate connection to star center
const INTER_STAR_LINK_STRENGTH = 0.2; // Weaker connection between stars
const STAR_LINK_DISTANCE = 80; // Fixed distance from center to content
const INTER_STAR_DISTANCE = 200; // Distance between star centers
const CENTER_GRAVITY = 0.02; // Gentle pull toward canvas center
const STAR_CENTER_WEIGHT = 10; // Weight multiplier for star centers
/**
* Creates a custom force simulation for star networks
*/
export function createStarSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
width: number,
height: number
): Simulation<NetworkNode, NetworkLink> {
// Create the simulation
const simulation = d3.forceSimulation(nodes) as any
simulation
.force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY))
.velocityDecay(0.2) // Lower decay for more responsive simulation
.alphaDecay(0.0001) // Much slower alpha decay to prevent freezing
.alphaMin(0.001); // Keep minimum energy to prevent complete freeze
// Custom charge force that varies by node type
const chargeForce = d3.forceManyBody()
.strength((d: NetworkNode) => {
// Tag anchors don't repel
if (d.isTagAnchor) {
return 0;
}
// Star centers repel each other strongly
if (d.isContainer && d.kind === 30040) {
return STAR_CENTER_CHARGE;
}
// Content nodes have minimal repulsion
return CONTENT_NODE_CHARGE;
})
.distanceMax(300); // Limit charge force range
// Custom link force with variable strength and distance
const linkForce = d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.strength((link: any) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
// Strong connection from star center to its content
if (source.kind === 30040 && target.kind === 30041) {
return STAR_LINK_STRENGTH;
}
// Weaker connection between star centers
if (source.kind === 30040 && target.kind === 30040) {
return INTER_STAR_LINK_STRENGTH;
}
return 0.5; // Default strength
})
.distance((link: any) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
// Fixed distance for star-to-content links
if (source.kind === 30040 && target.kind === 30041) {
return STAR_LINK_DISTANCE;
}
// Longer distance between star centers
if (source.kind === 30040 && target.kind === 30040) {
return INTER_STAR_DISTANCE;
}
return 100; // Default distance
});
// Apply forces to simulation
simulation
.force("charge", chargeForce)
.force("link", linkForce);
// Custom radial force to keep content nodes around their star center
simulation.force("radial", createRadialForce(nodes, links));
// Add tag gravity force if there are tag anchors
const hasTagAnchors = nodes.some(n => n.isTagAnchor);
if (hasTagAnchors) {
simulation.force("tagGravity", createTagGravityForce(nodes, links));
}
// Periodic reheat to prevent freezing
let tickCount = 0;
simulation.on("tick", () => {
tickCount++;
// Every 300 ticks, give a small energy boost to prevent freezing
if (tickCount % 300 === 0 && simulation.alpha() < 0.01) {
simulation.alpha(0.02);
}
});
return simulation;
}
/**
* Applies the radial force to keep content nodes in orbit around their star center
* @param nodes - The array of network nodes
* @param nodeToCenter - Map of content node IDs to their star center node
* @param targetDistance - The desired distance from center to content node
* @param alpha - The current simulation alpha
*/
function applyRadialForce(
nodes: NetworkNode[],
nodeToCenter: Map<string, NetworkNode>,
targetDistance: number,
alpha: number
): void {
nodes.forEach(node => {
if (node.kind === 30041) {
const center = nodeToCenter.get(node.id);
if (
center &&
center.x != null &&
center.y != null &&
node.x != null &&
node.y != null
) {
// Calculate desired position
const dx = node.x - center.x;
const dy = node.y - center.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// Normalize and apply force
const force = (distance - targetDistance) * alpha * 0.3; // Reduced force
node.vx = (node.vx || 0) - (dx / distance) * force;
node.vy = (node.vy || 0) - (dy / distance) * force;
}
}
}
});
}
/**
* Creates a custom radial force that keeps content nodes in orbit around their star center
*/
function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any {
// Build a map of content nodes to their star centers
const nodeToCenter = new Map<string, NetworkNode>();
links.forEach(link => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) {
nodeToCenter.set(target.id, source);
}
});
function force(alpha: number) {
applyRadialForce(nodes, nodeToCenter, STAR_LINK_DISTANCE, alpha);
}
force.initialize = function(_: NetworkNode[]) {
nodes = _;
};
return force;
}
/**
* Applies initial positioning for star networks
*/
export function applyInitialStarPositions(
nodes: NetworkNode[],
links: NetworkLink[],
width: number,
height: number
): void {
// Group nodes by their star centers
const starGroups = new Map<string, NetworkNode[]>();
const starCenters: NetworkNode[] = [];
// Identify star centers
nodes.forEach(node => {
if (node.isContainer && node.kind === 30040) {
starCenters.push(node);
starGroups.set(node.id, []);
}
});
// Assign content nodes to their star centers
links.forEach(link => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) {
const group = starGroups.get(source.id);
if (group) {
group.push(target);
}
}
});
// Position star centers in a grid or circle
if (starCenters.length === 1) {
// Single star - center it
const center = starCenters[0];
center.x = width / 2;
center.y = height / 2;
// Don't fix position initially - let simulation run naturally
} else if (starCenters.length > 1) {
// Multiple stars - arrange in a circle
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.3;
const angleStep = (2 * Math.PI) / starCenters.length;
starCenters.forEach((center, i) => {
const angle = i * angleStep;
center.x = centerX + radius * Math.cos(angle);
center.y = centerY + radius * Math.sin(angle);
// Don't fix position initially - let simulation adjust
});
}
// Position content nodes around their star centers
starGroups.forEach((contentNodes, centerId) => {
const center = nodes.find(n => n.id === centerId);
if (!center) return;
const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1);
contentNodes.forEach((node, i) => {
const angle = i * angleStep;
node.x = (center.x || 0) + STAR_LINK_DISTANCE * Math.cos(angle);
node.y = (center.y || 0) + STAR_LINK_DISTANCE * Math.sin(angle);
});
});
}
/**
* Handler for the start of a drag event in the star network simulation.
* Sets the fixed position of the node to its current position.
* @param event - The drag event from d3
* @param d - The node being dragged
* @param simulation - The d3 force simulation instance
*/
function dragstarted(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) {
// If no other drag is active, set a low alpha target to keep the simulation running smoothly
if (!event.active) {
simulation.alphaTarget(0.1).restart();
}
// Set the node's fixed position to its current position
d.fx = d.x;
d.fy = d.y;
}
/**
* Handler for the drag event in the star network simulation.
* Updates the node's fixed position to follow the mouse.
* @param event - The drag event from d3
* @param d - The node being dragged
*/
function dragged(event: any, d: NetworkNode) {
// Update the node's fixed position to the current mouse position
d.fx = event.x;
d.fy = event.y;
}
/**
* Handler for the end of a drag event in the star network simulation.
* Keeps the node fixed at its new position after dragging.
* @param event - The drag event from d3
* @param d - The node being dragged
* @param simulation - The d3 force simulation instance
*/
function dragended(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) {
// If no other drag is active, lower the alpha target to let the simulation cool down
if (!event.active) {
simulation.alphaTarget(0);
}
// Keep the node fixed at its new position
d.fx = event.x;
d.fy = event.y;
}
/**
* Custom drag handler for star networks
* @param simulation - The d3 force simulation instance
* @returns The d3 drag behavior
*/
export function createStarDragHandler(
simulation: Simulation<NetworkNode, NetworkLink>
): any {
// These handlers are now top-level functions, so we use closures to pass simulation to them.
// This is a common pattern in JavaScript/TypeScript when you need to pass extra arguments to event handlers.
return d3.drag()
.on('start', function(event: any, d: NetworkNode) { dragstarted(event, d, simulation); })
.on('drag', dragged)
.on('end', function(event: any, d: NetworkNode) { dragended(event, d, simulation); });
}

353
src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts

@ -0,0 +1,353 @@ @@ -0,0 +1,353 @@
/**
* Star Network Builder for NKBIP-01 Events
*
* This module provides utilities for building star network visualizations specifically
* for NKBIP-01 events (kinds 30040 and 30041). Unlike the sequential network builder,
* this creates star formations where index events (30040) are central nodes with
* content events (30041) arranged around them.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder';
import { createDebugFunction } from './common';
import { wikiKind, indexKind, zettelKinds } from '$lib/consts';
// Debug function
const debug = createDebugFunction("StarNetworkBuilder");
/**
* Represents a star network with a central index node and peripheral content nodes
*/
export interface StarNetwork {
center: NetworkNode; // Central index node (30040)
peripheralNodes: NetworkNode[]; // Content nodes (30041) and connected indices (30040)
links: NetworkLink[]; // Links within this star
}
/**
* Creates a star network from an index event and its references
*
* @param indexEvent - The central index event (30040)
* @param state - Current graph state
* @param level - Hierarchy level for this star
* @returns A star network structure
*/
export function createStarNetwork(
indexEvent: NDKEvent,
state: GraphState,
level: number = 0
): StarNetwork | null {
debug("Creating star network", { indexId: indexEvent.id, level });
const centerNode = state.nodeMap.get(indexEvent.id);
if (!centerNode) {
debug("Center node not found for index event", indexEvent.id);
return null;
}
// Set the center node level
centerNode.level = level;
// Extract referenced event IDs from 'a' tags
const referencedIds = getMatchingTags(indexEvent, "a")
.map(tag => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null);
debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds });
// Get peripheral nodes (both content and nested indices)
const peripheralNodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
referencedIds.forEach(id => {
const node = state.nodeMap.get(id);
if (node) {
// Set the peripheral node level
node.level += 1;
peripheralNodes.push(node);
// Create link from center to peripheral node
links.push({
source: centerNode,
target: node,
isSequential: false // Star links are not sequential
});
debug("Added peripheral node", { nodeId: id, nodeType: node.type });
}
});
return {
center: centerNode,
peripheralNodes,
links
};
}
/**
* Processes all index events to create star networks
*
* @param events - Array of all events
* @param maxLevel - Maximum nesting level to process
* @returns Array of star networks
*/
export function createStarNetworks(
events: NDKEvent[],
maxLevel: number,
existingNodeMap?: Map<string, NetworkNode>
): StarNetwork[] {
debug("Creating star networks", { eventCount: events.length, maxLevel });
// Use existing node map or create new one
const nodeMap = existingNodeMap || new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create nodes for all events if not using existing map
if (!existingNodeMap) {
events.forEach(event => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
}
const state: GraphState = {
nodeMap,
links: [],
eventMap,
referencedIds: new Set<string>()
};
// Find all index events and non-publication events
const publicationKinds = [wikiKind, indexKind, ...zettelKinds];
const indexEvents = events.filter(event => event.kind === indexKind);
const nonPublicationEvents = events.filter(event =>
event.kind !== undefined && !publicationKinds.includes(event.kind)
);
debug("Found index events", { count: indexEvents.length });
debug("Found non-publication events", { count: nonPublicationEvents.length });
const starNetworks: StarNetwork[] = [];
const processedIndices = new Set<string>();
// Process all index events regardless of level
indexEvents.forEach(indexEvent => {
if (!indexEvent.id || processedIndices.has(indexEvent.id)) return;
const star = createStarNetwork(indexEvent, state, 0);
if (star && star.peripheralNodes.length > 0) {
starNetworks.push(star);
processedIndices.add(indexEvent.id);
debug("Created star network", {
centerId: star.center.id,
peripheralCount: star.peripheralNodes.length
});
}
});
// Add non-publication events as standalone nodes (stars with no peripherals)
nonPublicationEvents.forEach(event => {
if (!event.id || !nodeMap.has(event.id)) return;
const node = nodeMap.get(event.id)!;
const star: StarNetwork = {
center: node,
peripheralNodes: [],
links: []
};
starNetworks.push(star);
debug("Created standalone star for non-publication event", {
eventId: event.id,
kind: event.kind
});
});
return starNetworks;
}
/**
* Creates inter-star connections between star networks
*
* @param starNetworks - Array of star networks
* @returns Additional links connecting different star networks
*/
export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] {
debug("Creating inter-star connections", { starCount: starNetworks.length });
const interStarLinks: NetworkLink[] = [];
// Create a map of center nodes for quick lookup
const centerNodeMap = new Map<string, NetworkNode>();
starNetworks.forEach(star => {
centerNodeMap.set(star.center.id, star.center);
});
// For each star, check if any of its peripheral nodes are centers of other stars
starNetworks.forEach(star => {
star.peripheralNodes.forEach(peripheralNode => {
// If this peripheral node is the center of another star, create an inter-star link
if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) {
const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id);
if (targetStar) {
interStarLinks.push({
source: star.center,
target: targetStar.center,
isSequential: false
});
debug("Created inter-star connection", {
from: star.center.id,
to: targetStar.center.id
});
}
}
});
});
return interStarLinks;
}
/**
* Applies star-specific positioning to nodes using a radial layout
*
* @param starNetworks - Array of star networks
* @param width - Canvas width
* @param height - Canvas height
*/
export function applyStarLayout(
starNetworks: StarNetwork[],
width: number,
height: number
): void {
debug("Applying star layout", {
starCount: starNetworks.length,
dimensions: { width, height }
});
const centerX = width / 2;
const centerY = height / 2;
// If only one star, center it
if (starNetworks.length === 1) {
const star = starNetworks[0];
// Position center node
star.center.x = centerX;
star.center.y = centerY;
star.center.fx = centerX; // Fix center position
star.center.fy = centerY;
// Position peripheral nodes in a circle around center
const radius = Math.min(width, height) * 0.25;
const angleStep = (2 * Math.PI) / star.peripheralNodes.length;
star.peripheralNodes.forEach((node, index) => {
const angle = index * angleStep;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
return;
}
// For multiple stars, arrange them in a grid or circle
const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length));
const starSpacingX = width / (starsPerRow + 1);
const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1);
starNetworks.forEach((star, index) => {
const row = Math.floor(index / starsPerRow);
const col = index % starsPerRow;
const starCenterX = (col + 1) * starSpacingX;
const starCenterY = (row + 1) * starSpacingY;
// Position center node
star.center.x = starCenterX;
star.center.y = starCenterY;
star.center.fx = starCenterX; // Fix center position
star.center.fy = starCenterY;
// Position peripheral nodes around this star's center
const radius = Math.min(starSpacingX, starSpacingY) * 0.3;
const angleStep = (2 * Math.PI) / Math.max(star.peripheralNodes.length, 1);
star.peripheralNodes.forEach((node, nodeIndex) => {
const angle = nodeIndex * angleStep;
node.x = starCenterX + radius * Math.cos(angle);
node.y = starCenterY + radius * Math.sin(angle);
});
});
}
/**
* Generates a complete star network graph from events
*
* @param events - Array of Nostr events
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data with star network layout
*/
export function generateStarGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating star graph", { eventCount: events.length, maxLevel });
// Guard against empty events
if (!events || events.length === 0) {
return { nodes: [], links: [] };
}
// Initialize all nodes first
const nodeMap = new Map<string, NetworkNode>();
events.forEach(event => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
// Create star networks with the existing node map
const starNetworks = createStarNetworks(events, maxLevel, nodeMap);
// Create inter-star connections
const interStarLinks = createInterStarConnections(starNetworks);
// Collect nodes that are part of stars
const nodesInStars = new Set<string>();
const allLinks: NetworkLink[] = [];
// Add nodes and links from all stars
starNetworks.forEach(star => {
nodesInStars.add(star.center.id);
star.peripheralNodes.forEach(node => {
nodesInStars.add(node.id);
});
allLinks.push(...star.links);
});
// Add inter-star links
allLinks.push(...interStarLinks);
// Include orphaned nodes (those not in any star)
const allNodes: NetworkNode[] = [];
nodeMap.forEach((node, id) => {
allNodes.push(node);
});
const result = {
nodes: allNodes,
links: allLinks
};
debug("Star graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length,
starCount: starNetworks.length,
orphanedNodes: allNodes.length - nodesInStars.size
});
return result;
}

314
src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts

@ -0,0 +1,314 @@ @@ -0,0 +1,314 @@
/**
* Tag Network Builder
*
* Enhances network visualizations with tag anchor nodes that act as gravity points
* for nodes sharing the same tags.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache";
import { SeededRandom, createDebugFunction } from "./common";
// Configuration
const TAG_ANCHOR_RADIUS = 15;
// TODO: Move this to settings panel for user control
const TAG_ANCHOR_PLACEMENT_RADIUS = 1250; // Radius from center within which to randomly place tag anchors
// Debug function
const debug = createDebugFunction("TagNetworkBuilder");
/**
* Creates a deterministic seed from a string
*/
function createSeed(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Color mapping for tag anchor nodes
*/
export function getTagAnchorColor(tagType: string): string {
switch (tagType) {
case "t":
return "#eba5a5"; // Blue for hashtags
case "p":
return "#10B981"; // Green for people
case "author":
return "#8B5CF6"; // Purple for authors
case "e":
return "#F59E0B"; // Yellow for events
case "a":
return "#EF4444"; // Red for articles
case "kind3":
return "#06B6D4"; // Cyan for follow lists
default:
return "#6B7280"; // Gray for others
}
}
/**
* Extracts unique tags from events for a specific tag type
*/
export function extractUniqueTagsForType(
events: NDKEvent[],
tagType: string,
): Map<string, Set<string>> {
// Map of tagValue -> Set of event IDs
const tagMap = new Map<string, Set<string>>();
debug("Extracting unique tags for type", { tagType, eventCount: events.length });
events.forEach((event) => {
if (!event.tags || !event.id) return;
event.tags.forEach((tag) => {
if (tag.length < 2) return;
if (tag[0] !== tagType) return;
const tagValue = tag[1];
if (!tagValue) return;
if (!tagMap.has(tagValue)) {
tagMap.set(tagValue, new Set());
}
tagMap.get(tagValue)!.add(event.id);
});
});
debug("Extracted tags", { tagCount: tagMap.size });
return tagMap;
}
/**
* Creates tag anchor nodes from extracted tags of a specific type
*/
export function createTagAnchorNodes(
tagMap: Map<string, Set<string>>,
tagType: string,
width: number,
height: number,
): NetworkNode[] {
const anchorNodes: NetworkNode[] = [];
debug("Creating tag anchor nodes", { tagType, tagCount: tagMap.size });
// Calculate positions for tag anchors randomly within radius
// Show all tags regardless of how many events they appear in
const minEventCount = 1;
let validTags = Array.from(tagMap.entries()).filter(
([_, eventIds]) => eventIds.size >= minEventCount,
);
if (validTags.length === 0) return [];
// Sort all tags by number of connections (events) descending
validTags.sort((a, b) => b[1].size - a[1].size);
validTags.forEach(([tagValue, eventIds]) => {
// Position anchors randomly within a radius from the center
const centerX = width / 2;
const centerY = height / 2;
// Create seeded random generator based on tag type and value for consistent positioning
const seedString = `${tagType}-${tagValue}`;
const rng = new SeededRandom(createSeed(seedString));
// Generate deterministic position within the defined radius
const angle = rng.next() * 2 * Math.PI;
const distance = rng.next() * TAG_ANCHOR_PLACEMENT_RADIUS;
const x = centerX + distance * Math.cos(angle);
const y = centerY + distance * Math.sin(angle);
// Format the display title based on tag type
let displayTitle = tagValue;
if (tagType === "t") {
displayTitle = tagValue.startsWith("#") ? tagValue : `#${tagValue}`;
} else if (tagType === "author") {
displayTitle = tagValue;
} else if (tagType === "p") {
// Use display name for pubkey
displayTitle = getDisplayNameSync(tagValue);
}
const anchorNode: NetworkNode = {
id: `tag-anchor-${tagType}-${tagValue}`,
title: displayTitle,
content: `${eventIds.size} events`,
author: "",
kind: 0, // Special kind for tag anchors
type: "TagAnchor",
level: -1, // Tag anchors are outside the hierarchy
isTagAnchor: true,
tagType,
tagValue,
connectedNodes: Array.from(eventIds),
x,
y,
fx: x, // Fix position
fy: y,
};
anchorNodes.push(anchorNode);
});
debug("Created tag anchor nodes", { count: anchorNodes.length });
return anchorNodes;
}
/**
* Creates invisible links between tag anchors and nodes that have those tags
*/
export function createTagLinks(
tagAnchors: NetworkNode[],
nodes: NetworkNode[],
): NetworkLink[] {
debug("Creating tag links", { anchorCount: tagAnchors.length, nodeCount: nodes.length });
const links: NetworkLink[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
tagAnchors.forEach((anchor) => {
if (!anchor.connectedNodes) return;
anchor.connectedNodes.forEach((nodeId) => {
const node = nodeMap.get(nodeId);
if (node) {
links.push({
source: anchor,
target: node,
isSequential: false,
});
}
});
});
debug("Created tag links", { linkCount: links.length });
return links;
}
/**
* Enhances a graph with tag anchor nodes for a specific tag type
*/
export function enhanceGraphWithTags(
graphData: GraphData,
events: NDKEvent[],
tagType: string,
width: number,
height: number,
displayLimit?: number,
): GraphData {
debug("Enhancing graph with tags", { tagType, displayLimit });
// Extract unique tags for the specified type
const tagMap = extractUniqueTagsForType(events, tagType);
// Create tag anchor nodes
let tagAnchors = createTagAnchorNodes(tagMap, tagType, width, height);
// Apply display limit if provided
if (displayLimit && displayLimit > 0 && tagAnchors.length > displayLimit) {
// Sort by connection count (already done in createTagAnchorNodes)
// and take only the top ones up to the limit
tagAnchors = tagAnchors.slice(0, displayLimit);
}
// Create links between anchors and nodes
const tagLinks = createTagLinks(tagAnchors, graphData.nodes);
// Return enhanced graph
return {
nodes: [...graphData.nodes, ...tagAnchors],
links: [...graphData.links, ...tagLinks],
};
}
/**
* Applies a gentle pull on each node toward its tag anchors.
*
* @param nodes - The array of network nodes to update.
* @param nodeToAnchors - A map from node IDs to their tag anchor nodes.
* @param alpha - The current simulation alpha (cooling factor).
*/
export function applyTagGravity(
nodes: NetworkNode[],
nodeToAnchors: Map<string, NetworkNode[]>,
alpha: number
): void {
nodes.forEach((node) => {
if (node.isTagAnchor) return; // Tag anchors don't move
const anchors = nodeToAnchors.get(node.id);
if (!anchors || anchors.length === 0) return;
// Apply gentle pull toward each tag anchor
anchors.forEach((anchor) => {
if (
anchor.x != null &&
anchor.y != null &&
node.x != null &&
node.y != null
) {
const dx = anchor.x - node.x;
const dy = anchor.y - node.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// Gentle force that decreases with distance
const strength = (0.02 * alpha) / anchors.length;
node.vx = (node.vx || 0) + (dx / distance) * strength * distance;
node.vy = (node.vy || 0) + (dy / distance) * strength * distance;
}
}
});
});
}
/**
* Custom force for tag anchor gravity
*/
export function createTagGravityForce(
nodes: NetworkNode[],
links: NetworkLink[],
): any {
// Build a map of nodes to their tag anchors
const nodeToAnchors = new Map<string, NetworkNode[]>();
links.forEach((link) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.isTagAnchor && !target.isTagAnchor) {
if (!nodeToAnchors.has(target.id)) {
nodeToAnchors.set(target.id, []);
}
nodeToAnchors.get(target.id)!.push(source);
} else if (target.isTagAnchor && !source.isTagAnchor) {
if (!nodeToAnchors.has(source.id)) {
nodeToAnchors.set(source.id, []);
}
nodeToAnchors.get(source.id)!.push(target);
}
});
debug("Creating tag gravity force");
function force(alpha: number) {
applyTagGravity(nodes, nodeToAnchors, alpha);
}
force.initialize = function (_: NetworkNode[]) {
nodes = _;
};
return force;
}

2
src/lib/state.ts

@ -11,5 +11,5 @@ export const tabBehaviour: Writable<string> = writable( @@ -11,5 +11,5 @@ export const tabBehaviour: Writable<string> = writable(
export const userPublickey: Writable<string> = writable(
(browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "",
);
export const networkFetchLimit: Writable<number> = writable(5);
export const networkFetchLimit: Writable<number> = writable(50);
export const levelsToRender: Writable<number> = writable(3);

2
src/lib/stores/index.ts

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
export * from './relayStore';
export * from './displayLimits';

182
src/lib/stores/visualizationConfig.ts

@ -0,0 +1,182 @@ @@ -0,0 +1,182 @@
import { writable, derived, get } from "svelte/store";
export interface EventKindConfig {
kind: number;
limit: number;
enabled?: boolean; // Whether this kind is enabled for display
nestedLevels?: number; // Only for kind 30040
depth?: number; // Only for kind 3 (follow lists)
showAll?: boolean; // Only for content kinds (30041, 30818) - show all loaded content instead of limit
}
/**
* VisualizationConfig now uses a Map<number, string> for eventConfigs.
* The key is the event kind (number), and the value is a JSON stringified EventKindConfig.
* This allows O(1) retrieval of config by kind.
*/
export interface VisualizationConfig {
/**
* Event configurations with per-kind limits.
*/
eventConfigs: EventKindConfig[];
/**
* Whether to search through all fetched events during graph traversal.
*/
searchThroughFetched: boolean;
}
// Default configurations for common event kinds
const DEFAULT_EVENT_CONFIGS: EventKindConfig[] = [
{ kind: 30040, limit: 20, nestedLevels: 1, enabled: true },
{ kind: 30041, limit: 20, enabled: false },
{ kind: 30818, limit: 20, enabled: false },
{ kind: 30023, limit: 20, enabled: false },
];
function createVisualizationConfig() {
const initialConfig: VisualizationConfig = {
eventConfigs: DEFAULT_EVENT_CONFIGS,
searchThroughFetched: true,
};
const { subscribe, set, update } = writable<VisualizationConfig>(initialConfig);
function reset() {
set(initialConfig);
}
function addEventKind(kind: number, limit: number = 10) {
update((config) => {
// Check if kind already exists
if (config.eventConfigs.some((ec) => ec.kind === kind)) {
return config;
}
const newConfig: EventKindConfig = { kind, limit, enabled: true };
// Add nestedLevels for 30040
if (kind === 30040) {
newConfig.nestedLevels = 1;
}
// Add depth for kind 3
if (kind === 3) {
newConfig.depth = 0;
}
return {
...config,
eventConfigs: [...config.eventConfigs, newConfig],
};
});
}
function removeEventKind(kind: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.filter((ec) => ec.kind !== kind),
}));
}
function updateEventLimit(kind: number, limit: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, limit } : ec,
),
}));
}
function updateNestedLevels(levels: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === 30040 ? { ...ec, nestedLevels: levels } : ec,
),
}));
}
function updateFollowDepth(depth: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === 3 ? { ...ec, depth: depth } : ec,
),
}));
}
function toggleShowAllContent(kind: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, showAll: !ec.showAll } : ec,
),
}));
}
function getEventConfig(kind: number) {
let config: EventKindConfig | undefined;
subscribe((c) => {
config = c.eventConfigs.find((ec) => ec.kind === kind);
})();
return config;
}
function toggleSearchThroughFetched() {
update((config) => ({
...config,
searchThroughFetched: !config.searchThroughFetched,
}));
}
function toggleKind(kind: number) {
update((config) => ({
...config,
eventConfigs: config.eventConfigs.map((ec) =>
ec.kind === kind ? { ...ec, enabled: !ec.enabled } : ec,
),
}));
}
return {
subscribe,
update,
reset,
addEventKind,
removeEventKind,
updateEventLimit,
updateNestedLevels,
updateFollowDepth,
toggleShowAllContent,
getEventConfig,
toggleSearchThroughFetched,
toggleKind,
};
}
export const visualizationConfig = createVisualizationConfig();
// Helper to get all enabled event kinds
export const enabledEventKinds = derived(visualizationConfig, ($config) =>
$config.eventConfigs
.filter((ec) => ec.enabled !== false)
.map((ec) => ec.kind),
);
/**
* Returns true if the given event kind is enabled in the config.
* @param config - The VisualizationConfig object.
* @param kind - The event kind number to check.
*/
export function isKindEnabledFn(config: VisualizationConfig, kind: number): boolean {
const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind);
// If not found, return false. Otherwise, return true unless explicitly disabled.
return !!eventConfig && eventConfig.enabled !== false;
}
// Derived store: returns a function that checks if a kind is enabled in the current config.
export const isKindEnabledStore = derived(
visualizationConfig,
($config) => (kind: number) => isKindEnabledFn($config, kind)
);

18
src/lib/types.ts

@ -13,3 +13,21 @@ export type TabType = @@ -13,3 +13,21 @@ export type TabType =
| "user"
| "settings"
| "editor";
export type EventCounts = { [kind: number]: number };
/**
* Enum of Nostr event kinds relevant to Alexandria.
*/
export enum NostrKind {
/** User metadata event (kind 0) */
UserMetadata = 0,
/** Text note event (kind 1) */
TextNote = 1,
/** Publication index event (kind 30040) */
PublicationIndex = 30040,
/** Publication content event (kind 30041) */
PublicationContent = 30041,
/** Wiki event (kind 30818) */
Wiki = 30818,
}

142
src/lib/utils/displayLimits.ts

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import type { VisualizationConfig } from '$lib/stores/visualizationConfig';
import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers';
import type { NostrEventId } from './nostr_identifiers';
/**
* Filters events based on visualization configuration
* @param events - All available events
* @param config - Visualization configuration
* @returns Filtered events that should be displayed
*/
export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationConfig): NDKEvent[] {
const result: NDKEvent[] = [];
const kindCounts = new Map<number, number>();
for (const event of events) {
const kind = event.kind;
if (kind === undefined) continue;
// Get the config for this event kind
const eventConfig = config.eventConfigs.find(ec => ec.kind === kind);
// Skip if the kind is disabled
if (eventConfig && eventConfig.enabled === false) {
continue;
}
const limit = eventConfig?.limit;
// Special handling for content kinds (30041, 30818) with showAll option
if ((kind === 30041 || kind === 30818) && eventConfig?.showAll) {
// Show all content events when showAll is true
result.push(event);
// Still update the count for UI display
const currentCount = kindCounts.get(kind) || 0;
kindCounts.set(kind, currentCount + 1);
} else if (limit !== undefined) {
// Normal limit checking
const currentCount = kindCounts.get(kind) || 0;
if (currentCount < limit) {
result.push(event);
kindCounts.set(kind, currentCount + 1);
}
} else {
// No limit configured, add the event
result.push(event);
}
}
return result;
}
/**
* Detects events that are referenced but not present in the current set
* @param events - Current events
* @param existingIds - Set of all known event IDs (hex format)
* @param existingCoordinates - Optional map of existing coordinates for NIP-33 detection
* @returns Set of missing event identifiers
*/
export function detectMissingEvents(
events: NDKEvent[],
existingIds: Set<NostrEventId>,
existingCoordinates?: Map<string, NDKEvent>
): Set<string> {
const missing = new Set<string>();
for (const event of events) {
// Check 'e' tags for direct event references (hex IDs)
const eTags = event.getMatchingTags('e');
for (const eTag of eTags) {
if (eTag.length < 2) continue;
const eventId = eTag[1];
// Type check: ensure it's a valid hex event ID
if (!isEventId(eventId)) {
console.warn('Invalid event ID in e tag:', eventId);
continue;
}
if (!existingIds.has(eventId)) {
missing.add(eventId);
}
}
// Check 'a' tags for NIP-33 references (kind:pubkey:d-tag)
const aTags = event.getMatchingTags('a');
for (const aTag of aTags) {
if (aTag.length < 2) continue;
const identifier = aTag[1];
// Type check: ensure it's a valid coordinate
if (!isCoordinate(identifier)) {
console.warn('Invalid coordinate in a tag:', identifier);
continue;
}
// Parse the coordinate
const parsed = parseCoordinate(identifier);
if (!parsed) continue;
// If we have existing coordinates, check if this one exists
if (existingCoordinates) {
if (!existingCoordinates.has(identifier)) {
missing.add(identifier);
}
} else {
// Without coordinate map, we can't detect missing NIP-33 events
// This is a limitation when we only have hex IDs
console.debug('Cannot detect missing NIP-33 events without coordinate map:', identifier);
}
}
}
return missing;
}
/**
* Builds a map of coordinates to events for NIP-33 detection
* @param events - Array of events to build coordinate map from
* @returns Map of coordinate strings to events
*/
export function buildCoordinateMap(events: NDKEvent[]): Map<string, NDKEvent> {
const coordinateMap = new Map<string, NDKEvent>();
for (const event of events) {
// Only process replaceable events (kinds 30000-39999)
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tagValue('d');
const author = event.pubkey;
if (dTag && author) {
const coordinate = `${event.kind}:${author}:${dTag}`;
coordinateMap.set(coordinate, event);
}
}
}
return coordinateMap;
}

82
src/lib/utils/eventColors.ts

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
/**
* Deterministic color mapping for event kinds
* Uses golden ratio to distribute colors evenly across the spectrum
*/
const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
/**
* Get a deterministic color for an event kind
* @param kind - The event kind number
* @returns HSL color string
*/
export function getEventKindColor(kind: number): string {
// Use golden ratio for better distribution
const hue = (kind * GOLDEN_RATIO * 360) % 360;
// Use different saturation/lightness for better visibility
const saturation = 65 + (kind % 20); // 65-85%
const lightness = 55 + ((kind * 3) % 15); // 55-70%
return `hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`;
}
/**
* Get a friendly name for an event kind
* @param kind - The event kind number
* @returns Human-readable name
*/
export function getEventKindName(kind: number): string {
const kindNames: Record<number, string> = {
0: 'Metadata',
1: 'Text Note',
2: 'Recommend Relay',
3: 'Contact List',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
8: 'Badge Award',
16: 'Generic Repost',
40: 'Channel Creation',
41: 'Channel Metadata',
42: 'Channel Message',
43: 'Channel Hide Message',
44: 'Channel Mute User',
1984: 'Reporting',
9734: 'Zap Request',
9735: 'Zap',
10000: 'Mute List',
10001: 'Pin List',
10002: 'Relay List',
22242: 'Client Authentication',
24133: 'Nostr Connect',
27235: 'HTTP Auth',
30000: 'Categorized People List',
30001: 'Categorized Bookmark List',
30008: 'Profile Badges',
30009: 'Badge Definition',
30017: 'Create or update a stall',
30018: 'Create or update a product',
30023: 'Long-form Content',
30024: 'Draft Long-form Content',
30040: 'Publication Index',
30041: 'Publication Content',
30078: 'Application-specific Data',
30311: 'Live Event',
30402: 'Classified Listing',
30403: 'Draft Classified Listing',
30617: 'Repository',
30818: 'Wiki Page',
31922: 'Date-Based Calendar Event',
31923: 'Time-Based Calendar Event',
31924: 'Calendar',
31925: 'Calendar Event RSVP',
31989: 'Handler recommendation',
31990: 'Handler information',
34550: 'Community Definition',
};
return kindNames[kind] || `Kind ${kind}`;
}

214
src/lib/utils/eventDeduplication.ts

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk';
/**
* Deduplicate content events by keeping only the most recent version
* @param contentEventSets Array of event sets from different sources
* @returns Map of coordinate to most recent event
*/
export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map<string, NDKEvent> {
const eventsByCoordinate = new Map<string, NDKEvent>();
// Track statistics for debugging
let totalEvents = 0;
let duplicateCoordinates = 0;
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = [];
contentEventSets.forEach((eventSet) => {
eventSet.forEach(event => {
totalEvents++;
const dTag = event.tagValue("d");
const author = event.pubkey;
const kind = event.kind;
if (dTag && author && kind) {
const coordinate = `${kind}:${author}:${dTag}`;
const existing = eventsByCoordinate.get(coordinate);
if (existing) {
// We found a duplicate coordinate
duplicateCoordinates++;
// Track details for the first few duplicates
if (duplicateDetails.length < 5) {
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate);
if (existingDetails) {
existingDetails.count++;
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`);
} else {
duplicateDetails.push({
coordinate,
count: 2, // existing + current
events: [
`${existing.id} (created_at: ${existing.created_at})`,
`${event.id} (created_at: ${event.created_at})`
]
});
}
}
}
// Keep the most recent event (highest created_at)
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) {
eventsByCoordinate.set(coordinate, event);
}
}
});
});
// Log deduplication results if any duplicates were found
if (duplicateCoordinates > 0) {
console.log(`[eventDeduplication] Found ${duplicateCoordinates} duplicate events out of ${totalEvents} total events`);
console.log(`[eventDeduplication] Reduced to ${eventsByCoordinate.size} unique coordinates`);
console.log(`[eventDeduplication] Duplicate details:`, duplicateDetails);
} else if (totalEvents > 0) {
console.log(`[eventDeduplication] No duplicates found in ${totalEvents} events`);
}
return eventsByCoordinate;
}
/**
* Deduplicate and combine all events, keeping only the most recent version of replaceable events
* @param nonPublicationEvents Array of non-publication events
* @param validIndexEvents Set of valid index events
* @param contentEvents Set of content events
* @returns Array of deduplicated events
*/
export function deduplicateAndCombineEvents(
nonPublicationEvents: NDKEvent[],
validIndexEvents: Set<NDKEvent>,
contentEvents: Set<NDKEvent>
): NDKEvent[] {
// Track statistics for debugging
const initialCount = nonPublicationEvents.length + validIndexEvents.size + contentEvents.size;
let replaceableEventsProcessed = 0;
let duplicateCoordinatesFound = 0;
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = [];
// First, build coordinate map for replaceable events
const coordinateMap = new Map<string, NDKEvent>();
const allEventsToProcess = [
...nonPublicationEvents, // 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 => {
if (!event.id) return;
// For replaceable events (30000-39999), track by coordinate
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
replaceableEventsProcessed++;
const dTag = event.tagValue("d");
const author = event.pubkey;
if (dTag && author) {
const coordinate = `${event.kind}:${author}:${dTag}`;
const existing = coordinateMap.get(coordinate);
if (existing) {
// We found a duplicate coordinate
duplicateCoordinatesFound++;
// Track details for the first few duplicates
if (duplicateDetails.length < 5) {
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate);
if (existingDetails) {
existingDetails.count++;
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`);
} else {
duplicateDetails.push({
coordinate,
count: 2, // existing + current
events: [
`${existing.id} (created_at: ${existing.created_at})`,
`${event.id} (created_at: ${event.created_at})`
]
});
}
}
}
// Keep the most recent version
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) {
coordinateMap.set(coordinate, event);
}
}
}
});
// Second pass: build final event map
const finalEventMap = new Map<string, NDKEvent>();
const seenCoordinates = new Set<string>();
allEventsToProcess.forEach(event => {
if (!event.id) return;
// For replaceable events, only add if it's the chosen version
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tagValue("d");
const author = event.pubkey;
if (dTag && author) {
const coordinate = `${event.kind}:${author}:${dTag}`;
const chosenEvent = coordinateMap.get(coordinate);
// Only add this event if it's the chosen one for this coordinate
if (chosenEvent && chosenEvent.id === event.id) {
if (!seenCoordinates.has(coordinate)) {
finalEventMap.set(event.id, event);
seenCoordinates.add(coordinate);
}
}
return;
}
}
// Non-replaceable events are added directly
finalEventMap.set(event.id, event);
});
const finalCount = finalEventMap.size;
const reduction = initialCount - finalCount;
// Log deduplication results if any duplicates were found
if (duplicateCoordinatesFound > 0) {
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Found ${duplicateCoordinatesFound} duplicate coordinates out of ${replaceableEventsProcessed} replaceable events`);
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Reduced from ${initialCount} to ${finalCount} events (${reduction} removed)`);
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Duplicate details:`, duplicateDetails);
} else if (replaceableEventsProcessed > 0) {
console.log(`[eventDeduplication] deduplicateAndCombineEvents: No duplicates found in ${replaceableEventsProcessed} replaceable events`);
}
return Array.from(finalEventMap.values());
}
/**
* Check if an event is a replaceable event (kinds 30000-39999)
* @param event The event to check
* @returns True if the event is replaceable
*/
export function isReplaceableEvent(event: NDKEvent): boolean {
return event.kind !== undefined && event.kind >= 30000 && event.kind < 40000;
}
/**
* Get the coordinate for a replaceable event
* @param event The event to get the coordinate for
* @returns The coordinate string (kind:pubkey:d-tag) or null if not a valid replaceable event
*/
export function getEventCoordinate(event: NDKEvent): string | null {
if (!isReplaceableEvent(event)) {
return null;
}
const dTag = event.tagValue("d");
const author = event.pubkey;
if (!dTag || !author) {
return null;
}
return `${event.kind}:${author}:${dTag}`;
}

98
src/lib/utils/event_kind_utils.ts

@ -0,0 +1,98 @@ @@ -0,0 +1,98 @@
import type { EventKindConfig } from '$lib/stores/visualizationConfig';
/**
* Validates an event kind input value.
* @param value - The input value to validate (string or number).
* @param existingKinds - Array of existing event kind numbers to check for duplicates.
* @returns The validated kind number, or null if validation fails.
*/
export function validateEventKind(
value: string | number,
existingKinds: number[]
): { kind: number | null; error: string } {
// Convert to string for consistent handling
const strValue = String(value);
if (strValue === null || strValue === undefined || strValue.trim() === '') {
return { kind: null, error: '' };
}
const kind = parseInt(strValue.trim());
if (isNaN(kind)) {
return { kind: null, error: 'Must be a number' };
}
if (kind < 0) {
return { kind: null, error: 'Must be non-negative' };
}
if (existingKinds.includes(kind)) {
return { kind: null, error: 'Already added' };
}
return { kind, error: '' };
}
/**
* Handles adding a new event kind with validation and state management.
* @param newKind - The new kind value to add.
* @param existingKinds - Array of existing event kind numbers.
* @param addKindFunction - Function to call when adding the kind.
* @param resetStateFunction - Function to call to reset the input state.
* @returns Object with success status and any error message.
*/
export function handleAddEventKind(
newKind: string,
existingKinds: number[],
addKindFunction: (kind: number) => void,
resetStateFunction: () => void
): { success: boolean; error: string } {
console.log('[handleAddEventKind] called with:', newKind);
const validation = validateEventKind(newKind, existingKinds);
console.log('[handleAddEventKind] Validation result:', validation);
if (validation.kind !== null) {
console.log('[handleAddEventKind] Adding event kind:', validation.kind);
addKindFunction(validation.kind);
resetStateFunction();
return { success: true, error: '' };
} else {
console.log('[handleAddEventKind] Validation failed:', validation.error);
return { success: false, error: validation.error };
}
}
/**
* Handles keyboard events for event kind input.
* @param e - The keyboard event.
* @param onEnter - Function to call when Enter is pressed.
* @param onEscape - Function to call when Escape is pressed.
*/
export function handleEventKindKeydown(
e: KeyboardEvent,
onEnter: () => void,
onEscape: () => void
): void {
if (e.key === 'Enter') {
onEnter();
} else if (e.key === 'Escape') {
onEscape();
}
}
/**
* Gets the display name for an event kind.
* @param kind - The event kind number.
* @returns The display name for the kind.
*/
export function getEventKindDisplayName(kind: number): string {
switch (kind) {
case 30040: return 'Publication Index';
case 30041: return 'Publication Content';
case 30818: return 'Wiki';
case 1: return 'Text Note';
case 0: return 'Metadata';
case 3: return 'Follow List';
default: return `Kind ${kind}`;
}
}

3
src/lib/utils/event_search.ts

@ -1,7 +1,8 @@ @@ -1,7 +1,8 @@
import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NDKFilter } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts";

88
src/lib/utils/nostr_identifiers.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { VALIDATION } from './search_constants';
import type { NostrEventId } from './nostr_identifiers';
/**
* Nostr identifier types
*/
export type NostrCoordinate = string; // kind:pubkey:d-tag format
export type NostrIdentifier = NostrEventId | NostrCoordinate;
/**
* Interface for parsed Nostr coordinate
*/
export interface ParsedCoordinate {
kind: number;
pubkey: string;
dTag: string;
}
/**
* Check if a string is a valid hex event ID
* @param id The string to check
* @returns True if it's a valid hex event ID
*/
export function isEventId(id: string): id is NostrEventId {
return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id);
}
/**
* Check if a string is a valid Nostr coordinate (kind:pubkey:d-tag)
* @param coordinate The string to check
* @returns True if it's a valid coordinate
*/
export function isCoordinate(coordinate: string): coordinate is NostrCoordinate {
const parts = coordinate.split(':');
if (parts.length < 3) return false;
const [kindStr, pubkey, ...dTagParts] = parts;
// Check if kind is a valid number
const kind = parseInt(kindStr, 10);
if (isNaN(kind) || kind < 0) return false;
// Check if pubkey is a valid hex string
if (!isEventId(pubkey)) return false;
// Check if d-tag exists (can contain colons)
if (dTagParts.length === 0) return false;
return true;
}
/**
* Parse a Nostr coordinate into its components
* @param coordinate The coordinate string to parse
* @returns Parsed coordinate or null if invalid
*/
export function parseCoordinate(coordinate: string): ParsedCoordinate | null {
if (!isCoordinate(coordinate)) return null;
const parts = coordinate.split(':');
const [kindStr, pubkey, ...dTagParts] = parts;
return {
kind: parseInt(kindStr, 10),
pubkey,
dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons
};
}
/**
* Create a coordinate string from components
* @param kind The event kind
* @param pubkey The author's public key
* @param dTag The d-tag value
* @returns The coordinate string
*/
export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate {
return `${kind}:${pubkey}:${dTag}`;
}
/**
* Check if a string is any valid Nostr identifier
* @param identifier The string to check
* @returns True if it's a valid Nostr identifier
*/
export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier {
return isEventId(identifier) || isCoordinate(identifier);
}

252
src/lib/utils/profileCache.ts

@ -0,0 +1,252 @@ @@ -0,0 +1,252 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store";
import { nip19 } from "nostr-tools";
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
* @param onProgress - Optional callback for progress updates
* @returns Array of profile events
*/
export async function batchFetchProfiles(
pubkeys: string[],
onProgress?: (fetched: number, total: number) => void
): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = [];
// Filter out already cached pubkeys
const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk));
if (uncachedPubkeys.length === 0) {
if (onProgress) onProgress(pubkeys.length, pubkeys.length);
return allProfileEvents;
}
try {
const ndk = get(ndkInstance);
// Report initial progress
const cachedCount = pubkeys.length - uncachedPubkeys.length;
if (onProgress) onProgress(cachedCount, pubkeys.length);
// Batch fetch in chunks to avoid overwhelming relays
const CHUNK_SIZE = 50;
let fetchedCount = cachedCount;
for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) {
const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length));
const profileEvents = await ndk.fetchEvents({
kinds: [0],
authors: chunk
});
// Process each profile event
profileEvents.forEach((event: NDKEvent) => {
try {
const content = JSON.parse(event.content);
profileCache.set(event.pubkey, content as ProfileData);
allProfileEvents.push(event);
fetchedCount++;
} catch (e) {
console.error("Failed to parse profile content:", e);
}
});
// Update progress
if (onProgress) {
onProgress(fetchedCount, pubkeys.length);
}
}
// Final progress update
if (onProgress) onProgress(pubkeys.length, pubkeys.length);
} catch (e) {
console.error("Failed to batch fetch profiles:", e);
}
return allProfileEvents;
}
/**
* 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:npub[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);
});
}

3
src/lib/utils/search_types.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { NDKEvent, NDKFilter, NDKSubscription } from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKSubscription } from "@nostr-dev-kit/ndk";
import type { NDKFilter } from "@nostr-dev-kit/ndk";
/**
* Extended NostrProfile interface for search results

206
src/lib/utils/tag_event_fetch.ts

@ -0,0 +1,206 @@ @@ -0,0 +1,206 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "../ndk";
import { get } from "svelte/store";
import { extractPubkeysFromEvents, batchFetchProfiles } from "./profileCache";
// Constants for publication event kinds
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818];
/**
* Interface for tag expansion fetch results
*/
export interface TagExpansionResult {
publications: NDKEvent[];
contentEvents: NDKEvent[];
}
/**
* Fetches publications and their content events from relays based on tags
*
* This function handles the relay-based fetching portion of tag expansion:
* 1. Fetches publication index events that have any of the specified tags
* 2. Extracts content event references from those publications
* 3. Fetches the referenced content events
*
* @param tags Array of tags to search for in publications
* @param existingEventIds Set of existing event IDs to avoid duplicates
* @param baseEvents Array of base events to check for existing content
* @param debug Optional debug function for logging
* @returns Promise resolving to publications and content events
*/
export async function fetchTaggedEventsFromRelays(
tags: string[],
existingEventIds: Set<string>,
baseEvents: NDKEvent[],
debug?: (...args: any[]) => void
): Promise<TagExpansionResult> {
const log = debug || console.debug;
log("Fetching from relays for tags:", tags);
// Fetch publications that have any of the specified tags
const ndk = get(ndkInstance);
const taggedPublications = await ndk.fetchEvents({
kinds: [INDEX_EVENT_KIND],
"#t": tags, // Match any of these tags
limit: 30 // Reasonable default limit
});
log("Found tagged publications from relays:", taggedPublications.size);
// Filter to avoid duplicates
const newPublications = Array.from(taggedPublications).filter(
(event: NDKEvent) => !existingEventIds.has(event.id)
);
// Extract content event d-tags from new publications
const contentEventDTags = new Set<string>();
const existingContentDTags = new Set(
baseEvents
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind))
.map(e => e.tagValue("d"))
.filter(d => d !== undefined)
);
newPublications.forEach((event: NDKEvent) => {
const aTags = event.getMatchingTags("a");
aTags.forEach((tag: string[]) => {
// Parse the 'a' tag identifier: kind:pubkey:d-tag
if (tag[1]) {
const parts = tag[1].split(':');
if (parts.length >= 3) {
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons
if (!existingContentDTags.has(dTag)) {
contentEventDTags.add(dTag);
}
}
}
});
});
// Fetch the content events
let newContentEvents: NDKEvent[] = [];
if (contentEventDTags.size > 0) {
const contentEventsSet = await ndk.fetchEvents({
kinds: CONTENT_EVENT_KINDS,
"#d": Array.from(contentEventDTags), // Use d-tag filter
});
newContentEvents = Array.from(contentEventsSet);
}
return {
publications: newPublications,
contentEvents: newContentEvents
};
}
/**
* Searches through already fetched events for publications with specified tags
*
* This function handles the local search portion of tag expansion:
* 1. Searches through existing events for publications with matching tags
* 2. Extracts content event references from those publications
* 3. Finds the referenced content events in existing events
*
* @param allEvents Array of all fetched events to search through
* @param tags Array of tags to search for in publications
* @param existingEventIds Set of existing event IDs to avoid duplicates
* @param baseEvents Array of base events to check for existing content
* @param debug Optional debug function for logging
* @returns Promise resolving to publications and content events
*/
export function findTaggedEventsInFetched(
allEvents: NDKEvent[],
tags: string[],
existingEventIds: Set<string>,
baseEvents: NDKEvent[],
debug?: (...args: any[]) => void
): TagExpansionResult {
const log = debug || console.debug;
log("Searching through already fetched events for tags:", tags);
// Find publications in allEvents that have the specified tags
const taggedPublications = allEvents.filter(event => {
if (event.kind !== INDEX_EVENT_KIND) return false;
if (existingEventIds.has(event.id)) return false; // Skip base events
// Check if event has any of the specified tags
const eventTags = event.getMatchingTags("t").map(tag => tag[1]);
return tags.some(tag => eventTags.includes(tag));
});
const newPublications = taggedPublications;
log("Found", newPublications.length, "publications in fetched events");
// For content events, also search in allEvents
const existingContentDTags = new Set(
baseEvents
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind))
.map(e => e.tagValue("d"))
.filter(d => d !== undefined)
);
const contentEventDTags = new Set<string>();
newPublications.forEach((event: NDKEvent) => {
const aTags = event.getMatchingTags("a");
aTags.forEach((tag: string[]) => {
// Parse the 'a' tag identifier: kind:pubkey:d-tag
if (tag[1]) {
const parts = tag[1].split(':');
if (parts.length >= 3) {
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons
if (!existingContentDTags.has(dTag)) {
contentEventDTags.add(dTag);
}
}
}
});
});
// Find content events in allEvents
const newContentEvents = allEvents.filter(event => {
if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false;
const dTag = event.tagValue("d");
return dTag !== undefined && contentEventDTags.has(dTag);
});
return {
publications: newPublications,
contentEvents: newContentEvents
};
}
/**
* Fetches profiles for new events and updates progress
*
* @param newPublications Array of new publication events
* @param newContentEvents Array of new content events
* @param onProgressUpdate Callback to update progress state
* @param debug Optional debug function for logging
* @returns Promise that resolves when profile fetching is complete
*/
export async function fetchProfilesForNewEvents(
newPublications: NDKEvent[],
newContentEvents: NDKEvent[],
onProgressUpdate: (progress: { current: number; total: number } | null) => void,
debug?: (...args: any[]) => void
): Promise<void> {
const log = debug || console.debug;
// Extract pubkeys from new events
const newPubkeys = extractPubkeysFromEvents([...newPublications, ...newContentEvents]);
if (newPubkeys.size > 0) {
log("Fetching profiles for", newPubkeys.size, "new pubkeys from tag expansion");
onProgressUpdate({ current: 0, total: newPubkeys.size });
await batchFetchProfiles(Array.from(newPubkeys), (fetched, total) => {
onProgressUpdate({ current: fetched, total });
});
onProgressUpdate(null);
}
}

774
src/routes/visualize/+page.svelte

@ -6,17 +6,37 @@ @@ -6,17 +6,37 @@
-->
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state";
import { visualizationConfig, type EventKindConfig } from "$lib/stores/visualizationConfig";
import { filterByDisplayLimits, detectMissingEvents, buildCoordinateMap } from "$lib/utils/displayLimits";
import type { PageData } from './$types';
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache";
import { activePubkey } from "$lib/ndk";
// Import utility functions for tag-based event fetching
// These functions handle the complex logic of finding publications by tags
// and extracting their associated content events
import {
fetchTaggedEventsFromRelays,
findTaggedEventsInFetched,
fetchProfilesForNewEvents
} from "$lib/utils/tag_event_fetch";
import { deduplicateAndCombineEvents } from "$lib/utils/eventDeduplication";
import type { EventCounts } from "$lib/types";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const DEBUG = true; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818];
// Props from load function
let { data } = $props<{ data: PageData }>();
/**
* Debug logging function that only logs when DEBUG is true
*/
@ -27,29 +47,377 @@ @@ -27,29 +47,377 @@
}
// State
let events: NDKEvent[] = [];
let loading = true;
let error: string | null = null;
let showSettings = false;
let allEvents = $state<NDKEvent[]>([]); // All fetched events
let events = $derived.by(() => {
if (allEvents.length > 0) {
const filtered = filterByDisplayLimits(allEvents, $visualizationConfig);
debug("Derived events update:", { allEvents: allEvents.length, filtered: filtered.length });
return filtered;
}
return [];
}); // Events to display (filtered by limits)
let loading = $state(true);
let error = $state<string | null>(null);
let showSettings = $state(false);
let baseEvents = $state<NDKEvent[]>([]); // Store original events before expansion
let missingEventIds = $derived.by(() => {
if (allEvents.length > 0) {
const eventIds = new Set(allEvents.map(e => e.id));
const coordinateMap = buildCoordinateMap(allEvents);
const missing = detectMissingEvents(events, eventIds, coordinateMap);
debug("Derived missingEventIds update:", {
allEvents: allEvents.length,
events: events.length,
missing: missing.size,
coordinates: coordinateMap.size
});
return missing;
}
return 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
let followListEvents = $state<NDKEvent[]>([]); // Store follow list events separately
// Profile loading progress
let profileLoadingProgress = $state<{current: number, total: number} | null>(null);
let profileLoadingMessage = $derived(
profileLoadingProgress
? `Loading profiles: ${profileLoadingProgress.current}/${profileLoadingProgress.total}`
: null
);
// Profile stats for EventTypeConfig
let profileStats = $state<{totalFetched: number, displayLimit: number}>({
totalFetched: 0,
displayLimit: 50
});
// Event counts from all events (not just filtered)
let allEventCounts = $derived.by(() => {
const counts: EventCounts = {};
allEvents.forEach((event: NDKEvent) => {
if (event.kind !== undefined) {
counts[event.kind] = (counts[event.kind] || 0) + 1;
}
});
debug("All event counts:", counts);
return counts;
});
/**
* Fetches follow lists (kind 3) with depth expansion
*/
async function fetchFollowLists(config: EventKindConfig): Promise<NDKEvent[]> {
const depth = config.depth || 0;
const allFollowEvents: NDKEvent[] = [];
const processedPubkeys = new Set<string>();
debug(`Fetching kind 3 follow lists with limit ${config.limit}, depth ${depth}`);
// If limit is 0, don't fetch any follow lists
if (config.limit === 0) {
debug("Follow list limit is 0, skipping fetch");
return [];
}
// Get the current user's pubkey
const currentUserPubkey = get(activePubkey);
if (!currentUserPubkey) {
console.warn("No logged-in user, cannot fetch user's follow list");
return [];
}
// If limit is 1, only fetch the current user's follow list
if (config.limit === 1) {
const userFollowList = await $ndkInstance.fetchEvents({
kinds: [3],
authors: [currentUserPubkey],
limit: 1
});
if (userFollowList.size === 0) {
console.warn("User has no follow list");
return [];
}
const userFollowEvent = Array.from(userFollowList)[0];
allFollowEvents.push(userFollowEvent);
processedPubkeys.add(currentUserPubkey);
debug(`Fetched user's follow list`);
} else {
// If limit > 1, fetch the user's follow list plus additional ones from people they follow
const userFollowList = await $ndkInstance.fetchEvents({
kinds: [3],
authors: [currentUserPubkey],
limit: 1
});
if (userFollowList.size === 0) {
console.warn("User has no follow list");
return [];
}
const userFollowEvent = Array.from(userFollowList)[0];
allFollowEvents.push(userFollowEvent);
processedPubkeys.add(currentUserPubkey);
// Extract followed pubkeys
const followedPubkeys: string[] = [];
userFollowEvent.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.push(tag[1]);
}
});
debug(`User follows ${followedPubkeys.length} people`);
// Fetch additional follow lists from people you follow
if (followedPubkeys.length > 0) {
const additionalLimit = config.limit - 1; // We already have the user's
const pubkeysToFetch = followedPubkeys.slice(0, additionalLimit);
debug(`Fetching ${pubkeysToFetch.length} additional follow lists (total limit: ${config.limit})`);
const additionalFollowLists = await $ndkInstance.fetchEvents({
kinds: [3],
authors: pubkeysToFetch
});
allFollowEvents.push(...Array.from(additionalFollowLists));
// Mark these as processed
additionalFollowLists.forEach(event => {
processedPubkeys.add(event.pubkey);
});
debug(`Fetched ${additionalFollowLists.size} additional follow lists`);
}
}
// If depth > 0, we need to fetch follow lists of follows (recursively)
if (depth > 0) {
// Start with all pubkeys from fetched follow lists
let currentLevelPubkeys: string[] = [];
allFollowEvents.forEach(event => {
event.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1] && !processedPubkeys.has(tag[1])) {
currentLevelPubkeys.push(tag[1]);
}
});
});
// Fetch additional levels based on depth
for (let level = 1; level <= depth; level++) {
if (currentLevelPubkeys.length === 0) break;
debug(`Fetching level ${level} follow lists for ${currentLevelPubkeys.length} pubkeys`);
// Fetch follow lists for this level
const levelFollowLists = await $ndkInstance.fetchEvents({
kinds: [3],
authors: currentLevelPubkeys
});
const nextLevelPubkeys: string[] = [];
levelFollowLists.forEach(event => {
allFollowEvents.push(event);
processedPubkeys.add(event.pubkey);
// Extract pubkeys for next level
if (level < depth) {
event.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1] && !processedPubkeys.has(tag[1])) {
nextLevelPubkeys.push(tag[1]);
}
});
}
});
currentLevelPubkeys = nextLevelPubkeys;
}
}
debug(`Fetched ${allFollowEvents.length} follow lists total`);
// Store follow lists separately for tag anchor use
followListEvents = [...allFollowEvents];
return allFollowEvents;
}
/**
* Fetches events from the Nostr network
*
* This function fetches index events and their referenced content events,
* filters them according to NIP-62, and combines them for visualization.
* This function orchestrates the fetching of events through multiple steps:
* 1. Setup configuration and loading state
* 2. Fetch non-publication events (kinds 1, 3, etc.)
* 3. Fetch publication index events
* 4. Extract and fetch content events
* 5. Deduplicate and combine all events
* 6. Fetch profiles for discovered pubkeys
* 7. Apply display limits and finalize
*/
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;
// Step 1: Fetch index events
// Step 1: Setup configuration and loading state
const { allConfigs, publicationConfigs, otherConfigs, kind0Config } = setupFetchConfiguration();
// Step 2: Fetch non-publication events
const nonPublicationEvents = await fetchNonPublicationEvents(otherConfigs);
// Step 3: Fetch publication index events
const validIndexEvents = await fetchPublicationIndexEvents(publicationConfigs);
// Step 4: Extract and fetch content events
const contentEvents = await fetchContentEvents(validIndexEvents, publicationConfigs);
// Step 5: Deduplicate and combine all events
// Combine all events (relays handle deduplication)
const combinedEvents = [
...nonPublicationEvents,
...Array.from(validIndexEvents),
...Array.from(contentEvents)
];
debug("Combined events:", { combinedEvents: combinedEvents.length });
// Update state
allEvents = combinedEvents;
followListEvents = [];
baseEvents = [...allEvents]; // Store base events for tag expansion
// Step 6: Fetch profiles for discovered pubkeys
const eventsWithProfiles = await fetchProfilesForEvents(combinedEvents, kind0Config);
// Step 7: Apply display limits and finalize
finalizeEventFetch(eventsWithProfiles);
} catch (e) {
console.error("Error fetching events:", e);
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);
}
}
/**
* Step 1: Setup configuration and loading state
*/
function setupFetchConfiguration() {
const config = get(visualizationConfig);
const allConfigs = config.eventConfigs;
debug("All event configs:", allConfigs);
debug("Enabled kinds:", allConfigs.filter(ec => ec.enabled !== false).map(ec => ec.kind));
// Set loading event kinds for display (show all being loaded)
loadingEventKinds = allConfigs.map(ec => ({
kind: ec.kind,
limit: ec.limit
}));
// Separate publication kinds from other kinds
const publicationKinds = [30040, 30041, 30818];
const publicationConfigs = allConfigs.filter(ec => publicationKinds.includes(ec.kind));
const otherConfigs = allConfigs.filter(ec => !publicationKinds.includes(ec.kind));
// Find kind 0 config for profile fetching
const kind0Config = otherConfigs.find(c => c.kind === 0);
return { allConfigs, publicationConfigs, otherConfigs, kind0Config };
}
/**
* Step 2: Fetch non-publication events (kinds 1, 3, etc. but NOT kind 0)
*/
async function fetchNonPublicationEvents(otherConfigs: any[]): Promise<NDKEvent[]> {
const nonProfileConfigs = otherConfigs.filter(c => c.kind !== 0);
let allFetchedEvents: NDKEvent[] = [];
if (nonProfileConfigs.length > 0) {
debug("Fetching non-publication events (excluding profiles):", nonProfileConfigs);
for (const config of nonProfileConfigs) {
try {
// Special handling for kind 3 (follow lists)
if (config.kind === 3) {
const followEvents = await fetchFollowLists(config);
allFetchedEvents.push(...followEvents);
} else {
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);
}
}
}
return allFetchedEvents;
}
/**
* Step 3: Fetch publication index events
*/
async function fetchPublicationIndexEvents(publicationConfigs: any[]): Promise<Set<NDKEvent>> {
const shouldFetchIndex = publicationConfigs.some(ec => ec.kind === INDEX_EVENT_KIND);
if (data.eventId) {
// Fetch specific publication
debug(`Fetching specific publication: ${data.eventId}`);
const event = await $ndkInstance.fetchEvent(data.eventId);
if (!event) {
throw new Error(`Publication not found: ${data.eventId}`);
}
if (event.kind !== INDEX_EVENT_KIND) {
throw new Error(`Event ${data.eventId} is not a publication index (kind ${INDEX_EVENT_KIND})`);
}
return new Set([event]);
} else if (!shouldFetchIndex) {
debug("Index events (30040) are disabled, skipping fetch");
return new Set();
} 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,
@ -59,60 +427,306 @@ @@ -59,60 +427,306 @@
);
debug("Fetched index events:", indexEvents.size);
// Step 2: Filter valid index events according to NIP-62
// Filter valid index events according to NIP-62
const validIndexEvents = filterValidIndexEvents(indexEvents);
debug("Valid index events after filtering:", validIndexEvents.size);
// Step 3: Extract content event IDs from index events
const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => {
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = event.getMatchingTags("a");
if (tags.length === 0) {
tags = event.getMatchingTags("e");
return validIndexEvents;
}
}
debug(
`Event ${event.id} has ${tags.length} tags (${tags.length > 0 ? (event.getMatchingTags("a").length > 0 ? "a" : "e") : "none"})`,
/**
* Step 4: Extract and fetch content events
*/
async function fetchContentEvents(validIndexEvents: Set<NDKEvent>, publicationConfigs: any[]): Promise<Set<NDKEvent>> {
// Extract content event references from index events
const contentReferences = extractContentReferences(validIndexEvents);
debug("Content references to fetch:", contentReferences.size);
// Fetch the referenced content events with author filter
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
const referencesByAuthor = groupContentReferencesByAuthor(contentReferences, enabledContentKinds);
// Fetch events for each author
const contentEventPromises = Array.from(referencesByAuthor.entries()).map(
async ([author, refs]) => {
const dTags = [...new Set(refs.map(r => r.dTag))]; // Dedupe d-tags
return $ndkInstance.fetchEvents({
kinds: enabledContentKinds, // Only fetch enabled kinds
authors: [author],
"#d": dTags,
});
}
);
tags.forEach((tag) => {
const eventId = tag[3];
if (eventId) {
contentEventIds.add(eventId);
const contentEventSets = await Promise.all(contentEventPromises);
// Combine all content events (relays handle deduplication)
const contentEvents = new Set<NDKEvent>();
contentEventSets.forEach(eventSet => {
eventSet.forEach(event => contentEvents.add(event));
});
debug("Fetched content events:", contentEvents.size);
return contentEvents;
}
/**
* Extract content event references from index events
*/
function extractContentReferences(validIndexEvents: Set<NDKEvent>): Map<string, { kind: number; pubkey: string; dTag: string }> {
const contentReferences = new Map<string, { kind: number; pubkey: string; dTag: string }>();
validIndexEvents.forEach((event) => {
const aTags = event.getMatchingTags("a");
debug(`Event ${event.id} has ${aTags.length} a-tags`);
aTags.forEach((tag) => {
// Parse the 'a' tag identifier: kind:pubkey:d-tag
if (tag[1]) {
const parts = tag[1].split(':');
if (parts.length >= 3) {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons
// Only add if it's a content event kind we're interested in
if (CONTENT_EVENT_KINDS.includes(kind)) {
const key = `${kind}:${pubkey}:${dTag}`;
contentReferences.set(key, { kind, pubkey, dTag });
}
}
}
});
});
return contentReferences;
}
/**
* Group content references by author for efficient fetching
*/
function groupContentReferencesByAuthor(
contentReferences: Map<string, { kind: number; pubkey: string; dTag: string }>,
enabledContentKinds: number[]
): Map<string, Array<{ kind: number; dTag: string }>> {
const referencesByAuthor = new Map<string, Array<{ kind: number; dTag: string }>>();
contentReferences.forEach(({ kind, pubkey, dTag }) => {
// Only include references for enabled kinds
if (enabledContentKinds.includes(kind)) {
if (!referencesByAuthor.has(pubkey)) {
referencesByAuthor.set(pubkey, []);
}
referencesByAuthor.get(pubkey)!.push({ kind, dTag });
}
});
debug("Content event IDs to fetch:", contentEventIds.size);
// Step 4: Fetch the referenced content events
debug(
`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(", ")})`,
return referencesByAuthor;
}
// Removed deduplication import - relays handle this properly
/**
* Step 6: Fetch profiles for discovered pubkeys
*/
async function fetchProfilesForEvents(combinedEvents: NDKEvent[], kind0Config: any): Promise<NDKEvent[]> {
// Extract all pubkeys and fetch profiles
debug("Extracting pubkeys from all events");
// Use the utility function to extract ALL pubkeys (authors + p tags + content)
const allPubkeys = extractPubkeysFromEvents(combinedEvents);
// Check if follow list is configured with limit > 0
const allConfigs = get(visualizationConfig).eventConfigs;
const followListConfig = allConfigs.find(c => c.kind === 3);
const shouldIncludeFollowPubkeys = followListConfig && followListConfig.limit > 0;
// Add pubkeys from follow lists only if follow list limit > 0
if (shouldIncludeFollowPubkeys && followListEvents.length > 0) {
debug("Including pubkeys from follow lists (limit > 0)");
followListEvents.forEach(event => {
if (event.pubkey) allPubkeys.add(event.pubkey);
event.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1]) {
allPubkeys.add(tag[1]);
}
});
});
} else if (!shouldIncludeFollowPubkeys && followListEvents.length > 0) {
debug("Excluding follow list pubkeys (limit = 0, only fetching event authors)");
}
debug("Profile extraction complete:", {
totalPubkeys: allPubkeys.size,
fromEvents: combinedEvents.length,
fromFollowLists: followListEvents.length
});
// Fetch ALL profiles if kind 0 is enabled
let profileEvents: NDKEvent[] = [];
if (kind0Config) {
debug("Fetching profiles for all discovered pubkeys");
// Update progress during fetch
profileLoadingProgress = { current: 0, total: allPubkeys.size };
profileEvents = await batchFetchProfiles(
Array.from(allPubkeys),
(fetched, total) => {
profileLoadingProgress = { current: fetched, total };
}
);
const contentEvents = await $ndkInstance.fetchEvents(
{
kinds: CONTENT_EVENT_KINDS,
ids: Array.from(contentEventIds),
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
profileLoadingProgress = null;
debug("Profile fetch complete, fetched", profileEvents.length, "profiles");
// Add profile events to allEvents
allEvents = [...combinedEvents, ...profileEvents];
// Update profile stats for display
// Use the total number of pubkeys, not just newly fetched profiles
profileStats = {
totalFetched: allPubkeys.size,
displayLimit: kind0Config.limit
};
} else {
allEvents = [...combinedEvents];
}
return allEvents;
}
/**
* Step 7: Apply display limits and finalize
*/
function finalizeEventFetch(eventsWithProfiles: NDKEvent[]) {
// Update allEvents - events will be automatically filtered via $derived
allEvents = eventsWithProfiles;
debug("Total events fetched:", eventsWithProfiles.length);
debug("Events displayed:", events.length);
debug("Missing event IDs:", missingEventIds.size);
debug("About to set loading to false");
debug("Current loading state:", loading);
}
/**
* Updates final state after tag expansion (display limits, missing events)
*
* @param newPublications Array of new publication events
* @param newContentEvents Array of new content events
*/
function updateFinalState(newPublications: NDKEvent[], newContentEvents: NDKEvent[]) {
debug("Events after expansion:", {
base: baseEvents.length,
newPubs: newPublications.length,
newContent: newContentEvents.length,
totalFetched: allEvents.length,
displayed: events.length,
missing: missingEventIds.size,
searchMode: $visualizationConfig.searchThroughFetched ? "fetched" : "relays"
});
}
/**
* Handles tag expansion to fetch related publications
*
* REFACTORED: This function has been broken down into smaller, focused steps:
* 1. Fetch/find tagged events using utility functions
* 2. Deduplicate events by coordinate using utility function
* 3. Fetch profiles for new events using utility function
* 4. Update final state (display limits, missing events)
*/
async function handleTagExpansion(tags: string[]) {
debug("Handling tag expansion", { tags, searchThroughFetched: $visualizationConfig.searchThroughFetched });
if (tags.length === 0) {
// Reset to base events only
allEvents = [...baseEvents];
return;
}
try {
// Don't show loading spinner for incremental updates
error = null;
// Keep track of existing event IDs to avoid duplicates
const existingEventIds = new Set(baseEvents.map(e => e.id));
let newPublications: NDKEvent[] = [];
let newContentEvents: NDKEvent[] = [];
// Step 1: Fetch or find tagged events using utility functions
if ($visualizationConfig.searchThroughFetched) {
// Search through already fetched events only
const result = findTaggedEventsInFetched(
allEvents,
tags,
existingEventIds,
baseEvents,
debug
);
debug("Fetched content events:", contentEvents.size);
newPublications = result.publications;
newContentEvents = result.contentEvents;
} else {
// Fetch from relays using the utility function
const result = await fetchTaggedEventsFromRelays(
tags,
existingEventIds,
baseEvents,
debug
);
newPublications = result.publications;
newContentEvents = result.contentEvents;
}
// Step 2: Deduplicate events by coordinate using existing utility function
allEvents = deduplicateAndCombineEvents(
baseEvents, // nonPublicationEvents
new Set(newPublications), // validIndexEvents
new Set(newContentEvents) // contentEvents
);
// Step 3: Fetch profiles for new events using utility function
await fetchProfilesForNewEvents(
newPublications,
newContentEvents,
(progress: { current: number; total: number } | null) => { profileLoadingProgress = progress; },
debug
);
// Step 4: Update final state (display limits, missing events)
updateFinalState(newPublications, newContentEvents);
// Step 5: Combine both sets of events
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
debug("Total events for visualization:", events.length);
} catch (e) {
console.error("Error fetching events:", e);
console.error("Error expanding tags:", e);
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
/**
* Clears all accumulated events
*/
function clearEvents() {
allEvents = [];
baseEvents = [];
followListEvents = [];
// Clear node positions cache in EventNetwork
// This will be handled by the component when events change
}
// Fetch events when component mounts
onMount(() => {
debug("Component mounted");
@ -123,15 +737,18 @@ @@ -123,15 +737,18 @@
<div class="leather w-full p-4 relative">
<!-- Header with title and settings button -->
<div class="flex items-center mb-4">
<h1 class="h-leather">Publication Network</h1>
<h1 class="h-leather">
{data.eventId ? 'Publication Visualization' : 'Publication Network'}
</h1>
</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
aria-hidden="true"
class="w-8 h-8 text-gray-300 animate-spin dark:text-gray-500 fill-blue-600"
class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -147,6 +764,39 @@ @@ -147,6 +764,39 @@
</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>
<!-- Profile loading progress bar -->
{#if profileLoadingProgress}
<div class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{profileLoadingMessage}
</p>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(profileLoadingProgress.current / profileLoadingProgress.total) * 100}%"
></div>
</div>
</div>
{/if}
</div>
</div>
<!-- Error message -->
{:else if error}
@ -159,14 +809,40 @@ @@ -159,14 +809,40 @@
<button
type="button"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
on:click={fetchEvents}
onclick={fetchEvents}
>
Retry
</button>
</div>
<!-- Network visualization -->
{:else}
<!-- Profile loading progress bar (overlay when loading profiles after initial load) -->
{#if profileLoadingProgress}
<div class="absolute top-0 left-0 right-0 z-10 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{profileLoadingMessage}
</p>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {(profileLoadingProgress.current / profileLoadingProgress.total) * 100}%"
></div>
</div>
</div>
</div>
{/if}
<!-- Event network visualization -->
<EventNetwork {events} onupdate={fetchEvents} />
<EventNetwork
{events}
{followListEvents}
totalCount={allEvents.length}
onupdate={fetchEvents}
onclear={clearEvents}
onTagExpansionChange={handleTagExpansion}
{profileStats}
{allEventCounts}
/>
{/if}
</div>

9
src/routes/visualize/+page.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const eventId = url.searchParams.get('event');
return {
eventId
};
};

66
src/styles/visualize.css

@ -109,4 +109,70 @@ @@ -109,4 +109,70 @@
.tooltip-help-text {
@apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic;
}
/* Star network visualization styles */
.star-center-node {
@apply transition-all duration-300;
stroke-width: 3px;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.2));
}
.star-center-node:hover {
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
transform: scale(1.05);
}
.network-node-content {
@apply transition-all duration-200;
}
.network-node-content:hover {
@apply transform scale-110;
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3));
}
/* Tag anchor nodes */
.tag-anchor-node {
@apply transition-all duration-300;
stroke: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
cursor: default !important;
}
.tag-anchor-node:hover {
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.6));
transform: scale(1.1);
}
/* Ensure drag cursor doesn't appear on tag anchors */
.node.drag-circle {
cursor: move;
}
.node.tag-anchor-node .drag-circle {
cursor: default !important;
}
/* Tag grid layout in legend */
.tag-grid {
display: grid;
gap: 0.5rem; /* Change this value to adjust spacing between columns and rows */
column-gap: 1.75rem; /* Use this to set column spacing independently */
row-gap: 0.5rem; /* Use this to set row spacing independently */
width: 100%;
}
.tag-grid-item {
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
.tag-grid-item .legend-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
}

20
tests/e2e/example.pw.spec.ts

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
import { test, expect } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Click the get started link.
await page.getByRole("link", { name: "Get started" }).click();
// Expects page to have a heading with the name of Installation.
await expect(
page.getByRole("heading", { name: "Installation" }),
).toBeVisible();
});

115
tests/integration/markupIntegration.test.ts

@ -1,115 +0,0 @@ @@ -1,115 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseBasicmarkup } from "../../src/lib/utils/markup/basicMarkupParser";
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser";
import { readFileSync } from "fs";
import { join } from "path";
const testFilePath = join(__dirname, "./markupTestfile.md");
const md = readFileSync(testFilePath, "utf-8");
describe("Markup Integration Test", () => {
it("parses markupTestfile.md with the basic parser", async () => {
const output = await parseBasicmarkup(md);
// Headers (should be present as raw text, not HTML tags)
expect(output).toContain("This is a test");
expect(output).toContain("# This is a test");
expect(output).toContain("### Disclaimer");
// Unordered list
expect(output).toContain("<ul");
expect(output).toContain("but");
// Ordered list
expect(output).toContain("<ol");
expect(output).toContain("first");
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain("<blockquote");
expect(output).toContain("This is important information");
// Inline code
expect(output).toContain(
'<div class="leather min-h-full w-full flex flex-col items-center">',
);
// Images
expect(output).toMatch(
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/,
);
// Links
expect(output).toMatch(
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/,
);
// Hashtags
expect(output).toContain("text-primary-600");
// Nostr identifiers (should be Alexandria links)
expect(output).toContain(
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z",
);
// Wikilinks
expect(output).toContain("wikilink");
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
expect(output).not.toMatch(/utm_/);
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule (should be present as --- in basic)
expect(output).toContain("---");
// Footnote references (should be present as [^1] in basic)
expect(output).toContain("[^1]");
// Table (should be present as | Syntax | Description | in basic)
expect(output).toContain("| Syntax | Description |");
});
it("parses markupTestfile.md with the advanced parser", async () => {
const output = await parseAdvancedmarkup(md);
// Headers
expect(output).toContain("<h1");
expect(output).toContain("<h2");
expect(output).toContain("Disclaimer");
// Unordered list
expect(output).toContain("<ul");
expect(output).toContain("but");
// Ordered list
expect(output).toContain("<ol");
expect(output).toContain("first");
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain("<blockquote");
expect(output).toContain("This is important information");
// Inline code
expect(output).toMatch(
/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s,
);
// Images
expect(output).toMatch(
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/,
);
// Links
expect(output).toMatch(
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/,
);
// Hashtags
expect(output).toContain("text-primary-600");
// Nostr identifiers (should be Alexandria links)
expect(output).toContain(
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z",
);
// Wikilinks
expect(output).toContain("wikilink");
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
expect(output).not.toMatch(/utm_/);
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule
expect(output).toContain("<hr");
// Footnote references and section
expect(output).toContain("Footnotes");
expect(output).toMatch(/<li id=\"fn-1\">/);
// Table
expect(output).toContain("<table");
// Code blocks
expect(output).toContain("<pre");
});
});

267
tests/integration/markupTestfile.md

@ -1,267 +0,0 @@ @@ -1,267 +0,0 @@
# This is a test
### Disclaimer
It is _only_ a test, for **sure**. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of _one asterisk_.[^1]
# H1
## H2
### H3
#### H4
##### H5
###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser.
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]]
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
> This is important information
> This is multiple
> lines of
> important information
> with a second[^2] footnote.
> [^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984.
This is a youtube link
https://www.youtube.com/watch?v=9aqVxNCpx9s
And here is a link with tracking tokens:
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU
This is an unordered list:
- but
- not
- really
This is an unordered list with nesting:
- but
- not
- really
- but
- yes,
- really
## More testing
An ordered list:
1. first
2. second
3. third
Let's nest that:
1. first 2. second indented
2. third 4. fourth indented 5. fifth indented even more 6. sixth under the fourth 7. seventh under the sixth
3. eighth under the third
This is ordered and unordered mixed:
1. first 2. second indented
2. third
- make this a bullet point 4. fourth indented even more
- second bullet point
Here is a horizontal rule:
---
Try embedded a nostr note with nevent:
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske
Here a note with no prefix
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz
Here with a naddr:
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz
Here's a nonsense one:
nevent123
And a nonsense one with a prefix:
nostr:naddrwhatever
And some Nostr addresses that should be preserved and have a internal link appended:
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk
URL with a tracking parameter, no markup:
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale
Image without markup:
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
https://next-alexandria.gitcitadel.eu/events?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags:
https://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a markup tag: [markup link title](https://next-alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or
```
in a code block
```
You can even use a multi-line code block, with a json tag.
````json
{
"created_at": 1745038670,
"content": "# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n![Nostr logo](https://user-images.githubusercontent.com/99301796/219900773-d6d02038-e2a0-4334-9f28-c14d40ab6fe7.png)\n\n[^1]: this is a footnote\n[^2]: so is this",
"tags": [
["subject", "test"],
["alt", "git repository issue: test"],
[
"a",
"30617:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:Alexandria",
"",
"root"
],
["p", "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],
["t", "gitstuff"]
],
"kind": 1621,
"pubkey": "dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319",
"id": "e78a689369511fdb3c36b990380c2d8db2b5e62f13f6b836e93ef5a09611afe8",
"sig": "7a2b3a6f6f61b6ea04de1fe873e46d40f2a220f02cdae004342430aa1df67647a9589459382f22576c651b3d09811546bbd79564cf472deaff032f137e94a865"
}
````
C or C++:
```cpp
bool getBit(int num, int i) {
return ((num & (1<<i)) != 0);
}
```
Asciidoc:
```adoc
= Header 1
preamble goes here
== Header 2
some more text
```
Gherkin:
```gherkin
Feature: Account Holder withdraws cash
Scenario: Account has sufficient funds
Given The account balance is $100
And the card is valid
And the machine contains enough money
When the Account Holder requests $20
Then the ATM should dispense $20
And the account balance should be $80
And the card should be returned
```
Go:
```go
package main
import (
"fmt"
"bufio"
"os"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Enter text: ")
scanner.Scan()
input := scanner.Text()
fmt.Println("You entered:", input)
}
```
or even markup:
```md
# A H1 Header
Paragraphs are separated by a blank line.
2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists
look like:
- this one[^some reference text]
- that one
- the other one
Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.
> Block quotes are
> written like so.
>
> They can span multiple paragraphs,
> if you like.
```
Test out some emojis :heart: and :trophy:
#### Here is an image![^some reference text]
![Nostr logo](https://user-images.githubusercontent.com/99301796/219900773-d6d02038-e2a0-4334-9f28-c14d40ab6fe7.png)
### I went ahead and implemented tables, too.
A neat table[^some reference text]:
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
A messy table (should render the same as above):
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
Here is a table without a header row:
| Sometimes | you don't |
| need a | header |
| just | pipes |
[^1]:
this is a footnote
[^some reference text]: this is a footnote that isn't a number

131
tests/unit/advancedMarkupParser.test.ts

@ -1,131 +0,0 @@ @@ -1,131 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser";
function stripWS(str: string) {
return str.replace(/\s+/g, " ").trim();
}
describe("Advanced Markup Parser", () => {
it("parses headers (ATX and Setext)", async () => {
const input = "# H1\nText\n\nH2\n====\n";
const output = await parseAdvancedmarkup(input);
expect(stripWS(output)).toContain("H1");
expect(stripWS(output)).toContain("H2");
});
it("parses bold, italic, and strikethrough", async () => {
const input =
"*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<strong>bold</strong>");
expect(output).toContain("<em>italic</em>");
expect(output).toContain('<del class="line-through">strikethrough</del>');
});
it("parses blockquotes", async () => {
const input = "> quote";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<blockquote");
expect(output).toContain("quote");
});
it("parses multi-line blockquotes", async () => {
const input = "> quote\n> quote";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<blockquote");
expect(output).toContain("quote");
expect(output).toContain("quote");
});
it("parses unordered lists", async () => {
const input = "* a\n* b";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<ul");
expect(output).toContain("a");
expect(output).toContain("b");
});
it("parses ordered lists", async () => {
const input = "1. one\n2. two";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<ol");
expect(output).toContain("one");
expect(output).toContain("two");
});
it("parses links and images", async () => {
const input = "[link](https://example.com) ![alt](https://img.com/x.png)";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<a");
expect(output).toContain("<img");
});
it("parses hashtags", async () => {
const input = "#hashtag";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("text-primary-600");
expect(output).toContain("#hashtag");
});
it("parses nostr identifiers", async () => {
const input =
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
const output = await parseAdvancedmarkup(input);
expect(output).toContain(
"./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
);
});
it("parses emoji shortcodes", async () => {
const input = "hello :smile:";
const output = await parseAdvancedmarkup(input);
expect(output).toMatch(/😄|:smile:/);
});
it("parses wikilinks", async () => {
const input = "[[Test Page|display]]";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("wikilink");
expect(output).toContain("display");
});
it("parses tables (with and without headers)", async () => {
const input = `| Syntax | Description |\n|--------|-------------|\n| Header | Title |\n| Paragraph | Text |\n\n| a | b |\n| c | d |`;
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<table");
expect(output).toContain("Header");
expect(output).toContain("a");
});
it("parses code blocks (with and without language)", async () => {
const input = "```js\nconsole.log(1);\n```\n```\nno lang\n```";
const output = await parseAdvancedmarkup(input);
const textOnly = output.replace(/<[^>]+>/g, "");
expect(output).toContain("<pre");
expect(textOnly).toContain("console.log(1);");
expect(textOnly).toContain("no lang");
});
it("parses horizontal rules", async () => {
const input = "---";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<hr");
});
it("parses footnotes (references and section)", async () => {
const input = "Here is a footnote[^1].\n\n[^1]: This is the footnote.";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("Footnotes");
expect(output).toContain("This is the footnote");
expect(output).toContain("fn-1");
});
it("parses unordered lists with '-' as bullet", async () => {
const input = "- item one\n- item two\n - nested item\n- item three";
const output = await parseAdvancedmarkup(input);
expect(output).toContain("<ul");
expect(output).toContain("item one");
expect(output).toContain("nested item");
expect(output).toContain("item three");
});
});

92
tests/unit/basicMarkupParser.test.ts

@ -1,92 +0,0 @@ @@ -1,92 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseBasicmarkup } from "../../src/lib/utils/markup/basicMarkupParser";
// Helper to strip whitespace for easier comparison
function stripWS(str: string) {
return str.replace(/\s+/g, " ").trim();
}
describe("Basic Markup Parser", () => {
it("parses ATX and Setext headers", async () => {
const input = "# H1\nText\n\nH2\n====\n";
const output = await parseBasicmarkup(input);
expect(stripWS(output)).toContain("H1");
expect(stripWS(output)).toContain("H2");
});
it("parses bold, italic, and strikethrough", async () => {
const input =
"*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~";
const output = await parseBasicmarkup(input);
expect(output).toContain("<strong>bold</strong>");
expect(output).toContain("<em>italic</em>");
expect(output).toContain('<del class="line-through">strikethrough</del>');
});
it("parses blockquotes", async () => {
const input = "> quote";
const output = await parseBasicmarkup(input);
expect(output).toContain("<blockquote");
expect(output).toContain("quote");
});
it("parses multi-line blockquotes", async () => {
const input = "> quote\n> quote";
const output = await parseBasicmarkup(input);
expect(output).toContain("<blockquote");
expect(output).toContain("quote");
expect(output).toContain("quote");
});
it("parses unordered lists", async () => {
const input = "* a\n* b";
const output = await parseBasicmarkup(input);
expect(output).toContain("<ul");
expect(output).toContain("a");
expect(output).toContain("b");
});
it("parses ordered lists", async () => {
const input = "1. one\n2. two";
const output = await parseBasicmarkup(input);
expect(output).toContain("<ol");
expect(output).toContain("one");
expect(output).toContain("two");
});
it("parses links and images", async () => {
const input = "[link](https://example.com) ![alt](https://img.com/x.png)";
const output = await parseBasicmarkup(input);
expect(output).toContain("<a");
expect(output).toContain("<img");
});
it("parses hashtags", async () => {
const input = "#hashtag";
const output = await parseBasicmarkup(input);
expect(output).toContain("text-primary-600");
expect(output).toContain("#hashtag");
});
it("parses nostr identifiers", async () => {
const input =
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
const output = await parseBasicmarkup(input);
expect(output).toContain(
"./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
);
});
it("parses emoji shortcodes", async () => {
const input = "hello :smile:";
const output = await parseBasicmarkup(input);
expect(output).toMatch(/😄|:smile:/);
});
it("parses wikilinks", async () => {
const input = "[[Test Page|display]]";
const output = await parseBasicmarkup(input);
expect(output).toContain("wikilink");
expect(output).toContain("display");
});
});

106
tests/unit/nostr_identifiers.test.ts

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import {
isEventId,
isCoordinate,
parseCoordinate,
createCoordinate,
isNostrIdentifier
} from '../../src/lib/utils/nostr_identifiers';
describe('Nostr Identifier Validation', () => {
describe('isEventId', () => {
it('should validate correct hex event IDs', () => {
const validId = 'a'.repeat(64);
expect(isEventId(validId)).toBe(true);
const validIdWithMixedCase = 'A'.repeat(32) + 'f'.repeat(32);
expect(isEventId(validIdWithMixedCase)).toBe(true);
});
it('should reject invalid event IDs', () => {
expect(isEventId('')).toBe(false);
expect(isEventId('abc')).toBe(false);
expect(isEventId('a'.repeat(63))).toBe(false); // too short
expect(isEventId('a'.repeat(65))).toBe(false); // too long
expect(isEventId('g'.repeat(64))).toBe(false); // invalid hex char
});
});
describe('isCoordinate', () => {
it('should validate correct coordinates', () => {
const validCoordinate = `30040:${'a'.repeat(64)}:chapter-1`;
expect(isCoordinate(validCoordinate)).toBe(true);
const coordinateWithColonsInDTag = `30041:${'b'.repeat(64)}:chapter:with:colons`;
expect(isCoordinate(coordinateWithColonsInDTag)).toBe(true);
});
it('should reject invalid coordinates', () => {
expect(isCoordinate('')).toBe(false);
expect(isCoordinate('abc')).toBe(false);
expect(isCoordinate('30040:abc:chapter-1')).toBe(false); // invalid pubkey
expect(isCoordinate('30040:abc')).toBe(false); // missing d-tag
expect(isCoordinate('abc:def:ghi')).toBe(false); // invalid kind
expect(isCoordinate('-1:abc:def')).toBe(false); // negative kind
});
});
describe('parseCoordinate', () => {
it('should parse valid coordinates correctly', () => {
const coordinate = `30040:${'a'.repeat(64)}:chapter-1`;
const parsed = parseCoordinate(coordinate);
expect(parsed).toEqual({
kind: 30040,
pubkey: 'a'.repeat(64),
dTag: 'chapter-1'
});
});
it('should handle d-tags with colons', () => {
const coordinate = `30041:${'b'.repeat(64)}:chapter:with:colons`;
const parsed = parseCoordinate(coordinate);
expect(parsed).toEqual({
kind: 30041,
pubkey: 'b'.repeat(64),
dTag: 'chapter:with:colons'
});
});
it('should return null for invalid coordinates', () => {
expect(parseCoordinate('')).toBeNull();
expect(parseCoordinate('abc')).toBeNull();
expect(parseCoordinate('30040:abc:chapter-1')).toBeNull();
});
});
describe('createCoordinate', () => {
it('should create valid coordinates', () => {
const coordinate = createCoordinate(30040, 'a'.repeat(64), 'chapter-1');
expect(coordinate).toBe(`30040:${'a'.repeat(64)}:chapter-1`);
});
it('should handle d-tags with colons', () => {
const coordinate = createCoordinate(30041, 'b'.repeat(64), 'chapter:with:colons');
expect(coordinate).toBe(`30041:${'b'.repeat(64)}:chapter:with:colons`);
});
});
describe('isNostrIdentifier', () => {
it('should accept valid event IDs', () => {
expect(isNostrIdentifier('a'.repeat(64))).toBe(true);
});
it('should accept valid coordinates', () => {
const coordinate = `30040:${'a'.repeat(64)}:chapter-1`;
expect(isNostrIdentifier(coordinate)).toBe(true);
});
it('should reject invalid identifiers', () => {
expect(isNostrIdentifier('')).toBe(false);
expect(isNostrIdentifier('abc')).toBe(false);
expect(isNostrIdentifier('30040:abc:chapter-1')).toBe(false);
});
});
});

457
tests/unit/relayDeduplication.test.ts

@ -0,0 +1,457 @@ @@ -0,0 +1,457 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import {
deduplicateContentEvents,
deduplicateAndCombineEvents,
isReplaceableEvent,
getEventCoordinate
} from '../../src/lib/utils/eventDeduplication';
// Mock NDKEvent for testing
class MockNDKEvent {
id: string;
kind: number;
pubkey: string;
created_at: number;
content: string;
tags: string[][];
constructor(id: string, kind: number, pubkey: string, created_at: number, dTag: string, content: string = '') {
this.id = id;
this.kind = kind;
this.pubkey = pubkey;
this.created_at = created_at;
this.content = content;
this.tags = [['d', dTag]];
}
tagValue(tagName: string): string | undefined {
const tag = this.tags.find(t => t[0] === tagName);
return tag ? tag[1] : undefined;
}
}
describe('Relay Deduplication Behavior Tests', () => {
let mockEvents: MockNDKEvent[];
beforeEach(() => {
// Create test events with different timestamps
mockEvents = [
// Older version of a publication content event
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old content'),
// Newer version of the same publication content event
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'Updated content'),
// Different publication content event
new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-2', 'Different content'),
// Publication index event (should not be deduplicated)
new MockNDKEvent('event4', 30040, 'pubkey1', 1200, 'book-1', 'Index content'),
// Regular text note (should not be deduplicated)
new MockNDKEvent('event5', 1, 'pubkey1', 1300, '', 'Regular note'),
];
});
describe('Addressable Event Deduplication', () => {
it('should keep only the most recent version of addressable events by coordinate', () => {
// Test the deduplication logic for content events
const eventSets = [new Set(mockEvents.filter(e => e.kind === 30041) as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should have 2 unique coordinates: chapter-1 and chapter-2
expect(result.size).toBe(2);
// Should keep the newer version of chapter-1
const chapter1Event = result.get('30041:pubkey1:chapter-1');
expect(chapter1Event?.id).toBe('event2');
expect(chapter1Event?.content).toBe('Updated content');
// Should keep chapter-2
const chapter2Event = result.get('30041:pubkey1:chapter-2');
expect(chapter2Event?.id).toBe('event3');
});
it('should handle events with missing d-tags gracefully', () => {
const eventWithoutDTag = new MockNDKEvent('event6', 30041, 'pubkey1', 1400, '', 'No d-tag');
eventWithoutDTag.tags = []; // Remove d-tag
const eventSets = [new Set([eventWithoutDTag] as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should not include events without d-tags
expect(result.size).toBe(0);
});
it('should handle events with missing timestamps', () => {
const eventWithoutTimestamp = new MockNDKEvent('event7', 30041, 'pubkey1', 0, 'chapter-3', 'No timestamp');
const eventWithTimestamp = new MockNDKEvent('event8', 30041, 'pubkey1', 1500, 'chapter-3', 'With timestamp');
const eventSets = [new Set([eventWithoutTimestamp, eventWithTimestamp] as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should prefer the event with timestamp
const chapter3Event = result.get('30041:pubkey1:chapter-3');
expect(chapter3Event?.id).toBe('event8');
});
});
describe('Mixed Event Type Deduplication', () => {
it('should only deduplicate addressable events (kinds 30000-39999)', () => {
const result = deduplicateAndCombineEvents(
[mockEvents[4]] as NDKEvent[], // Regular text note
new Set([mockEvents[3]] as NDKEvent[]), // Publication index
new Set([mockEvents[0], mockEvents[1], mockEvents[2]] as NDKEvent[]) // Content events
);
// Should have 4 events total:
// - 1 regular text note (not deduplicated)
// - 1 publication index (not deduplicated)
// - 2 unique content events (deduplicated from 3)
expect(result.length).toBe(4);
// Verify the content events were deduplicated
const contentEvents = result.filter(e => e.kind === 30041);
expect(contentEvents.length).toBe(2);
// Verify the newer version was kept
const newerEvent = contentEvents.find(e => e.id === 'event2');
expect(newerEvent).toBeDefined();
});
it('should handle non-addressable events correctly', () => {
const regularEvents = [
new MockNDKEvent('note1', 1, 'pubkey1', 1000, '', 'Note 1'),
new MockNDKEvent('note2', 1, 'pubkey1', 2000, '', 'Note 2'),
new MockNDKEvent('profile1', 0, 'pubkey1', 1500, '', 'Profile 1'),
];
const result = deduplicateAndCombineEvents(
regularEvents as NDKEvent[],
new Set(),
new Set()
);
// All regular events should be included (no deduplication)
expect(result.length).toBe(3);
});
});
describe('Coordinate System Validation', () => {
it('should correctly identify event coordinates', () => {
const event = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'test-chapter');
const coordinate = getEventCoordinate(event as NDKEvent);
expect(coordinate).toBe('30041:pubkey123:test-chapter');
});
it('should handle d-tags with colons correctly', () => {
const event = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'chapter:with:colons');
const coordinate = getEventCoordinate(event as NDKEvent);
expect(coordinate).toBe('30041:pubkey123:chapter:with:colons');
});
it('should return null for non-replaceable events', () => {
const event = new MockNDKEvent('test', 1, 'pubkey123', 1000, '');
const coordinate = getEventCoordinate(event as NDKEvent);
expect(coordinate).toBeNull();
});
});
describe('Replaceable Event Detection', () => {
it('should correctly identify replaceable events', () => {
const addressableEvent = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'test');
const regularEvent = new MockNDKEvent('test', 1, 'pubkey123', 1000, '');
expect(isReplaceableEvent(addressableEvent as NDKEvent)).toBe(true);
expect(isReplaceableEvent(regularEvent as NDKEvent)).toBe(false);
});
it('should handle edge cases of replaceable event ranges', () => {
const event29999 = new MockNDKEvent('test', 29999, 'pubkey123', 1000, 'test');
const event30000 = new MockNDKEvent('test', 30000, 'pubkey123', 1000, 'test');
const event39999 = new MockNDKEvent('test', 39999, 'pubkey123', 1000, 'test');
const event40000 = new MockNDKEvent('test', 40000, 'pubkey123', 1000, 'test');
expect(isReplaceableEvent(event29999 as NDKEvent)).toBe(false);
expect(isReplaceableEvent(event30000 as NDKEvent)).toBe(true);
expect(isReplaceableEvent(event39999 as NDKEvent)).toBe(true);
expect(isReplaceableEvent(event40000 as NDKEvent)).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle empty event sets', () => {
const result = deduplicateContentEvents([]);
expect(result.size).toBe(0);
});
it('should handle events with null/undefined values', () => {
const invalidEvent = {
id: undefined,
kind: 30041,
pubkey: 'pubkey1',
created_at: 1000,
tagValue: () => undefined, // Return undefined for d-tag
} as unknown as NDKEvent;
const eventSets = [new Set([invalidEvent])];
const result = deduplicateContentEvents(eventSets);
// Should handle gracefully without crashing
expect(result.size).toBe(0);
});
it('should handle events from different authors with same d-tag', () => {
const event1 = new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'same-chapter', 'Author 1');
const event2 = new MockNDKEvent('event2', 30041, 'pubkey2', 1000, 'same-chapter', 'Author 2');
const eventSets = [new Set([event1, event2] as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should have 2 events (different coordinates due to different authors)
expect(result.size).toBe(2);
expect(result.has('30041:pubkey1:same-chapter')).toBe(true);
expect(result.has('30041:pubkey2:same-chapter')).toBe(true);
});
});
});
describe('Relay Behavior Simulation', () => {
it('should simulate what happens when relays return duplicate events', () => {
// Simulate a relay that returns multiple versions of the same event
const relayEvents = [
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'),
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'),
new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-1', 'Middle version'),
];
// This simulates what a "bad" relay might return
const eventSets = [new Set(relayEvents as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should only keep the newest version
expect(result.size).toBe(1);
const keptEvent = result.get('30041:pubkey1:chapter-1');
expect(keptEvent?.id).toBe('event2');
expect(keptEvent?.content).toBe('New version');
});
it('should simulate multiple relays returning different versions', () => {
// Simulate multiple relays returning different versions
const relay1Events = [
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Relay 1 version'),
];
const relay2Events = [
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'Relay 2 version'),
];
const eventSets = [new Set(relay1Events as NDKEvent[]), new Set(relay2Events as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Should keep the newest version from any relay
expect(result.size).toBe(1);
const keptEvent = result.get('30041:pubkey1:chapter-1');
expect(keptEvent?.id).toBe('event2');
expect(keptEvent?.content).toBe('Relay 2 version');
});
});
describe('Real Relay Deduplication Tests', () => {
// These tests actually query real relays to see if they deduplicate
// Note: These are integration tests and may be flaky due to network conditions
it('should detect if relays are returning duplicate replaceable events', async () => {
// This test queries real relays to see if they return duplicates
// We'll use a known author who has published multiple versions of content
// Known author with multiple publication content events
const testAuthor = 'npub1z4m7gkva6yxgvdyclc7zp0qt69x9zgn8lu8sllg06wx6432h77qs0k97ks';
// Query for publication content events (kind 30041) from this author
// We expect relays to return only the most recent version of each d-tag
// This is a placeholder - in a real test, we would:
// 1. Query multiple relays for the same author's 30041 events
// 2. Check if any relay returns multiple events with the same d-tag
// 3. Verify that if duplicates exist, our deduplication logic handles them
console.log('Note: This test would require actual relay queries to verify deduplication behavior');
console.log('To run this test properly, we would need to:');
console.log('1. Query real relays for replaceable events');
console.log('2. Check if relays return duplicates');
console.log('3. Verify our deduplication logic works on real data');
// For now, we'll just assert that our logic is ready to handle real data
expect(true).toBe(true);
}, 30000); // 30 second timeout for network requests
it('should verify that our deduplication logic works on real relay data', async () => {
// This test would:
// 1. Fetch real events from relays
// 2. Apply our deduplication logic
// 3. Verify that the results are correct
console.log('Note: This test would require actual relay queries');
console.log('To implement this test, we would need to:');
console.log('1. Set up NDK with real relays');
console.log('2. Fetch events for a known author with multiple versions');
console.log('3. Apply deduplication and verify results');
expect(true).toBe(true);
}, 30000);
});
describe('Practical Relay Behavior Analysis', () => {
it('should document what we know about relay deduplication behavior', () => {
// This test documents our current understanding of relay behavior
// based on the code analysis and the comment from onedev
console.log('\n=== RELAY DEDUPLICATION BEHAVIOR ANALYSIS ===');
console.log('\nBased on the code analysis and the comment from onedev:');
console.log('\n1. THEORETICAL BEHAVIOR:');
console.log(' - Relays SHOULD handle deduplication for replaceable events');
console.log(' - Only the most recent version of each coordinate should be stored');
console.log(' - Client-side deduplication should only be needed for cached/local events');
console.log('\n2. REALITY CHECK:');
console.log(' - Not all relays implement deduplication correctly');
console.log(' - Some relays may return multiple versions of the same event');
console.log(' - Network conditions and relay availability can cause inconsistencies');
console.log('\n3. ALEXANDRIA\'S APPROACH:');
console.log(' - Implements client-side deduplication as a safety net');
console.log(' - Uses coordinate system (kind:pubkey:d-tag) for addressable events');
console.log(' - Keeps the most recent version based on created_at timestamp');
console.log(' - Only applies to replaceable events (kinds 30000-39999)');
console.log('\n4. WHY KEEP THE DEDUPLICATION:');
console.log(' - Defensive programming against imperfect relay implementations');
console.log(' - Handles multiple relay sources with different data');
console.log(' - Works with cached events that might be outdated');
console.log(' - Ensures consistent user experience regardless of relay behavior');
console.log('\n5. TESTING STRATEGY:');
console.log(' - Unit tests verify our deduplication logic works correctly');
console.log(' - Integration tests would verify relay behavior (when network allows)');
console.log(' - Monitoring can help determine if relays improve over time');
// This test documents our understanding rather than asserting specific behavior
expect(true).toBe(true);
});
it('should provide recommendations for when to remove deduplication', () => {
console.log('\n=== RECOMMENDATIONS FOR REMOVING DEDUPLICATION ===');
console.log('\nThe deduplication logic should be kept until:');
console.log('\n1. RELAY STANDARDS:');
console.log(' - NIP-33 (replaceable events) is widely implemented by relays');
console.log(' - Relays consistently return only the most recent version');
console.log(' - No major relay implementations return duplicates');
console.log('\n2. TESTING EVIDENCE:');
console.log(' - Real-world testing shows relays don\'t return duplicates');
console.log(' - Multiple relay operators confirm deduplication behavior');
console.log(' - No user reports of duplicate content issues');
console.log('\n3. MONITORING:');
console.log(' - Add logging to track when deduplication is actually used');
console.log(' - Monitor relay behavior over time');
console.log(' - Collect metrics on duplicate events found');
console.log('\n4. GRADUAL REMOVAL:');
console.log(' - Make deduplication configurable (on/off)');
console.log(' - Test with deduplication disabled in controlled environments');
console.log(' - Monitor for issues before removing completely');
console.log('\n5. FALLBACK STRATEGY:');
console.log(' - Keep deduplication as a fallback option');
console.log(' - Allow users to enable it if they experience issues');
console.log(' - Maintain the code for potential future use');
expect(true).toBe(true);
});
});
describe('Logging and Monitoring Tests', () => {
it('should verify that logging works when duplicates are found', () => {
// Mock console.log to capture output
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Create events with duplicates
const duplicateEvents = [
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'),
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'),
new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-1', 'Middle version'),
];
const eventSets = [new Set(duplicateEvents as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Verify the deduplication worked
expect(result.size).toBe(1);
// Verify that logging was called
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[eventDeduplication] Found 2 duplicate events out of 3 total events')
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[eventDeduplication] Reduced to 1 unique coordinates')
);
// Restore console.log
consoleSpy.mockRestore();
});
it('should verify that logging works when no duplicates are found', () => {
// Mock console.log to capture output
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Create events without duplicates
const uniqueEvents = [
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Content 1'),
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-2', 'Content 2'),
];
const eventSets = [new Set(uniqueEvents as NDKEvent[])];
const result = deduplicateContentEvents(eventSets);
// Verify no deduplication was needed
expect(result.size).toBe(2);
// Verify that logging was called with "no duplicates" message
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[eventDeduplication] No duplicates found in 2 events')
);
// Restore console.log
consoleSpy.mockRestore();
});
it('should verify that deduplicateAndCombineEvents logging works', () => {
// Mock console.log to capture output
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Create events with duplicates
const duplicateEvents = [
new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'),
new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'),
];
const result = deduplicateAndCombineEvents(
[] as NDKEvent[],
new Set(),
new Set(duplicateEvents as NDKEvent[])
);
// Verify the deduplication worked
expect(result.length).toBe(1);
// Verify that logging was called
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[eventDeduplication] deduplicateAndCombineEvents: Found 1 duplicate coordinates')
);
// Restore console.log
consoleSpy.mockRestore();
});
});

420
tests/unit/tagExpansion.test.ts

@ -0,0 +1,420 @@ @@ -0,0 +1,420 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import {
fetchTaggedEventsFromRelays,
findTaggedEventsInFetched,
fetchProfilesForNewEvents,
type TagExpansionResult
} from '../../src/lib/utils/tag_event_fetch';
// Mock NDKEvent for testing
class MockNDKEvent {
id: string;
kind: number;
pubkey: string;
created_at: number;
content: string;
tags: string[][];
constructor(id: string, kind: number, pubkey: string, created_at: number, content: string = '', tags: string[][] = []) {
this.id = id;
this.kind = kind;
this.pubkey = pubkey;
this.created_at = created_at;
this.content = content;
this.tags = tags;
}
tagValue(tagName: string): string | undefined {
const tag = this.tags.find(t => t[0] === tagName);
return tag ? tag[1] : undefined;
}
getMatchingTags(tagName: string): string[][] {
return this.tags.filter(tag => tag[0] === tagName);
}
}
// Mock NDK instance
const mockNDK = {
fetchEvents: vi.fn()
};
// Mock the ndkInstance store
vi.mock('../../src/lib/ndk', () => ({
ndkInstance: {
subscribe: vi.fn((fn) => {
fn(mockNDK);
return { unsubscribe: vi.fn() };
})
}
}));
// Mock the profile cache utilities
vi.mock('../../src/lib/utils/profileCache', () => ({
extractPubkeysFromEvents: vi.fn((events: NDKEvent[]) => {
const pubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) pubkeys.add(event.pubkey);
});
return pubkeys;
}),
batchFetchProfiles: vi.fn(async (pubkeys: string[], onProgress: (fetched: number, total: number) => void) => {
// Simulate progress updates
onProgress(0, pubkeys.length);
onProgress(pubkeys.length, pubkeys.length);
return [];
})
}));
describe('Tag Expansion Tests', () => {
let mockPublications: MockNDKEvent[];
let mockContentEvents: MockNDKEvent[];
let mockAllEvents: MockNDKEvent[];
beforeEach(() => {
vi.clearAllMocks();
// Create test publication index events (kind 30040)
mockPublications = [
new MockNDKEvent('pub1', 30040, 'author1', 1000, 'Book 1', [
['t', 'bitcoin'],
['t', 'cryptocurrency'],
['a', '30041:author1:chapter-1'],
['a', '30041:author1:chapter-2']
]),
new MockNDKEvent('pub2', 30040, 'author2', 1100, 'Book 2', [
['t', 'bitcoin'],
['t', 'blockchain'],
['a', '30041:author2:chapter-1']
]),
new MockNDKEvent('pub3', 30040, 'author3', 1200, 'Book 3', [
['t', 'ethereum'],
['a', '30041:author3:chapter-1']
])
];
// Create test content events (kind 30041)
mockContentEvents = [
new MockNDKEvent('content1', 30041, 'author1', 1000, 'Chapter 1 content', [['d', 'chapter-1']]),
new MockNDKEvent('content2', 30041, 'author1', 1100, 'Chapter 2 content', [['d', 'chapter-2']]),
new MockNDKEvent('content3', 30041, 'author2', 1200, 'Author 2 Chapter 1', [['d', 'chapter-1']]),
new MockNDKEvent('content4', 30041, 'author3', 1300, 'Author 3 Chapter 1', [['d', 'chapter-1']])
];
// Combine all events for testing
mockAllEvents = [...mockPublications, ...mockContentEvents];
});
describe('fetchTaggedEventsFromRelays', () => {
it('should fetch publications with matching tags from relays', async () => {
// Mock the NDK fetch to return publications with 'bitcoin' tag
const bitcoinPublications = mockPublications.filter(pub =>
pub.tags.some(tag => tag[0] === 't' && tag[1] === 'bitcoin')
);
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(bitcoinPublications as NDKEvent[]));
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[]));
const existingEventIds = new Set<string>(['existing-event']);
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = await fetchTaggedEventsFromRelays(
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should fetch publications with bitcoin tag
expect(mockNDK.fetchEvents).toHaveBeenCalledWith({
kinds: [30040],
"#t": ['bitcoin'],
limit: 30
});
// Should return the matching publications
expect(result.publications).toHaveLength(2);
expect(result.publications.map(p => p.id)).toContain('pub1');
expect(result.publications.map(p => p.id)).toContain('pub2');
// Should fetch content events for the publications
expect(mockNDK.fetchEvents).toHaveBeenCalledWith({
kinds: [30041, 30818],
"#d": ['chapter-1', 'chapter-2']
});
});
it('should filter out existing events to avoid duplicates', async () => {
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockPublications as NDKEvent[]));
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[]));
const existingEventIds = new Set<string>(['pub1']); // pub1 already exists
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = await fetchTaggedEventsFromRelays(
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should exclude pub1 since it already exists
expect(result.publications).toHaveLength(2);
expect(result.publications.map(p => p.id)).not.toContain('pub1');
expect(result.publications.map(p => p.id)).toContain('pub2');
expect(result.publications.map(p => p.id)).toContain('pub3');
});
it('should handle empty tag array gracefully', async () => {
// Mock empty result for empty tags
mockNDK.fetchEvents.mockResolvedValueOnce(new Set());
const existingEventIds = new Set<string>();
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = await fetchTaggedEventsFromRelays(
[],
existingEventIds,
baseEvents,
debug
);
expect(result.publications).toHaveLength(0);
expect(result.contentEvents).toHaveLength(0);
});
});
describe('findTaggedEventsInFetched', () => {
it('should find publications with matching tags in already fetched events', () => {
const existingEventIds = new Set<string>(['existing-event']);
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
mockAllEvents as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should find publications with bitcoin tag
expect(result.publications).toHaveLength(2);
expect(result.publications.map(p => p.id)).toContain('pub1');
expect(result.publications.map(p => p.id)).toContain('pub2');
// Should find content events for those publications
expect(result.contentEvents).toHaveLength(4);
expect(result.contentEvents.map(c => c.id)).toContain('content1');
expect(result.contentEvents.map(c => c.id)).toContain('content2');
expect(result.contentEvents.map(c => c.id)).toContain('content3');
expect(result.contentEvents.map(c => c.id)).toContain('content4');
});
it('should exclude base events from search results', () => {
const existingEventIds = new Set<string>(['pub1']); // pub1 is a base event
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
mockAllEvents as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should exclude pub1 since it's a base event
expect(result.publications).toHaveLength(1);
expect(result.publications.map(p => p.id)).not.toContain('pub1');
expect(result.publications.map(p => p.id)).toContain('pub2');
});
it('should handle multiple tags (OR logic)', () => {
const existingEventIds = new Set<string>();
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
mockAllEvents as NDKEvent[],
['bitcoin', 'ethereum'],
existingEventIds,
baseEvents,
debug
);
// Should find publications with either bitcoin OR ethereum tags
expect(result.publications).toHaveLength(3);
expect(result.publications.map(p => p.id)).toContain('pub1'); // bitcoin
expect(result.publications.map(p => p.id)).toContain('pub2'); // bitcoin
expect(result.publications.map(p => p.id)).toContain('pub3'); // ethereum
});
it('should handle events without tags gracefully', () => {
const eventWithoutTags = new MockNDKEvent('no-tags', 30040, 'author4', 1000, 'No tags');
const allEventsWithNoTags = [...mockAllEvents, eventWithoutTags];
const existingEventIds = new Set<string>();
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
allEventsWithNoTags as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should not include events without tags
expect(result.publications.map(p => p.id)).not.toContain('no-tags');
});
});
describe('fetchProfilesForNewEvents', () => {
it('should extract pubkeys and fetch profiles for new events', async () => {
const onProgressUpdate = vi.fn();
const debug = vi.fn();
await fetchProfilesForNewEvents(
mockPublications as NDKEvent[],
mockContentEvents as NDKEvent[],
onProgressUpdate,
debug
);
// Should call progress update with initial state
expect(onProgressUpdate).toHaveBeenCalledWith({ current: 0, total: 3 });
// Should call progress update with final state
expect(onProgressUpdate).toHaveBeenCalledWith({ current: 3, total: 3 });
// Should clear progress at the end
expect(onProgressUpdate).toHaveBeenCalledWith(null);
});
it('should handle empty event arrays gracefully', async () => {
const onProgressUpdate = vi.fn();
const debug = vi.fn();
await fetchProfilesForNewEvents(
[],
[],
onProgressUpdate,
debug
);
// Should not call progress update for empty arrays
expect(onProgressUpdate).not.toHaveBeenCalled();
});
});
describe('Tag Expansion Integration', () => {
it('should demonstrate the complete tag expansion flow', async () => {
// This test simulates the complete flow from the visualize page
// Step 1: Mock relay fetch for 'bitcoin' tag
const bitcoinPublications = mockPublications.filter(pub =>
pub.tags.some(tag => tag[0] === 't' && tag[1] === 'bitcoin')
);
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(bitcoinPublications as NDKEvent[]));
mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[]));
const existingEventIds = new Set<string>(['base-event']);
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
// Step 2: Fetch from relays
const relayResult = await fetchTaggedEventsFromRelays(
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
expect(relayResult.publications).toHaveLength(2);
expect(relayResult.contentEvents).toHaveLength(4);
// Step 3: Search in fetched events
const searchResult = findTaggedEventsInFetched(
mockAllEvents as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
expect(searchResult.publications).toHaveLength(2);
expect(searchResult.contentEvents).toHaveLength(4);
// Step 4: Fetch profiles
const onProgressUpdate = vi.fn();
await fetchProfilesForNewEvents(
relayResult.publications,
relayResult.contentEvents,
onProgressUpdate,
debug
);
expect(onProgressUpdate).toHaveBeenCalledWith(null);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle malformed a-tags gracefully', () => {
const malformedPublication = new MockNDKEvent('malformed', 30040, 'author1', 1000, 'Malformed', [
['t', 'bitcoin'],
['a', 'invalid-tag-format'], // Missing parts
['a', '30041:author1:chapter-1'] // Valid format
]);
const allEventsWithMalformed = [...mockAllEvents, malformedPublication];
const existingEventIds = new Set<string>();
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
allEventsWithMalformed as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should still work and include the publication with valid a-tags
expect(result.publications).toHaveLength(3);
expect(result.contentEvents.length).toBeGreaterThan(0);
});
it('should handle events with d-tags containing colons', () => {
const publicationWithColonDTag = new MockNDKEvent('colon-pub', 30040, 'author1', 1000, 'Colon d-tag', [
['t', 'bitcoin'],
['a', '30041:author1:chapter:with:colons']
]);
const contentWithColonDTag = new MockNDKEvent('colon-content', 30041, 'author1', 1100, 'Content with colon d-tag', [
['d', 'chapter:with:colons']
]);
const allEventsWithColons = [...mockAllEvents, publicationWithColonDTag, contentWithColonDTag];
const existingEventIds = new Set<string>();
const baseEvents: NDKEvent[] = [];
const debug = vi.fn();
const result = findTaggedEventsInFetched(
allEventsWithColons as NDKEvent[],
['bitcoin'],
existingEventIds,
baseEvents,
debug
);
// Should handle d-tags with colons correctly
expect(result.publications).toHaveLength(3);
expect(result.contentEvents.map(c => c.id)).toContain('colon-content');
});
});
});
Loading…
Cancel
Save