45 changed files with 7436 additions and 1703 deletions
@ -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 |
||||||
@ -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> |
||||||
@ -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> |
|
||||||
@ -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> |
||||||
@ -1,58 +1,136 @@ |
|||||||
<!-- |
|
||||||
Settings Component |
|
||||||
--> |
|
||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import { Button } from "flowbite-svelte"; |
|
||||||
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
||||||
import { fly } from "svelte/transition"; |
import EventTypeConfig from "$lib/components/EventTypeConfig.svelte"; |
||||||
import { quintOut } from "svelte/easing"; |
import { visualizationConfig } from "$lib/stores/visualizationConfig"; |
||||||
import EventLimitControl from "$lib/components/EventLimitControl.svelte"; |
import { Toggle } from "flowbite-svelte"; |
||||||
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; |
import type { EventCounts } from "$lib/types"; |
||||||
import { networkFetchLimit } from "$lib/state"; |
|
||||||
|
|
||||||
let { count = 0, onupdate } = $props<{ |
let { |
||||||
|
count = 0, |
||||||
|
totalCount = 0, |
||||||
|
onupdate, |
||||||
|
onclear = () => {}, |
||||||
|
starVisualization = $bindable(true), |
||||||
|
eventCounts = {}, |
||||||
|
profileStats = { totalFetched: 0, displayLimit: 50 }, |
||||||
|
} = $props<{ |
||||||
count: number; |
count: number; |
||||||
|
totalCount: number; |
||||||
onupdate: () => void; |
onupdate: () => void; |
||||||
|
onclear?: () => void; |
||||||
|
|
||||||
|
starVisualization?: boolean; |
||||||
|
eventCounts?: EventCounts; |
||||||
|
profileStats?: { totalFetched: number; displayLimit: number }; |
||||||
}>(); |
}>(); |
||||||
|
|
||||||
let expanded = $state(false); |
let expanded = $state(false); |
||||||
|
let eventTypesExpanded = $state(true); |
||||||
|
let visualSettingsExpanded = $state(true); |
||||||
|
|
||||||
function toggle() { |
function toggle() { |
||||||
expanded = !expanded; |
expanded = !expanded; |
||||||
} |
} |
||||||
/** |
|
||||||
* Handles updates to visualization settings |
function toggleEventTypes() { |
||||||
*/ |
eventTypesExpanded = !eventTypesExpanded; |
||||||
function handleLimitUpdate() { |
} |
||||||
onupdate(); |
|
||||||
|
function toggleVisualSettings() { |
||||||
|
visualSettingsExpanded = !visualSettingsExpanded; |
||||||
} |
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class="leather-legend sm:!right-1 sm:!left-auto"> |
<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> |
<h3 class="h-leather">Settings</h3> |
||||||
<Button |
<div class="pointer-events-none"> |
||||||
color="none" |
|
||||||
outline |
|
||||||
size="xs" |
|
||||||
onclick={toggle} |
|
||||||
class="rounded-full" |
|
||||||
> |
|
||||||
{#if expanded} |
{#if expanded} |
||||||
<CaretUpOutline /> |
<CaretUpOutline /> |
||||||
{:else} |
{:else} |
||||||
<CaretDownOutline /> |
<CaretDownOutline /> |
||||||
{/if} |
{/if} |
||||||
</Button> |
</div> |
||||||
</div> |
</div> |
||||||
|
|
||||||
{#if expanded} |
{#if expanded} |
||||||
<div class="space-y-4"> |
<div class="space-y-4"> |
||||||
<span class="leather bg-transparent legend-text"> |
<span class="leather bg-transparent legend-text"> |
||||||
Showing {count} events from {$networkFetchLimit} headers |
Showing {count} of {totalCount} events |
||||||
</span> |
</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> |
</div> |
||||||
{/if} |
{/if} |
||||||
</div> |
</div> |
||||||
|
|||||||
@ -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> |
||||||
File diff suppressed because it is too large
Load Diff
@ -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); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
@ -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, |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
@ -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); }); |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export * from './relayStore'; |
||||||
|
export * from './displayLimits'; |
||||||
@ -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) |
||||||
|
); |
||||||
@ -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; |
||||||
|
} |
||||||
|
|
||||||
@ -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}`; |
||||||
|
} |
||||||
|
|
||||||
@ -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}`; |
||||||
|
}
|
||||||
@ -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}`; |
||||||
|
} |
||||||
|
}
|
||||||
@ -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); |
||||||
|
}
|
||||||
@ -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); |
||||||
|
}); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
}
|
||||||
@ -0,0 +1,9 @@ |
|||||||
|
import type { PageLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => { |
||||||
|
const eventId = url.searchParams.get('event'); |
||||||
|
|
||||||
|
return { |
||||||
|
eventId |
||||||
|
}; |
||||||
|
}; |
||||||
@ -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(); |
|
||||||
}); |
|
||||||
@ -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"); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -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\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] |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
### 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 |
|
||||||
@ -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) "; |
|
||||||
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"); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -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) "; |
|
||||||
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"); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -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); |
||||||
|
}); |
||||||
|
}); |
||||||
|
});
|
||||||
@ -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(); |
||||||
|
}); |
||||||
|
});
|
||||||
@ -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…
Reference in new issue