44 changed files with 7285 additions and 1469 deletions
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
--- |
||||
description: |
||||
globs: |
||||
alwaysApply: false |
||||
--- |
||||
You are an agentic AI assistant that quickly identifies key focus areas in a git diff so that a developer can efficiently review files within a change set in a logical order. |
||||
|
||||
## Interaction Workflow |
||||
|
||||
### Prerequisites |
||||
|
||||
- Determine which branch the developer is working on. If the branch is the main branch, tell the developer that you cannot perform a code review on the main branch, and end your turn. |
||||
|
||||
### Project Structure |
||||
|
||||
This project broadly follows a model-view-controller (MVC) pattern. |
||||
|
||||
- The Model consists primarily of Nostr relays, accessed via WebSocket APIs. The Model layer also includes data stored in the web browser. |
||||
- The View is a reactive UI defined by SvelteKit pages and Svelte components. |
||||
- The Controller layer is defined by various TypeScript modules that provide utility functions, classes, singletons, and other facilities that prepare data for the view layer or handle user-provided data for to be saved to the browser or relays. |
||||
|
||||
### Additional Context |
||||
|
||||
- The primary branch for this repo is called `master`. |
||||
|
||||
### Expected Output |
||||
|
||||
- The developer may leave comments on the reviewed changes via an external PR tool, such as GitHub or OneDev, so specify filenames and line numbers for each highlighted item of code. |
||||
- Specify the context of highlighted items, such as function, class, or component names. |
||||
- Always explain why an item is worth the developer's particular attention. |
||||
- Keep the code diff surveys concise and to the point. |
||||
|
||||
### Code Review Order |
||||
|
||||
1. Obtain the diff of the current branch with the main branch. If necessary, ask the developer to provide the diff as context. |
||||
2. Read the diff and associated commit messages, if available, and give the developer a brief summary of the changes and key items to note. |
||||
3. Tell the developer you will provide a more detailed description of the changes. |
||||
4. Tell the developer to review model-level changes first. These may include changes to API clients, changes to database access, and changes to cache or browser storage patterns. |
||||
5. Next, point the developer's attention to changes in the controller/view controller layer. These may include changes to service classes, utility functions, and anything else that could be classified as "business logic". |
||||
6. Finally, draw the developer's attention to view/UI changes. In this project, view changes will be almost entirely in `.svelte` component files. |
||||
@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
|
||||
* Settings Panel Documentation |
||||
|
||||
** Overview |
||||
The settings panel controls how events are fetched and displayed in the visualization. It has several sections that work together to create an efficient and user-friendly experience. |
||||
|
||||
** Event Types Configuration |
||||
|
||||
*** Purpose |
||||
Controls which types of Nostr events are fetched and how many of each type. |
||||
|
||||
*** Key Event Types |
||||
- *Kind 30040* (Index Events): Publication indices |
||||
- *Kind 30041* (Content Events): Publication content |
||||
- *Kind 30818* (Content Events): Alternative content format |
||||
- *Kind 30023* (Content Events): Alternative content format |
||||
|
||||
*** How Limits Work |
||||
Each event kind has a limit number that controls different things: |
||||
|
||||
**** For Kind 0 (Profiles) |
||||
- Limit controls how many profiles to fetch from discovered pubkeys |
||||
- These profiles are used for: |
||||
- Displaying names instead of pubkeys |
||||
- Showing profile pictures in tooltips |
||||
- When "People" tag anchors are selected, this limit controls how many people anchors to display |
||||
|
||||
**** For Kind 3 (Follow Lists) |
||||
- =limit = 1=: Only fetch the current user's follow list |
||||
- =limit > 1=: Fetch the user's follow list PLUS (limit-1) follow lists from people they follow |
||||
- The depth selector controls traversal: |
||||
- =Direct= (0): Just the immediate follows |
||||
- =2 degrees= (1): Follows of follows |
||||
- =3 degrees= (2): Three levels deep |
||||
|
||||
**** For Kind 30040/30041/30818 |
||||
- Limit controls maximum number of these events to fetch |
||||
|
||||
** Tag Anchors |
||||
|
||||
*** What Are Tag Anchors? |
||||
Tag anchors are special nodes in the graph that act as gravity points for events sharing common attributes. They help organize the visualization by grouping related content. |
||||
|
||||
*** Tag Types Available |
||||
- *Hashtags* (t): Groups events by hashtag |
||||
- *Authors*: Groups events by author |
||||
- *People* (p): Shows people from follow lists as anchor points |
||||
- *Event References* (e): Groups events that reference each other |
||||
- *Titles*: Groups events by title |
||||
- *Summaries*: Groups events by summary |
||||
|
||||
*** How People Tag Anchors Work |
||||
When "People" is selected as the tag type: |
||||
|
||||
1. The system looks at all loaded follow lists (kind 3 events) |
||||
2. Extracts all pubkeys (people) from those follow lists |
||||
3. Creates tag anchors for those people (up to the kind 0 limit) |
||||
4. Connects each person anchor to: |
||||
- Events they authored (where pubkey matches) |
||||
- Events where they're mentioned in "p" tags |
||||
|
||||
*** Display Limiting and Auto-Disable |
||||
- Tag anchors are created for ALL discovered tags |
||||
- But only displayed up to the configured limit |
||||
- When > 20 tag anchors exist, they're all auto-disabled |
||||
- Users can selectively enable specific anchors |
||||
- The legend becomes scrollable for many anchors |
||||
|
||||
*** "Only show people with publications" Checkbox |
||||
When checked (default): |
||||
- Only shows people who have events in the current visualization |
||||
|
||||
When unchecked: |
||||
- Shows ALL people from follow lists, even if they have no events displayed |
||||
- Useful for seeing your complete social graph |
||||
|
||||
** Display Limits Section |
||||
|
||||
*** Max Publication Indices (30040) |
||||
Controls display filtering for publication indices after they're fetched. |
||||
|
||||
*** Max Events per Index |
||||
Limits how many content events to show per publication index. |
||||
|
||||
*** Fetch if not found |
||||
When enabled, automatically fetches missing referenced events. |
||||
|
||||
** Graph Traversal Section |
||||
|
||||
*** Search through already fetched |
||||
When enabled, tag expansion only searches through events already loaded (more efficient). |
||||
|
||||
*** Append mode |
||||
When enabled, new fetches add to the existing graph instead of replacing it. |
||||
|
||||
** Current Implementation Questions |
||||
|
||||
1. *Profile Fetching*: Should we fetch profiles for: |
||||
- Only event authors? |
||||
- All pubkeys in follow lists? |
||||
- All pubkeys mentioned anywhere? |
||||
|
||||
2. *People Tag Anchors*: Should they connect to: |
||||
- Only events where the person is tagged with "p"? |
||||
- Events they authored? |
||||
- Both? |
||||
|
||||
3. *Display Limits*: Should limits control: |
||||
- How many to fetch from relays? |
||||
- How many to display (fetch all, display subset)? |
||||
- Both with separate controls? |
||||
|
||||
4. *Auto-disable Threshold*: Is 20 the right number for auto-disabling tag anchors? |
||||
|
||||
** Ideal User Flow |
||||
|
||||
1. User loads the visualization |
||||
2. Their follow list is fetched (kind 3, limit 1) |
||||
3. Profiles are fetched for people they follow (kind 0, respecting limit) |
||||
4. Publications are fetched (kind 30040/30041/30818) |
||||
5. User enables "People" tag anchors |
||||
6. Sees their follows as anchor points |
||||
7. Can see which follows have authored content |
||||
8. Can selectively enable/disable specific people |
||||
9. Can increase limits to see more content/people |
||||
@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
|
||||
<script lang="ts"> |
||||
import { visualizationConfig, enabledEventKinds } from '$lib/stores/visualizationConfig'; |
||||
import { Button, Badge } from 'flowbite-svelte'; |
||||
import { CloseCircleOutline } from 'flowbite-svelte-icons'; |
||||
import type { EventCounts } from "$lib/types"; |
||||
import { NostrKind } from '$lib/types'; |
||||
|
||||
let { |
||||
onReload = () => {}, |
||||
eventCounts = {} |
||||
} = $props<{ |
||||
onReload?: () => void; |
||||
eventCounts?: EventCounts; |
||||
}>(); |
||||
|
||||
let newKind = $state(''); |
||||
let showAddInput = $state(false); |
||||
let inputError = $state(''); |
||||
|
||||
function validateKind(value: string): number | null { |
||||
if (!value || value.trim() === '') { |
||||
inputError = ''; |
||||
return null; |
||||
} |
||||
const kind = parseInt(value.trim()); |
||||
if (isNaN(kind)) { |
||||
inputError = 'Must be a number'; |
||||
return null; |
||||
} |
||||
if (kind < 0) { |
||||
inputError = 'Must be positive'; |
||||
return null; |
||||
} |
||||
if ($visualizationConfig.eventConfigs.some(ec => ec.kind === kind)) { |
||||
inputError = 'Already added'; |
||||
return null; |
||||
} |
||||
inputError = ''; |
||||
return kind; |
||||
} |
||||
|
||||
function handleAddKind() { |
||||
const kind = validateKind(newKind); |
||||
if (kind != null) { |
||||
visualizationConfig.addEventKind(kind); |
||||
newKind = ''; |
||||
showAddInput = false; |
||||
inputError = ''; |
||||
} |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent) { |
||||
if (e.key === 'Enter') { |
||||
handleAddKind(); |
||||
} else if (e.key === 'Escape') { |
||||
showAddInput = false; |
||||
newKind = ''; |
||||
inputError = ''; |
||||
} |
||||
} |
||||
|
||||
function removeKind(kind: number) { |
||||
visualizationConfig.removeEventKind(kind); |
||||
} |
||||
|
||||
function toggleKind(kind: number) { |
||||
visualizationConfig.toggleKind(kind); |
||||
} |
||||
|
||||
function getKindName(kind: number): string { |
||||
switch (kind) { |
||||
case NostrKind.PublicationIndex: return 'Publication Index'; |
||||
case NostrKind.PublicationContent: return 'Publication Content'; |
||||
case NostrKind.Wiki: return 'Wiki'; |
||||
case NostrKind.TextNote: return 'Text Note'; |
||||
case NostrKind.UserMetadata: return 'Metadata'; |
||||
default: return `Kind ${kind}`; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="space-y-3"> |
||||
<div class="flex flex-wrap gap-2 items-center"> |
||||
{#each $visualizationConfig.eventConfigs as ec} |
||||
{@const isEnabled = ec.enabled !== false} |
||||
{@const isLoaded = (eventCounts[ec.kind] || 0) > 0} |
||||
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} |
||||
<button |
||||
class="badge-container {isEnabled ? '' : 'disabled'} {isLoaded ? 'loaded' : 'not-loaded'}" |
||||
onclick={() => toggleKind(ec.kind)} |
||||
title={isEnabled ? `Click to disable ${getKindName(ec.kind)}` : `Click to enable ${getKindName(ec.kind)}`} |
||||
> |
||||
<Badge |
||||
color="dark" |
||||
class="flex items-center gap-1 px-2 py-1 {isEnabled ? '' : 'opacity-40'} border-2 {borderColor}" |
||||
> |
||||
<span class="text-xs">{ec.kind}</span> |
||||
{#if isLoaded} |
||||
<span class="text-xs text-gray-400">({eventCounts[ec.kind]})</span> |
||||
{/if} |
||||
<button |
||||
onclick={(e) => { |
||||
e.stopPropagation(); |
||||
removeKind(ec.kind); |
||||
}} |
||||
class="ml-1 text-red-500 hover:text-red-700 transition-colors" |
||||
title="Remove {getKindName(ec.kind)}" |
||||
> |
||||
<CloseCircleOutline class="w-3 h-3" /> |
||||
</button> |
||||
</Badge> |
||||
</button> |
||||
{/each} |
||||
|
||||
{#if !showAddInput} |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => showAddInput = true} |
||||
class="gap-1" |
||||
> |
||||
<span>+</span> |
||||
<span>Add Kind</span> |
||||
</Button> |
||||
{/if} |
||||
|
||||
<Button |
||||
size="xs" |
||||
color="blue" |
||||
onclick={onReload} |
||||
class="gap-1" |
||||
title="Reload graph with current event type filters" |
||||
> |
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> |
||||
</svg> |
||||
<span>Reload</span> |
||||
</Button> |
||||
</div> |
||||
|
||||
{#if showAddInput} |
||||
<div class="flex items-center gap-2"> |
||||
<input |
||||
bind:value={newKind} |
||||
type="number" |
||||
placeholder="Enter event kind number (e.g. 1)" |
||||
class="flex-1 px-3 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" |
||||
onkeydown={handleKeydown} |
||||
oninput={(e) => { |
||||
const value = (e.target as HTMLInputElement).value; |
||||
validateKind(value); |
||||
}} |
||||
/> |
||||
<Button size="xs" onclick={handleAddKind} disabled={!newKind}> |
||||
Add |
||||
</Button> |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => { |
||||
showAddInput = false; |
||||
newKind = ''; |
||||
inputError = ''; |
||||
}} |
||||
> |
||||
Cancel |
||||
</Button> |
||||
</div> |
||||
{#if inputError} |
||||
<p class="text-xs text-red-500 -mt-2"> |
||||
{inputError} |
||||
</p> |
||||
{/if} |
||||
{/if} |
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1"> |
||||
<p class="flex items-center gap-2"> |
||||
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span> |
||||
<span>Green border = Events loaded</span> |
||||
</p> |
||||
<p class="flex items-center gap-2"> |
||||
<span class="inline-block w-3 h-3 border-2 border-red-500 rounded"></span> |
||||
<span>Red border = Not loaded (click Reload to fetch)</span> |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.badge-container { |
||||
background: none; |
||||
border: none; |
||||
padding: 0; |
||||
cursor: pointer; |
||||
transition: transform 0.2s ease; |
||||
} |
||||
|
||||
.badge-container:hover:not(.disabled) { |
||||
transform: scale(1.05); |
||||
} |
||||
|
||||
.badge-container.disabled { |
||||
cursor: pointer; |
||||
} |
||||
</style> |
||||
@ -1,52 +0,0 @@
@@ -1,52 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { networkFetchLimit } from "$lib/state"; |
||||
import { createEventDispatcher } from "svelte"; |
||||
|
||||
const dispatch = createEventDispatcher<{ |
||||
update: { limit: number }; |
||||
}>(); |
||||
|
||||
let inputValue = $networkFetchLimit; |
||||
|
||||
function handleInput(event: Event) { |
||||
const input = event.target as HTMLInputElement; |
||||
const value = parseInt(input.value); |
||||
// Ensure value is between 1 and 50 |
||||
if (value >= 1 && value <= 50) { |
||||
inputValue = value; |
||||
} |
||||
} |
||||
|
||||
function handleUpdate() { |
||||
$networkFetchLimit = inputValue; |
||||
dispatch("update", { limit: inputValue }); |
||||
} |
||||
|
||||
function handleKeyDown(event: KeyboardEvent) { |
||||
if (event.key === "Enter") { |
||||
handleUpdate(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="flex items-center gap-2 mb-4"> |
||||
<label for="event-limit" class="leather bg-transparent text-sm font-medium" |
||||
>Number of root events: |
||||
</label> |
||||
<input |
||||
type="number" |
||||
id="event-limit" |
||||
min="1" |
||||
max="50" |
||||
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white" |
||||
bind:value={inputValue} |
||||
on:input={handleInput} |
||||
on:keydown={handleKeyDown} |
||||
/> |
||||
<button |
||||
on:click={handleUpdate} |
||||
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" |
||||
> |
||||
Update |
||||
</button> |
||||
</div> |
||||
@ -0,0 +1,274 @@
@@ -0,0 +1,274 @@
|
||||
<script lang="ts"> |
||||
import { visualizationConfig } from '$lib/stores/visualizationConfig'; |
||||
import { Button, Input } from 'flowbite-svelte'; |
||||
import { CloseCircleOutline } from 'flowbite-svelte-icons'; |
||||
import { getEventKindName, getEventKindColor } from '$lib/utils/eventColors'; |
||||
import { |
||||
validateEventKind, |
||||
handleAddEventKind, |
||||
handleEventKindKeydown |
||||
} from '$lib/utils/event_kind_utils'; |
||||
import type { EventCounts } from "$lib/types"; |
||||
|
||||
let { |
||||
onReload = () => {}, |
||||
eventCounts = {}, |
||||
profileStats = { totalFetched: 0, displayLimit: 50 } |
||||
} = $props<{ |
||||
onReload?: () => void; |
||||
eventCounts?: EventCounts; |
||||
profileStats?: { totalFetched: number; displayLimit: number }; |
||||
}>(); |
||||
|
||||
let newKind = $state(''); |
||||
let showAddInput = $state(false); |
||||
let inputError = $state(''); |
||||
|
||||
// Get existing kinds for validation |
||||
let existingKinds = $derived($visualizationConfig.eventConfigs.map((ec: any) => ec.kind)); |
||||
|
||||
function handleAddKind() { |
||||
const result = handleAddEventKind( |
||||
newKind, |
||||
existingKinds, |
||||
(kind) => visualizationConfig.addEventKind(kind), |
||||
() => { |
||||
newKind = ''; |
||||
showAddInput = false; |
||||
inputError = ''; |
||||
} |
||||
); |
||||
|
||||
if (!result.success) { |
||||
inputError = result.error; |
||||
} |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent) { |
||||
handleEventKindKeydown( |
||||
e, |
||||
handleAddKind, |
||||
() => { |
||||
showAddInput = false; |
||||
newKind = ''; |
||||
inputError = ''; |
||||
} |
||||
); |
||||
} |
||||
|
||||
function handleLimitChange(kind: number, value: string) { |
||||
const limit = parseInt(value); |
||||
if (!isNaN(limit) && limit > 0) { |
||||
visualizationConfig.updateEventLimit(kind, limit); |
||||
// Update profile stats display limit if it's kind 0 |
||||
if (kind === 0) { |
||||
profileStats = { ...profileStats, displayLimit: limit }; |
||||
} |
||||
} |
||||
} |
||||
|
||||
function handleNestedLevelsChange(value: string) { |
||||
const levels = parseInt(value); |
||||
if (!isNaN(levels) && levels >= 0) { |
||||
visualizationConfig.updateNestedLevels(levels); |
||||
} |
||||
} |
||||
|
||||
function handleFollowDepthChange(value: string) { |
||||
const depth = parseInt(value); |
||||
if (!isNaN(depth) && depth >= 0 && depth <= 2) { |
||||
visualizationConfig.updateFollowDepth(depth); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="space-y-3"> |
||||
<span class="text-xs text-gray-600 dark:text-gray-400"> |
||||
Showing {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} of {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} events |
||||
</span> |
||||
|
||||
<!-- Event configurations --> |
||||
<div class="space-y-2"> |
||||
{#each $visualizationConfig.eventConfigs as config} |
||||
{@const isLoaded = (eventCounts[config.kind] || 0) > 0} |
||||
{@const isDisabled = config.enabled === false} |
||||
{@const color = getEventKindColor(config.kind)} |
||||
{@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} |
||||
<div class="flex items-center gap-2"> |
||||
<!-- Kind badge with color indicator and load status border --> |
||||
<button |
||||
class="flex items-center gap-1 min-w-[140px] px-2 py-1 border-2 rounded {borderColor} {isDisabled ? 'opacity-50' : ''} hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" |
||||
onclick={() => visualizationConfig.toggleKind(config.kind)} |
||||
title={isDisabled ? `Click to enable ${getEventKindName(config.kind)}` : `Click to disable ${getEventKindName(config.kind)}`} |
||||
> |
||||
<span |
||||
class="inline-block w-3 h-3 rounded-full flex-shrink-0" |
||||
style="background-color: {color}" |
||||
></span> |
||||
<span class="text-sm font-medium dark:text-white"> |
||||
{config.kind} |
||||
</span> |
||||
</button> |
||||
<button |
||||
onclick={() => visualizationConfig.removeEventKind(config.kind)} |
||||
class="text-red-500 hover:text-red-700 transition-colors" |
||||
title="Remove {getEventKindName(config.kind)}" |
||||
> |
||||
<CloseCircleOutline class="w-4 h-4" /> |
||||
</button> |
||||
|
||||
<!-- Special format for kind 0 (profiles) --> |
||||
{#if config.kind === 0} |
||||
<input |
||||
type="number" |
||||
value={config.limit} |
||||
min="1" |
||||
max={profileStats.totalFetched || 1000} |
||||
class="w-16 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" |
||||
oninput={(e) => handleLimitChange(config.kind, e.currentTarget.value)} |
||||
title="Max profiles to display" |
||||
/> |
||||
<span class="text-xs text-gray-600 dark:text-gray-400"> |
||||
of {profileStats.totalFetched} fetched |
||||
</span> |
||||
{:else} |
||||
<!-- Limit input for other kinds --> |
||||
<input |
||||
type="number" |
||||
value={config.limit} |
||||
min="1" |
||||
max="1000" |
||||
class="w-16 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white {(config.kind === 30041 || config.kind === 30818) && config.showAll ? 'opacity-50' : ''}" |
||||
oninput={(e) => handleLimitChange(config.kind, e.currentTarget.value)} |
||||
title="Max to display" |
||||
disabled={(config.kind === 30041 || config.kind === 30818) && config.showAll} |
||||
/> |
||||
|
||||
<!-- Show All checkbox for content kinds (30041, 30818) --> |
||||
{#if config.kind === 30041 || config.kind === 30818} |
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400"> |
||||
<input |
||||
type="checkbox" |
||||
checked={config.showAll || false} |
||||
onchange={() => visualizationConfig.toggleShowAllContent(config.kind)} |
||||
class="w-3 h-3" |
||||
/> |
||||
All |
||||
</label> |
||||
{/if} |
||||
{/if} |
||||
|
||||
<!-- Nested levels for 30040 --> |
||||
{#if config.kind === 30040} |
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Nested Levels:</span> |
||||
<input |
||||
type="number" |
||||
value={config.nestedLevels || 1} |
||||
min="0" |
||||
max="10" |
||||
class="w-14 px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" |
||||
oninput={(e) => handleNestedLevelsChange(e.currentTarget.value)} |
||||
title="Levels to traverse" |
||||
/> |
||||
{/if} |
||||
|
||||
<!-- Additional settings for kind 3 (follow lists) --> |
||||
{#if config.kind === 3} |
||||
<select |
||||
value={config.depth || 0} |
||||
onchange={(e) => handleFollowDepthChange(e.currentTarget.value)} |
||||
class="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" |
||||
title="How many degrees of separation to traverse" |
||||
> |
||||
<option value="0">Direct</option> |
||||
<option value="1">2 degrees</option> |
||||
<option value="2">3 degrees</option> |
||||
</select> |
||||
{/if} |
||||
|
||||
<!-- Load indicator --> |
||||
{#if config.kind !== 0 && isLoaded} |
||||
<span class="text-xs text-green-600 dark:text-green-400"> |
||||
({eventCounts[config.kind]}) |
||||
</span> |
||||
{:else if config.kind !== 0} |
||||
<span class="text-xs text-red-600 dark:text-red-400"> |
||||
(not loaded) |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Add kind button/input --> |
||||
{#if showAddInput} |
||||
<div class="flex items-center gap-2"> |
||||
<input |
||||
bind:value={newKind} |
||||
type="number" |
||||
placeholder="Enter event kind number (e.g. 1)" |
||||
class="flex-1 px-3 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" |
||||
onkeydown={handleKeydown} |
||||
oninput={(e) => { |
||||
const validation = validateEventKind(e.currentTarget.value, existingKinds); |
||||
inputError = validation.error; |
||||
}} |
||||
/> |
||||
<Button size="xs" onclick={handleAddKind} disabled={newKind === '' || !!inputError}> |
||||
Add |
||||
</Button> |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => { |
||||
showAddInput = false; |
||||
newKind = ''; |
||||
inputError = ''; |
||||
}} |
||||
> |
||||
Cancel |
||||
</Button> |
||||
</div> |
||||
{#if inputError} |
||||
<p class="text-xs text-red-500 -mt-2"> |
||||
{inputError} |
||||
</p> |
||||
{/if} |
||||
{:else} |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => showAddInput = true} |
||||
class="gap-1" |
||||
> |
||||
<span>+</span> |
||||
<span>Add Event Type</span> |
||||
</Button> |
||||
{/if} |
||||
|
||||
<!-- Reload button --> |
||||
<Button |
||||
size="xs" |
||||
color="blue" |
||||
onclick={onReload} |
||||
class="gap-1" |
||||
title="Reload graph with current settings" |
||||
> |
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> |
||||
</svg> |
||||
<span>Reload</span> |
||||
</Button> |
||||
|
||||
<!-- Border legend --> |
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2"> |
||||
<p class="flex items-center gap-2"> |
||||
<span class="inline-block w-3 h-3 border-2 border-green-500 rounded"></span> |
||||
<span>Green = Events loaded</span> |
||||
</p> |
||||
<p class="flex items-center gap-2"> |
||||
<span class="inline-block w-3 h-3 border-2 border-red-500 rounded"></span> |
||||
<span>Red = Not loaded (click Reload)</span> |
||||
</p> |
||||
</div> |
||||
</div> |
||||
@ -1,58 +1,136 @@
@@ -1,58 +1,136 @@
|
||||
<!-- |
||||
Settings Component |
||||
--> |
||||
<script lang="ts"> |
||||
import { Button } from "flowbite-svelte"; |
||||
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
||||
import { fly } from "svelte/transition"; |
||||
import { quintOut } from "svelte/easing"; |
||||
import EventLimitControl from "$lib/components/EventLimitControl.svelte"; |
||||
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; |
||||
import { networkFetchLimit } from "$lib/state"; |
||||
import EventTypeConfig from "$lib/components/EventTypeConfig.svelte"; |
||||
import { visualizationConfig } from "$lib/stores/visualizationConfig"; |
||||
import { Toggle } from "flowbite-svelte"; |
||||
import type { EventCounts } from "$lib/types"; |
||||
|
||||
let { count = 0, onupdate } = $props<{ |
||||
let { |
||||
count = 0, |
||||
totalCount = 0, |
||||
onupdate, |
||||
onclear = () => {}, |
||||
starVisualization = $bindable(true), |
||||
eventCounts = {}, |
||||
profileStats = { totalFetched: 0, displayLimit: 50 }, |
||||
} = $props<{ |
||||
count: number; |
||||
totalCount: number; |
||||
onupdate: () => void; |
||||
onclear?: () => void; |
||||
|
||||
starVisualization?: boolean; |
||||
eventCounts?: EventCounts; |
||||
profileStats?: { totalFetched: number; displayLimit: number }; |
||||
}>(); |
||||
|
||||
let expanded = $state(false); |
||||
let eventTypesExpanded = $state(true); |
||||
let visualSettingsExpanded = $state(true); |
||||
|
||||
function toggle() { |
||||
expanded = !expanded; |
||||
} |
||||
/** |
||||
* Handles updates to visualization settings |
||||
*/ |
||||
function handleLimitUpdate() { |
||||
onupdate(); |
||||
|
||||
function toggleEventTypes() { |
||||
eventTypesExpanded = !eventTypesExpanded; |
||||
} |
||||
|
||||
function toggleVisualSettings() { |
||||
visualSettingsExpanded = !visualSettingsExpanded; |
||||
} |
||||
</script> |
||||
|
||||
<div class="leather-legend sm:!right-1 sm:!left-auto"> |
||||
<div class="flex items-center justify-between space-x-3"> |
||||
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}> |
||||
<h3 class="h-leather">Settings</h3> |
||||
<Button |
||||
color="none" |
||||
outline |
||||
size="xs" |
||||
onclick={toggle} |
||||
class="rounded-full" |
||||
> |
||||
<div class="pointer-events-none"> |
||||
{#if expanded} |
||||
<CaretUpOutline /> |
||||
{:else} |
||||
<CaretDownOutline /> |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
|
||||
{#if expanded} |
||||
<div class="space-y-4"> |
||||
<span class="leather bg-transparent legend-text"> |
||||
Showing {count} events from {$networkFetchLimit} headers |
||||
Showing {count} of {totalCount} events |
||||
</span> |
||||
<EventLimitControl on:update={handleLimitUpdate} /> |
||||
<EventRenderLevelLimit on:update={handleLimitUpdate} /> |
||||
|
||||
<!-- Event Configuration Section (combines types and limits) --> |
||||
<div |
||||
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0" |
||||
> |
||||
<div |
||||
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2" |
||||
onclick={toggleEventTypes} |
||||
> |
||||
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm"> |
||||
Event Configuration |
||||
</h4> |
||||
<div class="pointer-events-none"> |
||||
{#if eventTypesExpanded} |
||||
<CaretUpOutline class="w-3 h-3" /> |
||||
{:else} |
||||
<CaretDownOutline class="w-3 h-3" /> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{#if eventTypesExpanded} |
||||
<EventTypeConfig onReload={onupdate} {eventCounts} {profileStats} /> |
||||
{/if} |
||||
</div> |
||||
|
||||
|
||||
|
||||
<!-- Visual Settings Section --> |
||||
<div |
||||
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0" |
||||
> |
||||
<div |
||||
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2" |
||||
onclick={toggleVisualSettings} |
||||
> |
||||
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm"> |
||||
Visual Settings |
||||
</h4> |
||||
<div class="pointer-events-none"> |
||||
{#if visualSettingsExpanded} |
||||
<CaretUpOutline class="w-3 h-3" /> |
||||
{:else} |
||||
<CaretDownOutline class="w-3 h-3" /> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{#if visualSettingsExpanded} |
||||
|
||||
<div class="space-y-4"> |
||||
<div class="space-y-2"> |
||||
<label |
||||
class="leather bg-transparent legend-text flex items-center space-x-2" |
||||
> |
||||
<Toggle |
||||
checked={starVisualization} |
||||
onchange={(e: Event) => { |
||||
const target = e.target as HTMLInputElement; |
||||
starVisualization = target.checked; |
||||
}} |
||||
class="text-xs" |
||||
/> |
||||
<span>Star Network View</span> |
||||
</label> |
||||
<p class="text-xs text-gray-500 dark:text-gray-400"> |
||||
Toggle between star clusters (on) and linear sequence (off) |
||||
visualization |
||||
</p> |
||||
</div> |
||||
|
||||
</div> |
||||
|
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
<!-- |
||||
TagTable Component |
||||
Displays a table of unique tags found in the event network |
||||
--> |
||||
<script lang="ts"> |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { Table } from "flowbite-svelte"; |
||||
|
||||
let { events = [], selectedTagType = "t" } = $props<{ |
||||
events: NDKEvent[]; |
||||
selectedTagType: string; |
||||
}>(); |
||||
|
||||
// Computed property for unique tags |
||||
let uniqueTags = $derived(() => { |
||||
const tagMap = new Map(); |
||||
|
||||
events.forEach(event => { |
||||
const tags = event.tags || []; |
||||
tags.forEach(tag => { |
||||
if (tag[0] === selectedTagType) { |
||||
const tagValue = tag[1]; |
||||
const count = tagMap.get(tagValue)?.count || 0; |
||||
tagMap.set(tagValue, { |
||||
value: tagValue, |
||||
count: count + 1, |
||||
// Store first event that references this tag |
||||
firstEvent: tagMap.get(tagValue)?.firstEvent || event.id |
||||
}); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
return Array.from(tagMap.values()) |
||||
.sort((a, b) => b.count - a.count); // Sort by frequency |
||||
}); |
||||
|
||||
// Tag type labels for display |
||||
const tagTypeLabels: Record<string, string> = { |
||||
't': 'Hashtags', |
||||
'author': 'Authors', |
||||
'p': 'People', |
||||
'e': 'Events', |
||||
'title': 'Titles', |
||||
'summary': 'Summaries' |
||||
}; |
||||
</script> |
||||
|
||||
{#if uniqueTags.length > 0} |
||||
<div class="tag-table-container p-4"> |
||||
<h3 class="text-lg font-semibold mb-2"> |
||||
{tagTypeLabels[selectedTagType] || 'Tags'} |
||||
</h3> |
||||
<Table hoverable> |
||||
<thead> |
||||
<tr> |
||||
<th>Tag</th> |
||||
<th>Count</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{#each uniqueTags as tag} |
||||
<tr> |
||||
<td>{tag.value}</td> |
||||
<td>{tag.count}</td> |
||||
</tr> |
||||
{/each} |
||||
</tbody> |
||||
</Table> |
||||
</div> |
||||
{:else} |
||||
<div class="p-4 text-gray-500"> |
||||
No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.tag-table-container { |
||||
max-height: 300px; |
||||
overflow-y: auto; |
||||
} |
||||
</style> |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
/** |
||||
* Common utilities shared across network builders |
||||
*/ |
||||
|
||||
/** |
||||
* Seeded random number generator for deterministic layouts |
||||
*/ |
||||
export class SeededRandom { |
||||
private seed: number; |
||||
|
||||
constructor(seed: number) { |
||||
this.seed = seed; |
||||
} |
||||
|
||||
next(): number { |
||||
const x = Math.sin(this.seed++) * 10000; |
||||
return x - Math.floor(x); |
||||
} |
||||
|
||||
nextFloat(min: number, max: number): number { |
||||
return min + this.next() * (max - min); |
||||
} |
||||
|
||||
nextInt(min: number, max: number): number { |
||||
return Math.floor(this.nextFloat(min, max + 1)); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Creates a debug function with a prefix |
||||
* @param prefix - The prefix to add to all debug messages |
||||
* @returns A debug function that can be toggled on/off |
||||
*/ |
||||
export function createDebugFunction(prefix: string) { |
||||
const DEBUG = false; |
||||
return function debug(...args: any[]) { |
||||
if (DEBUG) { |
||||
console.log(`[${prefix}]`, ...args); |
||||
} |
||||
}; |
||||
} |
||||
@ -0,0 +1,337 @@
@@ -0,0 +1,337 @@
|
||||
/** |
||||
* Person Network Builder |
||||
* |
||||
* Creates person anchor nodes for event authors in the network visualization |
||||
*/ |
||||
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import type { NetworkNode, NetworkLink } from "../types"; |
||||
import { getDisplayNameSync } from "$lib/utils/profileCache"; |
||||
import { SeededRandom, createDebugFunction } from "./common"; |
||||
|
||||
const PERSON_ANCHOR_RADIUS = 15; |
||||
const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; |
||||
const MAX_PERSON_NODES = 20; // Default limit for person nodes
|
||||
|
||||
// Debug function
|
||||
const debug = createDebugFunction("PersonNetworkBuilder"); |
||||
|
||||
|
||||
/** |
||||
* Creates a deterministic seed from a string |
||||
*/ |
||||
function createSeed(str: string): number { |
||||
let hash = 0; |
||||
for (let i = 0; i < str.length; i++) { |
||||
const char = str.charCodeAt(i); |
||||
hash = (hash << 5) - hash + char; |
||||
hash = hash & hash; |
||||
} |
||||
return Math.abs(hash); |
||||
} |
||||
|
||||
export interface PersonConnection { |
||||
signedByEventIds: Set<string>; |
||||
referencedInEventIds: Set<string>; |
||||
isFromFollowList?: boolean; // Track if this person comes from follow lists
|
||||
} |
||||
|
||||
/** |
||||
* Extracts unique persons (pubkeys) from events |
||||
* Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags) |
||||
*/ |
||||
export function extractUniquePersons( |
||||
events: NDKEvent[], |
||||
followListEvents?: NDKEvent[] |
||||
): Map<string, PersonConnection> { |
||||
// Map of pubkey -> PersonConnection
|
||||
const personMap = new Map<string, PersonConnection>(); |
||||
|
||||
debug("Extracting unique persons", { eventCount: events.length, followListCount: followListEvents?.length || 0 }); |
||||
|
||||
// First collect pubkeys from follow list events
|
||||
const followListPubkeys = new Set<string>(); |
||||
if (followListEvents && followListEvents.length > 0) { |
||||
followListEvents.forEach((event) => { |
||||
// Follow list author
|
||||
if (event.pubkey) { |
||||
followListPubkeys.add(event.pubkey); |
||||
} |
||||
// People in follow lists (p tags)
|
||||
if (event.tags) { |
||||
event.tags |
||||
.filter(tag => { |
||||
tag[0] === 'p' |
||||
}) |
||||
.forEach(tag => { |
||||
followListPubkeys.add(tag[1]); |
||||
}); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
events.forEach((event) => { |
||||
if (!event.id) return; |
||||
|
||||
// Track signed-by connections
|
||||
if (event.pubkey) { |
||||
if (!personMap.has(event.pubkey)) { |
||||
personMap.set(event.pubkey, { |
||||
signedByEventIds: new Set(), |
||||
referencedInEventIds: new Set(), |
||||
isFromFollowList: followListPubkeys.has(event.pubkey) |
||||
}); |
||||
} |
||||
personMap.get(event.pubkey)!.signedByEventIds.add(event.id); |
||||
} |
||||
|
||||
// Track referenced connections from "p" tags
|
||||
if (event.tags) { |
||||
event.tags.forEach(tag => { |
||||
if (tag[0] === "p" && tag[1]) { |
||||
const referencedPubkey = tag[1]; |
||||
if (!personMap.has(referencedPubkey)) { |
||||
personMap.set(referencedPubkey, { |
||||
signedByEventIds: new Set(), |
||||
referencedInEventIds: new Set(), |
||||
isFromFollowList: followListPubkeys.has(referencedPubkey) |
||||
}); |
||||
} |
||||
personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
debug("Extracted persons", { personCount: personMap.size }); |
||||
|
||||
return personMap; |
||||
} |
||||
|
||||
/** |
||||
* Helper to build eligible person info for anchor nodes. |
||||
*/ |
||||
function buildEligiblePerson( |
||||
pubkey: string, |
||||
connection: PersonConnection, |
||||
showSignedBy: boolean, |
||||
showReferenced: boolean |
||||
): { |
||||
pubkey: string; |
||||
connection: PersonConnection; |
||||
connectedEventIds: Set<string>; |
||||
totalConnections: number; |
||||
} | null { |
||||
const connectedEventIds = new Set<string>(); |
||||
|
||||
if (showSignedBy) { |
||||
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); |
||||
} |
||||
|
||||
if (showReferenced) { |
||||
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); |
||||
} |
||||
|
||||
if (connectedEventIds.size === 0) { |
||||
return null; |
||||
} |
||||
|
||||
return { |
||||
pubkey, |
||||
connection, |
||||
connectedEventIds, |
||||
totalConnections: connectedEventIds.size |
||||
}; |
||||
} |
||||
|
||||
type EligiblePerson = { |
||||
pubkey: string; |
||||
connection: PersonConnection; |
||||
totalConnections: number; |
||||
connectedEventIds: Set<string>; |
||||
}; |
||||
|
||||
function getEligiblePersons( |
||||
personMap: Map<string, PersonConnection>, |
||||
showSignedBy: boolean, |
||||
showReferenced: boolean, |
||||
limit: number |
||||
): EligiblePerson[] { |
||||
// Build eligible persons and keep only top N using a min-heap or partial sort
|
||||
const eligible: EligiblePerson[] = []; |
||||
|
||||
for (const [pubkey, connection] of personMap) { |
||||
let totalConnections = 0; |
||||
if (showSignedBy) totalConnections += connection.signedByEventIds.size; |
||||
if (showReferenced) totalConnections += connection.referencedInEventIds.size; |
||||
if (totalConnections === 0) continue; |
||||
|
||||
// Only build the set if this person is eligible
|
||||
const connectedEventIds = new Set<string>(); |
||||
if (showSignedBy) { |
||||
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); |
||||
} |
||||
if (showReferenced) { |
||||
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); |
||||
} |
||||
|
||||
eligible.push({ pubkey, connection, totalConnections, connectedEventIds }); |
||||
} |
||||
|
||||
// Partial sort: get top N by totalConnections
|
||||
eligible.sort((a, b) => b.totalConnections - a.totalConnections); |
||||
return eligible.slice(0, limit); |
||||
} |
||||
|
||||
/** |
||||
* Creates person anchor nodes |
||||
*/ |
||||
export function createPersonAnchorNodes( |
||||
personMap: Map<string, PersonConnection>, |
||||
width: number, |
||||
height: number, |
||||
showSignedBy: boolean, |
||||
showReferenced: boolean, |
||||
limit: number = MAX_PERSON_NODES |
||||
): { nodes: NetworkNode[], totalCount: number } { |
||||
const anchorNodes: NetworkNode[] = []; |
||||
|
||||
const centerX = width / 2; |
||||
const centerY = height / 2; |
||||
|
||||
// Calculate eligible persons and their connection counts
|
||||
const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit); |
||||
|
||||
// Create nodes for the limited set
|
||||
debug("Creating person anchor nodes", {
|
||||
eligibleCount: eligiblePersons.length,
|
||||
limitedCount: eligiblePersons.length, |
||||
showSignedBy, |
||||
showReferenced
|
||||
}); |
||||
|
||||
eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => { |
||||
// Create seeded random generator for consistent positioning
|
||||
const rng = new SeededRandom(createSeed(pubkey)); |
||||
|
||||
// Generate deterministic position
|
||||
const angle = rng.next() * 2 * Math.PI; |
||||
const distance = rng.next() * PERSON_ANCHOR_PLACEMENT_RADIUS; |
||||
const x = centerX + distance * Math.cos(angle); |
||||
const y = centerY + distance * Math.sin(angle); |
||||
|
||||
// Get display name
|
||||
const displayName = getDisplayNameSync(pubkey); |
||||
|
||||
const anchorNode: NetworkNode = { |
||||
id: `person-anchor-${pubkey}`, |
||||
title: displayName, |
||||
content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`, |
||||
author: "", |
||||
kind: 0, // Special kind for anchors
|
||||
type: "PersonAnchor", |
||||
level: -1, |
||||
isPersonAnchor: true, |
||||
pubkey, |
||||
displayName, |
||||
connectedNodes: Array.from(connectedEventIds), |
||||
isFromFollowList: connection.isFromFollowList, |
||||
x, |
||||
y, |
||||
fx: x, // Fix position
|
||||
fy: y, |
||||
}; |
||||
|
||||
anchorNodes.push(anchorNode); |
||||
}); |
||||
|
||||
debug("Created person anchor nodes", { count: anchorNodes.length, totalEligible: eligiblePersons.length }); |
||||
|
||||
return { |
||||
nodes: anchorNodes, |
||||
totalCount: eligiblePersons.length |
||||
}; |
||||
} |
||||
|
||||
export interface PersonLink extends NetworkLink { |
||||
connectionType?: "signed-by" | "referenced"; |
||||
} |
||||
|
||||
/** |
||||
* Creates links between person anchors and their events |
||||
* Adds connection type for coloring |
||||
*/ |
||||
export function createPersonLinks( |
||||
personAnchors: NetworkNode[], |
||||
nodes: NetworkNode[], |
||||
personMap: Map<string, PersonConnection> |
||||
): PersonLink[] { |
||||
debug("Creating person links", { anchorCount: personAnchors.length, nodeCount: nodes.length }); |
||||
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n])); |
||||
|
||||
const links: PersonLink[] = personAnchors.flatMap((anchor) => { |
||||
if (!anchor.connectedNodes || !anchor.pubkey) { |
||||
return []; |
||||
} |
||||
|
||||
const connection = personMap.get(anchor.pubkey); |
||||
if (!connection) { |
||||
return []; |
||||
} |
||||
|
||||
return anchor.connectedNodes.map((nodeId) => { |
||||
const node = nodeMap.get(nodeId); |
||||
if (!node) { |
||||
return undefined; |
||||
} |
||||
|
||||
let connectionType: 'signed-by' | 'referenced' | undefined; |
||||
if (connection.signedByEventIds.has(nodeId)) { |
||||
connectionType = 'signed-by'; |
||||
} else if (connection.referencedInEventIds.has(nodeId)) { |
||||
connectionType = 'referenced'; |
||||
} |
||||
|
||||
return { |
||||
source: anchor, |
||||
target: node, |
||||
isSequential: false, |
||||
connectionType, |
||||
}; |
||||
}).filter(Boolean); // Remove undefineds
|
||||
}); |
||||
|
||||
debug("Created person links", { linkCount: links.length }); |
||||
return links; |
||||
} |
||||
|
||||
/** |
||||
* Formats person anchor info for display in Legend |
||||
*/ |
||||
export interface PersonAnchorInfo { |
||||
pubkey: string; |
||||
displayName: string; |
||||
signedByCount: number; |
||||
referencedCount: number; |
||||
isFromFollowList: boolean; |
||||
} |
||||
|
||||
/** |
||||
* Extracts person info for Legend display |
||||
*/ |
||||
export function extractPersonAnchorInfo( |
||||
personAnchors: NetworkNode[], |
||||
personMap: Map<string, PersonConnection> |
||||
): PersonAnchorInfo[] { |
||||
return personAnchors.map(anchor => { |
||||
const connection = personMap.get(anchor.pubkey || ""); |
||||
return { |
||||
pubkey: anchor.pubkey || "", |
||||
displayName: anchor.displayName || "", |
||||
signedByCount: connection?.signedByEventIds.size || 0, |
||||
referencedCount: connection?.referencedInEventIds.size || 0, |
||||
isFromFollowList: connection?.isFromFollowList || false, |
||||
}; |
||||
}); |
||||
} |
||||
@ -0,0 +1,308 @@
@@ -0,0 +1,308 @@
|
||||
/** |
||||
* Star Network Force Simulation |
||||
*
|
||||
* Custom force simulation optimized for star network layouts. |
||||
* Provides stronger connections between star centers and their content nodes, |
||||
* with specialized forces to maintain hierarchical structure. |
||||
*/ |
||||
|
||||
import * as d3 from "d3"; |
||||
import type { NetworkNode, NetworkLink } from "../types"; |
||||
import type { Simulation } from "./forceSimulation"; |
||||
import { createTagGravityForce } from "./tagNetworkBuilder"; |
||||
|
||||
// Configuration for star network forces
|
||||
const STAR_CENTER_CHARGE = -300; // Stronger repulsion between star centers
|
||||
const CONTENT_NODE_CHARGE = -50; // Weaker repulsion for content nodes
|
||||
const STAR_LINK_STRENGTH = 0.5; // Moderate connection to star center
|
||||
const INTER_STAR_LINK_STRENGTH = 0.2; // Weaker connection between stars
|
||||
const STAR_LINK_DISTANCE = 80; // Fixed distance from center to content
|
||||
const INTER_STAR_DISTANCE = 200; // Distance between star centers
|
||||
const CENTER_GRAVITY = 0.02; // Gentle pull toward canvas center
|
||||
const STAR_CENTER_WEIGHT = 10; // Weight multiplier for star centers
|
||||
|
||||
/** |
||||
* Creates a custom force simulation for star networks |
||||
*/ |
||||
export function createStarSimulation( |
||||
nodes: NetworkNode[], |
||||
links: NetworkLink[], |
||||
width: number, |
||||
height: number |
||||
): Simulation<NetworkNode, NetworkLink> { |
||||
// Create the simulation
|
||||
const simulation = d3.forceSimulation(nodes) as any |
||||
simulation |
||||
.force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY)) |
||||
.velocityDecay(0.2) // Lower decay for more responsive simulation
|
||||
.alphaDecay(0.0001) // Much slower alpha decay to prevent freezing
|
||||
.alphaMin(0.001); // Keep minimum energy to prevent complete freeze
|
||||
|
||||
// Custom charge force that varies by node type
|
||||
const chargeForce = d3.forceManyBody() |
||||
.strength((d: NetworkNode) => { |
||||
// Tag anchors don't repel
|
||||
if (d.isTagAnchor) { |
||||
return 0; |
||||
} |
||||
// Star centers repel each other strongly
|
||||
if (d.isContainer && d.kind === 30040) { |
||||
return STAR_CENTER_CHARGE; |
||||
} |
||||
// Content nodes have minimal repulsion
|
||||
return CONTENT_NODE_CHARGE; |
||||
}) |
||||
.distanceMax(300); // Limit charge force range
|
||||
|
||||
// Custom link force with variable strength and distance
|
||||
const linkForce = d3.forceLink(links) |
||||
.id((d: NetworkNode) => d.id) |
||||
.strength((link: any) => { |
||||
const source = link.source as NetworkNode; |
||||
const target = link.target as NetworkNode; |
||||
// Strong connection from star center to its content
|
||||
if (source.kind === 30040 && target.kind === 30041) { |
||||
return STAR_LINK_STRENGTH; |
||||
} |
||||
// Weaker connection between star centers
|
||||
if (source.kind === 30040 && target.kind === 30040) { |
||||
return INTER_STAR_LINK_STRENGTH; |
||||
} |
||||
return 0.5; // Default strength
|
||||
}) |
||||
.distance((link: any) => { |
||||
const source = link.source as NetworkNode; |
||||
const target = link.target as NetworkNode; |
||||
// Fixed distance for star-to-content links
|
||||
if (source.kind === 30040 && target.kind === 30041) { |
||||
return STAR_LINK_DISTANCE; |
||||
} |
||||
// Longer distance between star centers
|
||||
if (source.kind === 30040 && target.kind === 30040) { |
||||
return INTER_STAR_DISTANCE; |
||||
} |
||||
return 100; // Default distance
|
||||
}); |
||||
|
||||
// Apply forces to simulation
|
||||
simulation |
||||
.force("charge", chargeForce) |
||||
.force("link", linkForce); |
||||
|
||||
// Custom radial force to keep content nodes around their star center
|
||||
simulation.force("radial", createRadialForce(nodes, links)); |
||||
|
||||
// Add tag gravity force if there are tag anchors
|
||||
const hasTagAnchors = nodes.some(n => n.isTagAnchor); |
||||
if (hasTagAnchors) { |
||||
simulation.force("tagGravity", createTagGravityForce(nodes, links)); |
||||
} |
||||
|
||||
// Periodic reheat to prevent freezing
|
||||
let tickCount = 0; |
||||
simulation.on("tick", () => { |
||||
tickCount++; |
||||
// Every 300 ticks, give a small energy boost to prevent freezing
|
||||
if (tickCount % 300 === 0 && simulation.alpha() < 0.01) { |
||||
simulation.alpha(0.02); |
||||
} |
||||
}); |
||||
|
||||
return simulation; |
||||
} |
||||
|
||||
/** |
||||
* Applies the radial force to keep content nodes in orbit around their star center |
||||
* @param nodes - The array of network nodes |
||||
* @param nodeToCenter - Map of content node IDs to their star center node |
||||
* @param targetDistance - The desired distance from center to content node |
||||
* @param alpha - The current simulation alpha |
||||
*/ |
||||
function applyRadialForce( |
||||
nodes: NetworkNode[], |
||||
nodeToCenter: Map<string, NetworkNode>, |
||||
targetDistance: number, |
||||
alpha: number |
||||
): void { |
||||
nodes.forEach(node => { |
||||
if (node.kind === 30041) { |
||||
const center = nodeToCenter.get(node.id); |
||||
if ( |
||||
center && |
||||
center.x != null && |
||||
center.y != null && |
||||
node.x != null && |
||||
node.y != null |
||||
) { |
||||
// Calculate desired position
|
||||
const dx = node.x - center.x; |
||||
const dy = node.y - center.y; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance > 0) { |
||||
// Normalize and apply force
|
||||
const force = (distance - targetDistance) * alpha * 0.3; // Reduced force
|
||||
node.vx = (node.vx || 0) - (dx / distance) * force; |
||||
node.vy = (node.vy || 0) - (dy / distance) * force; |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Creates a custom radial force that keeps content nodes in orbit around their star center |
||||
*/ |
||||
function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any { |
||||
// Build a map of content nodes to their star centers
|
||||
const nodeToCenter = new Map<string, NetworkNode>(); |
||||
|
||||
links.forEach(link => { |
||||
const source = link.source as NetworkNode; |
||||
const target = link.target as NetworkNode; |
||||
if (source.kind === 30040 && target.kind === 30041) { |
||||
nodeToCenter.set(target.id, source); |
||||
} |
||||
}); |
||||
|
||||
function force(alpha: number) { |
||||
applyRadialForce(nodes, nodeToCenter, STAR_LINK_DISTANCE, alpha); |
||||
} |
||||
|
||||
force.initialize = function(_: NetworkNode[]) { |
||||
nodes = _; |
||||
}; |
||||
|
||||
return force; |
||||
} |
||||
|
||||
/** |
||||
* Applies initial positioning for star networks |
||||
*/ |
||||
export function applyInitialStarPositions( |
||||
nodes: NetworkNode[], |
||||
links: NetworkLink[], |
||||
width: number, |
||||
height: number |
||||
): void { |
||||
// Group nodes by their star centers
|
||||
const starGroups = new Map<string, NetworkNode[]>(); |
||||
const starCenters: NetworkNode[] = []; |
||||
|
||||
// Identify star centers
|
||||
nodes.forEach(node => { |
||||
if (node.isContainer && node.kind === 30040) { |
||||
starCenters.push(node); |
||||
starGroups.set(node.id, []); |
||||
} |
||||
}); |
||||
|
||||
// Assign content nodes to their star centers
|
||||
links.forEach(link => { |
||||
const source = link.source as NetworkNode; |
||||
const target = link.target as NetworkNode; |
||||
if (source.kind === 30040 && target.kind === 30041) { |
||||
const group = starGroups.get(source.id); |
||||
if (group) { |
||||
group.push(target); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
// Position star centers in a grid or circle
|
||||
if (starCenters.length === 1) { |
||||
// Single star - center it
|
||||
const center = starCenters[0]; |
||||
center.x = width / 2; |
||||
center.y = height / 2; |
||||
// Don't fix position initially - let simulation run naturally
|
||||
} else if (starCenters.length > 1) { |
||||
// Multiple stars - arrange in a circle
|
||||
const centerX = width / 2; |
||||
const centerY = height / 2; |
||||
const radius = Math.min(width, height) * 0.3; |
||||
const angleStep = (2 * Math.PI) / starCenters.length; |
||||
|
||||
starCenters.forEach((center, i) => { |
||||
const angle = i * angleStep; |
||||
center.x = centerX + radius * Math.cos(angle); |
||||
center.y = centerY + radius * Math.sin(angle); |
||||
// Don't fix position initially - let simulation adjust
|
||||
}); |
||||
} |
||||
|
||||
// Position content nodes around their star centers
|
||||
starGroups.forEach((contentNodes, centerId) => { |
||||
const center = nodes.find(n => n.id === centerId); |
||||
if (!center) return; |
||||
|
||||
const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1); |
||||
contentNodes.forEach((node, i) => { |
||||
const angle = i * angleStep; |
||||
node.x = (center.x || 0) + STAR_LINK_DISTANCE * Math.cos(angle); |
||||
node.y = (center.y || 0) + STAR_LINK_DISTANCE * Math.sin(angle); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Handler for the start of a drag event in the star network simulation. |
||||
* Sets the fixed position of the node to its current position. |
||||
* @param event - The drag event from d3 |
||||
* @param d - The node being dragged |
||||
* @param simulation - The d3 force simulation instance |
||||
*/ |
||||
function dragstarted(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) { |
||||
// If no other drag is active, set a low alpha target to keep the simulation running smoothly
|
||||
if (!event.active) { |
||||
simulation.alphaTarget(0.1).restart(); |
||||
} |
||||
// Set the node's fixed position to its current position
|
||||
d.fx = d.x; |
||||
d.fy = d.y; |
||||
} |
||||
|
||||
/** |
||||
* Handler for the drag event in the star network simulation. |
||||
* Updates the node's fixed position to follow the mouse. |
||||
* @param event - The drag event from d3 |
||||
* @param d - The node being dragged |
||||
*/ |
||||
function dragged(event: any, d: NetworkNode) { |
||||
// Update the node's fixed position to the current mouse position
|
||||
d.fx = event.x; |
||||
d.fy = event.y; |
||||
} |
||||
|
||||
/** |
||||
* Handler for the end of a drag event in the star network simulation. |
||||
* Keeps the node fixed at its new position after dragging. |
||||
* @param event - The drag event from d3 |
||||
* @param d - The node being dragged |
||||
* @param simulation - The d3 force simulation instance |
||||
*/ |
||||
function dragended(event: any, d: NetworkNode, simulation: Simulation<NetworkNode, NetworkLink>) { |
||||
// If no other drag is active, lower the alpha target to let the simulation cool down
|
||||
if (!event.active) { |
||||
simulation.alphaTarget(0); |
||||
} |
||||
// Keep the node fixed at its new position
|
||||
d.fx = event.x; |
||||
d.fy = event.y; |
||||
} |
||||
|
||||
/** |
||||
* Custom drag handler for star networks |
||||
* @param simulation - The d3 force simulation instance |
||||
* @returns The d3 drag behavior |
||||
*/ |
||||
export function createStarDragHandler( |
||||
simulation: Simulation<NetworkNode, NetworkLink> |
||||
): any { |
||||
// These handlers are now top-level functions, so we use closures to pass simulation to them.
|
||||
// This is a common pattern in JavaScript/TypeScript when you need to pass extra arguments to event handlers.
|
||||
return d3.drag() |
||||
.on('start', function(event: any, d: NetworkNode) { dragstarted(event, d, simulation); }) |
||||
.on('drag', dragged) |
||||
.on('end', function(event: any, d: NetworkNode) { dragended(event, d, simulation); }); |
||||
} |
||||
@ -0,0 +1,353 @@
@@ -0,0 +1,353 @@
|
||||
/** |
||||
* Star Network Builder for NKBIP-01 Events |
||||
*
|
||||
* This module provides utilities for building star network visualizations specifically |
||||
* for NKBIP-01 events (kinds 30040 and 30041). Unlike the sequential network builder, |
||||
* this creates star formations where index events (30040) are central nodes with
|
||||
* content events (30041) arranged around them. |
||||
*/ |
||||
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; |
||||
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||
import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder'; |
||||
import { createDebugFunction } from './common'; |
||||
import { wikiKind, indexKind, zettelKinds } from '$lib/consts'; |
||||
|
||||
|
||||
// Debug function
|
||||
const debug = createDebugFunction("StarNetworkBuilder"); |
||||
|
||||
/** |
||||
* Represents a star network with a central index node and peripheral content nodes |
||||
*/ |
||||
export interface StarNetwork { |
||||
center: NetworkNode; // Central index node (30040)
|
||||
peripheralNodes: NetworkNode[]; // Content nodes (30041) and connected indices (30040)
|
||||
links: NetworkLink[]; // Links within this star
|
||||
} |
||||
|
||||
/** |
||||
* Creates a star network from an index event and its references |
||||
*
|
||||
* @param indexEvent - The central index event (30040) |
||||
* @param state - Current graph state |
||||
* @param level - Hierarchy level for this star |
||||
* @returns A star network structure |
||||
*/ |
||||
export function createStarNetwork( |
||||
indexEvent: NDKEvent, |
||||
state: GraphState, |
||||
level: number = 0 |
||||
): StarNetwork | null { |
||||
debug("Creating star network", { indexId: indexEvent.id, level }); |
||||
|
||||
const centerNode = state.nodeMap.get(indexEvent.id); |
||||
if (!centerNode) { |
||||
debug("Center node not found for index event", indexEvent.id); |
||||
return null; |
||||
} |
||||
|
||||
// Set the center node level
|
||||
centerNode.level = level; |
||||
|
||||
// Extract referenced event IDs from 'a' tags
|
||||
const referencedIds = getMatchingTags(indexEvent, "a") |
||||
.map(tag => extractEventIdFromATag(tag)) |
||||
.filter((id): id is string => id !== null); |
||||
|
||||
debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds }); |
||||
|
||||
// Get peripheral nodes (both content and nested indices)
|
||||
const peripheralNodes: NetworkNode[] = []; |
||||
const links: NetworkLink[] = []; |
||||
|
||||
referencedIds.forEach(id => { |
||||
const node = state.nodeMap.get(id); |
||||
if (node) { |
||||
// Set the peripheral node level
|
||||
node.level += 1; |
||||
peripheralNodes.push(node); |
||||
|
||||
// Create link from center to peripheral node
|
||||
links.push({ |
||||
source: centerNode, |
||||
target: node, |
||||
isSequential: false // Star links are not sequential
|
||||
}); |
||||
|
||||
debug("Added peripheral node", { nodeId: id, nodeType: node.type }); |
||||
} |
||||
}); |
||||
|
||||
return { |
||||
center: centerNode, |
||||
peripheralNodes, |
||||
links |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Processes all index events to create star networks |
||||
*
|
||||
* @param events - Array of all events |
||||
* @param maxLevel - Maximum nesting level to process |
||||
* @returns Array of star networks |
||||
*/ |
||||
export function createStarNetworks( |
||||
events: NDKEvent[], |
||||
maxLevel: number, |
||||
existingNodeMap?: Map<string, NetworkNode> |
||||
): StarNetwork[] { |
||||
debug("Creating star networks", { eventCount: events.length, maxLevel }); |
||||
|
||||
// Use existing node map or create new one
|
||||
const nodeMap = existingNodeMap || new Map<string, NetworkNode>(); |
||||
const eventMap = createEventMap(events); |
||||
|
||||
// Create nodes for all events if not using existing map
|
||||
if (!existingNodeMap) { |
||||
events.forEach(event => { |
||||
if (!event.id) return; |
||||
const node = createNetworkNode(event); |
||||
nodeMap.set(event.id, node); |
||||
}); |
||||
} |
||||
|
||||
const state: GraphState = { |
||||
nodeMap, |
||||
links: [], |
||||
eventMap, |
||||
referencedIds: new Set<string>() |
||||
}; |
||||
|
||||
// Find all index events and non-publication events
|
||||
const publicationKinds = [wikiKind, indexKind, ...zettelKinds]; |
||||
const indexEvents = events.filter(event => event.kind === indexKind); |
||||
const nonPublicationEvents = events.filter(event =>
|
||||
event.kind !== undefined && !publicationKinds.includes(event.kind) |
||||
); |
||||
|
||||
debug("Found index events", { count: indexEvents.length }); |
||||
debug("Found non-publication events", { count: nonPublicationEvents.length }); |
||||
|
||||
const starNetworks: StarNetwork[] = []; |
||||
const processedIndices = new Set<string>(); |
||||
|
||||
// Process all index events regardless of level
|
||||
indexEvents.forEach(indexEvent => { |
||||
if (!indexEvent.id || processedIndices.has(indexEvent.id)) return; |
||||
|
||||
const star = createStarNetwork(indexEvent, state, 0); |
||||
if (star && star.peripheralNodes.length > 0) { |
||||
starNetworks.push(star); |
||||
processedIndices.add(indexEvent.id); |
||||
debug("Created star network", {
|
||||
centerId: star.center.id,
|
||||
peripheralCount: star.peripheralNodes.length |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
// Add non-publication events as standalone nodes (stars with no peripherals)
|
||||
nonPublicationEvents.forEach(event => { |
||||
if (!event.id || !nodeMap.has(event.id)) return; |
||||
|
||||
const node = nodeMap.get(event.id)!; |
||||
const star: StarNetwork = { |
||||
center: node, |
||||
peripheralNodes: [], |
||||
links: [] |
||||
}; |
||||
starNetworks.push(star); |
||||
debug("Created standalone star for non-publication event", {
|
||||
eventId: event.id, |
||||
kind: event.kind |
||||
}); |
||||
}); |
||||
|
||||
return starNetworks; |
||||
} |
||||
|
||||
/** |
||||
* Creates inter-star connections between star networks |
||||
*
|
||||
* @param starNetworks - Array of star networks |
||||
* @returns Additional links connecting different star networks |
||||
*/ |
||||
export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] { |
||||
debug("Creating inter-star connections", { starCount: starNetworks.length }); |
||||
|
||||
const interStarLinks: NetworkLink[] = []; |
||||
|
||||
// Create a map of center nodes for quick lookup
|
||||
const centerNodeMap = new Map<string, NetworkNode>(); |
||||
starNetworks.forEach(star => { |
||||
centerNodeMap.set(star.center.id, star.center); |
||||
}); |
||||
|
||||
// For each star, check if any of its peripheral nodes are centers of other stars
|
||||
starNetworks.forEach(star => { |
||||
star.peripheralNodes.forEach(peripheralNode => { |
||||
// If this peripheral node is the center of another star, create an inter-star link
|
||||
if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) { |
||||
const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id); |
||||
if (targetStar) { |
||||
interStarLinks.push({ |
||||
source: star.center, |
||||
target: targetStar.center, |
||||
isSequential: false |
||||
}); |
||||
debug("Created inter-star connection", {
|
||||
from: star.center.id,
|
||||
to: targetStar.center.id
|
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
return interStarLinks; |
||||
} |
||||
|
||||
/** |
||||
* Applies star-specific positioning to nodes using a radial layout |
||||
*
|
||||
* @param starNetworks - Array of star networks |
||||
* @param width - Canvas width |
||||
* @param height - Canvas height |
||||
*/ |
||||
export function applyStarLayout( |
||||
starNetworks: StarNetwork[], |
||||
width: number, |
||||
height: number |
||||
): void { |
||||
debug("Applying star layout", {
|
||||
starCount: starNetworks.length,
|
||||
dimensions: { width, height }
|
||||
}); |
||||
|
||||
const centerX = width / 2; |
||||
const centerY = height / 2; |
||||
|
||||
// If only one star, center it
|
||||
if (starNetworks.length === 1) { |
||||
const star = starNetworks[0]; |
||||
|
||||
// Position center node
|
||||
star.center.x = centerX; |
||||
star.center.y = centerY; |
||||
star.center.fx = centerX; // Fix center position
|
||||
star.center.fy = centerY; |
||||
|
||||
// Position peripheral nodes in a circle around center
|
||||
const radius = Math.min(width, height) * 0.25; |
||||
const angleStep = (2 * Math.PI) / star.peripheralNodes.length; |
||||
|
||||
star.peripheralNodes.forEach((node, index) => { |
||||
const angle = index * angleStep; |
||||
node.x = centerX + radius * Math.cos(angle); |
||||
node.y = centerY + radius * Math.sin(angle); |
||||
}); |
||||
|
||||
return; |
||||
} |
||||
|
||||
// For multiple stars, arrange them in a grid or circle
|
||||
const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length)); |
||||
const starSpacingX = width / (starsPerRow + 1); |
||||
const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1); |
||||
|
||||
starNetworks.forEach((star, index) => { |
||||
const row = Math.floor(index / starsPerRow); |
||||
const col = index % starsPerRow; |
||||
|
||||
const starCenterX = (col + 1) * starSpacingX; |
||||
const starCenterY = (row + 1) * starSpacingY; |
||||
|
||||
// Position center node
|
||||
star.center.x = starCenterX; |
||||
star.center.y = starCenterY; |
||||
star.center.fx = starCenterX; // Fix center position
|
||||
star.center.fy = starCenterY; |
||||
|
||||
// Position peripheral nodes around this star's center
|
||||
const radius = Math.min(starSpacingX, starSpacingY) * 0.3; |
||||
const angleStep = (2 * Math.PI) / Math.max(star.peripheralNodes.length, 1); |
||||
|
||||
star.peripheralNodes.forEach((node, nodeIndex) => { |
||||
const angle = nodeIndex * angleStep; |
||||
node.x = starCenterX + radius * Math.cos(angle); |
||||
node.y = starCenterY + radius * Math.sin(angle); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Generates a complete star network graph from events |
||||
*
|
||||
* @param events - Array of Nostr events |
||||
* @param maxLevel - Maximum hierarchy level to process |
||||
* @returns Complete graph data with star network layout |
||||
*/ |
||||
export function generateStarGraph( |
||||
events: NDKEvent[], |
||||
maxLevel: number |
||||
): GraphData { |
||||
debug("Generating star graph", { eventCount: events.length, maxLevel }); |
||||
|
||||
// Guard against empty events
|
||||
if (!events || events.length === 0) { |
||||
return { nodes: [], links: [] }; |
||||
} |
||||
|
||||
// Initialize all nodes first
|
||||
const nodeMap = new Map<string, NetworkNode>(); |
||||
events.forEach(event => { |
||||
if (!event.id) return; |
||||
const node = createNetworkNode(event); |
||||
nodeMap.set(event.id, node); |
||||
}); |
||||
|
||||
// Create star networks with the existing node map
|
||||
const starNetworks = createStarNetworks(events, maxLevel, nodeMap); |
||||
|
||||
// Create inter-star connections
|
||||
const interStarLinks = createInterStarConnections(starNetworks); |
||||
|
||||
// Collect nodes that are part of stars
|
||||
const nodesInStars = new Set<string>(); |
||||
const allLinks: NetworkLink[] = []; |
||||
|
||||
// Add nodes and links from all stars
|
||||
starNetworks.forEach(star => { |
||||
nodesInStars.add(star.center.id); |
||||
star.peripheralNodes.forEach(node => { |
||||
nodesInStars.add(node.id); |
||||
}); |
||||
allLinks.push(...star.links); |
||||
}); |
||||
|
||||
// Add inter-star links
|
||||
allLinks.push(...interStarLinks); |
||||
|
||||
// Include orphaned nodes (those not in any star)
|
||||
const allNodes: NetworkNode[] = []; |
||||
nodeMap.forEach((node, id) => { |
||||
allNodes.push(node); |
||||
}); |
||||
|
||||
const result = { |
||||
nodes: allNodes, |
||||
links: allLinks |
||||
}; |
||||
|
||||
debug("Star graph generation complete", {
|
||||
nodeCount: result.nodes.length,
|
||||
linkCount: result.links.length, |
||||
starCount: starNetworks.length, |
||||
orphanedNodes: allNodes.length - nodesInStars.size |
||||
}); |
||||
|
||||
return result; |
||||
} |
||||
@ -0,0 +1,314 @@
@@ -0,0 +1,314 @@
|
||||
/** |
||||
* Tag Network Builder |
||||
* |
||||
* Enhances network visualizations with tag anchor nodes that act as gravity points |
||||
* for nodes sharing the same tags. |
||||
*/ |
||||
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import type { NetworkNode, NetworkLink, GraphData } from "../types"; |
||||
import { getDisplayNameSync } from "$lib/utils/profileCache"; |
||||
import { SeededRandom, createDebugFunction } from "./common"; |
||||
|
||||
// Configuration
|
||||
const TAG_ANCHOR_RADIUS = 15; |
||||
// TODO: Move this to settings panel for user control
|
||||
const TAG_ANCHOR_PLACEMENT_RADIUS = 1250; // Radius from center within which to randomly place tag anchors
|
||||
|
||||
// Debug function
|
||||
const debug = createDebugFunction("TagNetworkBuilder"); |
||||
|
||||
|
||||
/** |
||||
* Creates a deterministic seed from a string |
||||
*/ |
||||
function createSeed(str: string): number { |
||||
let hash = 0; |
||||
for (let i = 0; i < str.length; i++) { |
||||
const char = str.charCodeAt(i); |
||||
hash = (hash << 5) - hash + char; |
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
} |
||||
return Math.abs(hash); |
||||
} |
||||
|
||||
/** |
||||
* Color mapping for tag anchor nodes |
||||
*/ |
||||
export function getTagAnchorColor(tagType: string): string { |
||||
switch (tagType) { |
||||
case "t": |
||||
return "#eba5a5"; // Blue for hashtags
|
||||
case "p": |
||||
return "#10B981"; // Green for people
|
||||
case "author": |
||||
return "#8B5CF6"; // Purple for authors
|
||||
case "e": |
||||
return "#F59E0B"; // Yellow for events
|
||||
case "a": |
||||
return "#EF4444"; // Red for articles
|
||||
case "kind3": |
||||
return "#06B6D4"; // Cyan for follow lists
|
||||
default: |
||||
return "#6B7280"; // Gray for others
|
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Extracts unique tags from events for a specific tag type |
||||
*/ |
||||
export function extractUniqueTagsForType( |
||||
events: NDKEvent[], |
||||
tagType: string, |
||||
): Map<string, Set<string>> { |
||||
// Map of tagValue -> Set of event IDs
|
||||
const tagMap = new Map<string, Set<string>>(); |
||||
debug("Extracting unique tags for type", { tagType, eventCount: events.length }); |
||||
|
||||
events.forEach((event) => { |
||||
if (!event.tags || !event.id) return; |
||||
|
||||
event.tags.forEach((tag) => { |
||||
if (tag.length < 2) return; |
||||
|
||||
if (tag[0] !== tagType) return; |
||||
const tagValue = tag[1]; |
||||
|
||||
if (!tagValue) return; |
||||
|
||||
if (!tagMap.has(tagValue)) { |
||||
tagMap.set(tagValue, new Set()); |
||||
} |
||||
|
||||
tagMap.get(tagValue)!.add(event.id); |
||||
}); |
||||
}); |
||||
|
||||
debug("Extracted tags", { tagCount: tagMap.size }); |
||||
|
||||
return tagMap; |
||||
} |
||||
|
||||
/** |
||||
* Creates tag anchor nodes from extracted tags of a specific type |
||||
*/ |
||||
export function createTagAnchorNodes( |
||||
tagMap: Map<string, Set<string>>, |
||||
tagType: string, |
||||
width: number, |
||||
height: number, |
||||
): NetworkNode[] { |
||||
const anchorNodes: NetworkNode[] = []; |
||||
|
||||
debug("Creating tag anchor nodes", { tagType, tagCount: tagMap.size }); |
||||
|
||||
// Calculate positions for tag anchors randomly within radius
|
||||
// Show all tags regardless of how many events they appear in
|
||||
const minEventCount = 1; |
||||
let validTags = Array.from(tagMap.entries()).filter( |
||||
([_, eventIds]) => eventIds.size >= minEventCount, |
||||
); |
||||
|
||||
if (validTags.length === 0) return []; |
||||
|
||||
// Sort all tags by number of connections (events) descending
|
||||
validTags.sort((a, b) => b[1].size - a[1].size); |
||||
|
||||
validTags.forEach(([tagValue, eventIds]) => { |
||||
// Position anchors randomly within a radius from the center
|
||||
const centerX = width / 2; |
||||
const centerY = height / 2; |
||||
|
||||
// Create seeded random generator based on tag type and value for consistent positioning
|
||||
const seedString = `${tagType}-${tagValue}`; |
||||
const rng = new SeededRandom(createSeed(seedString)); |
||||
|
||||
// Generate deterministic position within the defined radius
|
||||
const angle = rng.next() * 2 * Math.PI; |
||||
const distance = rng.next() * TAG_ANCHOR_PLACEMENT_RADIUS; |
||||
const x = centerX + distance * Math.cos(angle); |
||||
const y = centerY + distance * Math.sin(angle); |
||||
|
||||
// Format the display title based on tag type
|
||||
let displayTitle = tagValue; |
||||
if (tagType === "t") { |
||||
displayTitle = tagValue.startsWith("#") ? tagValue : `#${tagValue}`; |
||||
} else if (tagType === "author") { |
||||
displayTitle = tagValue; |
||||
} else if (tagType === "p") { |
||||
// Use display name for pubkey
|
||||
displayTitle = getDisplayNameSync(tagValue); |
||||
} |
||||
|
||||
const anchorNode: NetworkNode = { |
||||
id: `tag-anchor-${tagType}-${tagValue}`, |
||||
title: displayTitle, |
||||
content: `${eventIds.size} events`, |
||||
author: "", |
||||
kind: 0, // Special kind for tag anchors
|
||||
type: "TagAnchor", |
||||
level: -1, // Tag anchors are outside the hierarchy
|
||||
isTagAnchor: true, |
||||
tagType, |
||||
tagValue, |
||||
connectedNodes: Array.from(eventIds), |
||||
x, |
||||
y, |
||||
fx: x, // Fix position
|
||||
fy: y, |
||||
}; |
||||
|
||||
anchorNodes.push(anchorNode); |
||||
}); |
||||
|
||||
debug("Created tag anchor nodes", { count: anchorNodes.length }); |
||||
return anchorNodes; |
||||
} |
||||
|
||||
/** |
||||
* Creates invisible links between tag anchors and nodes that have those tags |
||||
*/ |
||||
export function createTagLinks( |
||||
tagAnchors: NetworkNode[], |
||||
nodes: NetworkNode[], |
||||
): NetworkLink[] { |
||||
debug("Creating tag links", { anchorCount: tagAnchors.length, nodeCount: nodes.length }); |
||||
|
||||
const links: NetworkLink[] = []; |
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n])); |
||||
|
||||
tagAnchors.forEach((anchor) => { |
||||
if (!anchor.connectedNodes) return; |
||||
|
||||
anchor.connectedNodes.forEach((nodeId) => { |
||||
const node = nodeMap.get(nodeId); |
||||
if (node) { |
||||
links.push({ |
||||
source: anchor, |
||||
target: node, |
||||
isSequential: false, |
||||
}); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
debug("Created tag links", { linkCount: links.length }); |
||||
return links; |
||||
} |
||||
|
||||
/** |
||||
* Enhances a graph with tag anchor nodes for a specific tag type |
||||
*/ |
||||
export function enhanceGraphWithTags( |
||||
graphData: GraphData, |
||||
events: NDKEvent[], |
||||
tagType: string, |
||||
width: number, |
||||
height: number, |
||||
displayLimit?: number, |
||||
): GraphData { |
||||
debug("Enhancing graph with tags", { tagType, displayLimit }); |
||||
|
||||
// Extract unique tags for the specified type
|
||||
const tagMap = extractUniqueTagsForType(events, tagType); |
||||
|
||||
// Create tag anchor nodes
|
||||
let tagAnchors = createTagAnchorNodes(tagMap, tagType, width, height); |
||||
|
||||
// Apply display limit if provided
|
||||
if (displayLimit && displayLimit > 0 && tagAnchors.length > displayLimit) { |
||||
// Sort by connection count (already done in createTagAnchorNodes)
|
||||
// and take only the top ones up to the limit
|
||||
tagAnchors = tagAnchors.slice(0, displayLimit); |
||||
} |
||||
|
||||
// Create links between anchors and nodes
|
||||
const tagLinks = createTagLinks(tagAnchors, graphData.nodes); |
||||
|
||||
// Return enhanced graph
|
||||
return { |
||||
nodes: [...graphData.nodes, ...tagAnchors], |
||||
links: [...graphData.links, ...tagLinks], |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Applies a gentle pull on each node toward its tag anchors. |
||||
* |
||||
* @param nodes - The array of network nodes to update. |
||||
* @param nodeToAnchors - A map from node IDs to their tag anchor nodes. |
||||
* @param alpha - The current simulation alpha (cooling factor). |
||||
*/ |
||||
export function applyTagGravity( |
||||
nodes: NetworkNode[], |
||||
nodeToAnchors: Map<string, NetworkNode[]>, |
||||
alpha: number |
||||
): void { |
||||
nodes.forEach((node) => { |
||||
if (node.isTagAnchor) return; // Tag anchors don't move
|
||||
|
||||
const anchors = nodeToAnchors.get(node.id); |
||||
if (!anchors || anchors.length === 0) return; |
||||
|
||||
// Apply gentle pull toward each tag anchor
|
||||
anchors.forEach((anchor) => { |
||||
if ( |
||||
anchor.x != null && |
||||
anchor.y != null && |
||||
node.x != null && |
||||
node.y != null |
||||
) { |
||||
const dx = anchor.x - node.x; |
||||
const dy = anchor.y - node.y; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance > 0) { |
||||
// Gentle force that decreases with distance
|
||||
const strength = (0.02 * alpha) / anchors.length; |
||||
node.vx = (node.vx || 0) + (dx / distance) * strength * distance; |
||||
node.vy = (node.vy || 0) + (dy / distance) * strength * distance; |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Custom force for tag anchor gravity |
||||
*/ |
||||
export function createTagGravityForce( |
||||
nodes: NetworkNode[], |
||||
links: NetworkLink[], |
||||
): any { |
||||
// Build a map of nodes to their tag anchors
|
||||
const nodeToAnchors = new Map<string, NetworkNode[]>(); |
||||
|
||||
links.forEach((link) => { |
||||
const source = link.source as NetworkNode; |
||||
const target = link.target as NetworkNode; |
||||
|
||||
if (source.isTagAnchor && !target.isTagAnchor) { |
||||
if (!nodeToAnchors.has(target.id)) { |
||||
nodeToAnchors.set(target.id, []); |
||||
} |
||||
nodeToAnchors.get(target.id)!.push(source); |
||||
} else if (target.isTagAnchor && !source.isTagAnchor) { |
||||
if (!nodeToAnchors.has(source.id)) { |
||||
nodeToAnchors.set(source.id, []); |
||||
} |
||||
nodeToAnchors.get(source.id)!.push(target); |
||||
} |
||||
}); |
||||
|
||||
debug("Creating tag gravity force"); |
||||
|
||||
function force(alpha: number) { |
||||
applyTagGravity(nodes, nodeToAnchors, alpha); |
||||
} |
||||
|
||||
force.initialize = function (_: NetworkNode[]) { |
||||
nodes = _; |
||||
}; |
||||
|
||||
return force; |
||||
} |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export * from './relayStore'; |
||||
export * from './displayLimits'; |
||||
@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
|
||||
import { writable, derived, get } from "svelte/store"; |
||||
|
||||
export interface EventKindConfig { |
||||
kind: number; |
||||
limit: number; |
||||
enabled?: boolean; // Whether this kind is enabled for display
|
||||
nestedLevels?: number; // Only for kind 30040
|
||||
depth?: number; // Only for kind 3 (follow lists)
|
||||
showAll?: boolean; // Only for content kinds (30041, 30818) - show all loaded content instead of limit
|
||||
} |
||||
|
||||
/** |
||||
* VisualizationConfig now uses a Map<number, string> for eventConfigs. |
||||
* The key is the event kind (number), and the value is a JSON stringified EventKindConfig. |
||||
* This allows O(1) retrieval of config by kind. |
||||
*/ |
||||
export interface VisualizationConfig { |
||||
/** |
||||
* Event configurations with per-kind limits. |
||||
*/ |
||||
eventConfigs: EventKindConfig[]; |
||||
|
||||
/** |
||||
* Whether to search through all fetched events during graph traversal. |
||||
*/ |
||||
searchThroughFetched: boolean; |
||||
} |
||||
|
||||
// Default configurations for common event kinds
|
||||
const DEFAULT_EVENT_CONFIGS: EventKindConfig[] = [ |
||||
{ kind: 30040, limit: 20, nestedLevels: 1, enabled: true }, |
||||
{ kind: 30041, limit: 20, enabled: false }, |
||||
{ kind: 30818, limit: 20, enabled: false }, |
||||
{ kind: 30023, limit: 20, enabled: false }, |
||||
]; |
||||
|
||||
function createVisualizationConfig() { |
||||
const initialConfig: VisualizationConfig = { |
||||
eventConfigs: DEFAULT_EVENT_CONFIGS, |
||||
searchThroughFetched: true, |
||||
}; |
||||
|
||||
const { subscribe, set, update } = writable<VisualizationConfig>(initialConfig); |
||||
|
||||
function reset() { |
||||
set(initialConfig); |
||||
} |
||||
|
||||
function addEventKind(kind: number, limit: number = 10) { |
||||
update((config) => { |
||||
// Check if kind already exists
|
||||
if (config.eventConfigs.some((ec) => ec.kind === kind)) { |
||||
return config; |
||||
} |
||||
|
||||
const newConfig: EventKindConfig = { kind, limit, enabled: true }; |
||||
|
||||
// Add nestedLevels for 30040
|
||||
if (kind === 30040) { |
||||
newConfig.nestedLevels = 1; |
||||
} |
||||
|
||||
// Add depth for kind 3
|
||||
if (kind === 3) { |
||||
newConfig.depth = 0; |
||||
} |
||||
|
||||
return { |
||||
...config, |
||||
eventConfigs: [...config.eventConfigs, newConfig], |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
function removeEventKind(kind: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.filter((ec) => ec.kind !== kind), |
||||
})); |
||||
} |
||||
|
||||
function updateEventLimit(kind: number, limit: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.map((ec) => |
||||
ec.kind === kind ? { ...ec, limit } : ec, |
||||
), |
||||
})); |
||||
} |
||||
|
||||
function updateNestedLevels(levels: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.map((ec) => |
||||
ec.kind === 30040 ? { ...ec, nestedLevels: levels } : ec, |
||||
), |
||||
})); |
||||
} |
||||
|
||||
function updateFollowDepth(depth: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.map((ec) => |
||||
ec.kind === 3 ? { ...ec, depth: depth } : ec, |
||||
), |
||||
})); |
||||
} |
||||
|
||||
function toggleShowAllContent(kind: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.map((ec) => |
||||
ec.kind === kind ? { ...ec, showAll: !ec.showAll } : ec, |
||||
), |
||||
})); |
||||
} |
||||
|
||||
function getEventConfig(kind: number) { |
||||
let config: EventKindConfig | undefined; |
||||
subscribe((c) => { |
||||
config = c.eventConfigs.find((ec) => ec.kind === kind); |
||||
})(); |
||||
return config; |
||||
} |
||||
|
||||
function toggleSearchThroughFetched() { |
||||
update((config) => ({ |
||||
...config, |
||||
searchThroughFetched: !config.searchThroughFetched, |
||||
})); |
||||
} |
||||
|
||||
function toggleKind(kind: number) { |
||||
update((config) => ({ |
||||
...config, |
||||
eventConfigs: config.eventConfigs.map((ec) => |
||||
ec.kind === kind ? { ...ec, enabled: !ec.enabled } : ec, |
||||
), |
||||
})); |
||||
} |
||||
|
||||
return { |
||||
subscribe, |
||||
update, |
||||
reset, |
||||
addEventKind, |
||||
removeEventKind, |
||||
updateEventLimit, |
||||
updateNestedLevels, |
||||
updateFollowDepth, |
||||
toggleShowAllContent, |
||||
getEventConfig, |
||||
toggleSearchThroughFetched, |
||||
toggleKind, |
||||
}; |
||||
} |
||||
|
||||
export const visualizationConfig = createVisualizationConfig(); |
||||
|
||||
// Helper to get all enabled event kinds
|
||||
export const enabledEventKinds = derived(visualizationConfig, ($config) => |
||||
$config.eventConfigs |
||||
.filter((ec) => ec.enabled !== false) |
||||
.map((ec) => ec.kind), |
||||
); |
||||
|
||||
/** |
||||
* Returns true if the given event kind is enabled in the config. |
||||
* @param config - The VisualizationConfig object. |
||||
* @param kind - The event kind number to check. |
||||
*/ |
||||
export function isKindEnabledFn(config: VisualizationConfig, kind: number): boolean { |
||||
const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind); |
||||
// If not found, return false. Otherwise, return true unless explicitly disabled.
|
||||
return !!eventConfig && eventConfig.enabled !== false; |
||||
} |
||||
|
||||
// Derived store: returns a function that checks if a kind is enabled in the current config.
|
||||
export const isKindEnabledStore = derived( |
||||
visualizationConfig, |
||||
($config) => (kind: number) => isKindEnabledFn($config, kind) |
||||
); |
||||
@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import type { VisualizationConfig } from '$lib/stores/visualizationConfig'; |
||||
import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers'; |
||||
import type { NostrEventId } from './nostr_identifiers'; |
||||
|
||||
/** |
||||
* Filters events based on visualization configuration |
||||
* @param events - All available events |
||||
* @param config - Visualization configuration |
||||
* @returns Filtered events that should be displayed |
||||
*/ |
||||
export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationConfig): NDKEvent[] { |
||||
const result: NDKEvent[] = []; |
||||
const kindCounts = new Map<number, number>(); |
||||
|
||||
for (const event of events) { |
||||
const kind = event.kind; |
||||
if (kind === undefined) continue; |
||||
|
||||
// Get the config for this event kind
|
||||
const eventConfig = config.eventConfigs.find(ec => ec.kind === kind); |
||||
|
||||
// Skip if the kind is disabled
|
||||
if (eventConfig && eventConfig.enabled === false) { |
||||
continue; |
||||
} |
||||
|
||||
const limit = eventConfig?.limit; |
||||
|
||||
// Special handling for content kinds (30041, 30818) with showAll option
|
||||
if ((kind === 30041 || kind === 30818) && eventConfig?.showAll) { |
||||
// Show all content events when showAll is true
|
||||
result.push(event); |
||||
// Still update the count for UI display
|
||||
const currentCount = kindCounts.get(kind) || 0; |
||||
kindCounts.set(kind, currentCount + 1); |
||||
} else if (limit !== undefined) { |
||||
// Normal limit checking
|
||||
const currentCount = kindCounts.get(kind) || 0; |
||||
if (currentCount < limit) { |
||||
result.push(event); |
||||
kindCounts.set(kind, currentCount + 1); |
||||
} |
||||
} else { |
||||
// No limit configured, add the event
|
||||
result.push(event); |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Detects events that are referenced but not present in the current set |
||||
* @param events - Current events |
||||
* @param existingIds - Set of all known event IDs (hex format) |
||||
* @param existingCoordinates - Optional map of existing coordinates for NIP-33 detection |
||||
* @returns Set of missing event identifiers |
||||
*/ |
||||
export function detectMissingEvents( |
||||
events: NDKEvent[],
|
||||
existingIds: Set<NostrEventId>, |
||||
existingCoordinates?: Map<string, NDKEvent> |
||||
): Set<string> { |
||||
const missing = new Set<string>(); |
||||
|
||||
for (const event of events) { |
||||
// Check 'e' tags for direct event references (hex IDs)
|
||||
const eTags = event.getMatchingTags('e'); |
||||
for (const eTag of eTags) { |
||||
if (eTag.length < 2) continue; |
||||
|
||||
const eventId = eTag[1]; |
||||
|
||||
// Type check: ensure it's a valid hex event ID
|
||||
if (!isEventId(eventId)) { |
||||
console.warn('Invalid event ID in e tag:', eventId); |
||||
continue; |
||||
} |
||||
|
||||
if (!existingIds.has(eventId)) { |
||||
missing.add(eventId); |
||||
} |
||||
} |
||||
|
||||
// Check 'a' tags for NIP-33 references (kind:pubkey:d-tag)
|
||||
const aTags = event.getMatchingTags('a'); |
||||
for (const aTag of aTags) { |
||||
if (aTag.length < 2) continue; |
||||
|
||||
const identifier = aTag[1]; |
||||
|
||||
// Type check: ensure it's a valid coordinate
|
||||
if (!isCoordinate(identifier)) { |
||||
console.warn('Invalid coordinate in a tag:', identifier); |
||||
continue; |
||||
} |
||||
|
||||
// Parse the coordinate
|
||||
const parsed = parseCoordinate(identifier); |
||||
if (!parsed) continue; |
||||
|
||||
// If we have existing coordinates, check if this one exists
|
||||
if (existingCoordinates) { |
||||
if (!existingCoordinates.has(identifier)) { |
||||
missing.add(identifier); |
||||
} |
||||
} else { |
||||
// Without coordinate map, we can't detect missing NIP-33 events
|
||||
// This is a limitation when we only have hex IDs
|
||||
console.debug('Cannot detect missing NIP-33 events without coordinate map:', identifier); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return missing; |
||||
} |
||||
|
||||
/** |
||||
* Builds a map of coordinates to events for NIP-33 detection |
||||
* @param events - Array of events to build coordinate map from |
||||
* @returns Map of coordinate strings to events |
||||
*/ |
||||
export function buildCoordinateMap(events: NDKEvent[]): Map<string, NDKEvent> { |
||||
const coordinateMap = new Map<string, NDKEvent>(); |
||||
|
||||
for (const event of events) { |
||||
// Only process replaceable events (kinds 30000-39999)
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) { |
||||
const dTag = event.tagValue('d'); |
||||
const author = event.pubkey; |
||||
|
||||
if (dTag && author) { |
||||
const coordinate = `${event.kind}:${author}:${dTag}`; |
||||
coordinateMap.set(coordinate, event); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return coordinateMap; |
||||
} |
||||
|
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
/** |
||||
* Deterministic color mapping for event kinds |
||||
* Uses golden ratio to distribute colors evenly across the spectrum |
||||
*/ |
||||
|
||||
const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; |
||||
|
||||
/** |
||||
* Get a deterministic color for an event kind |
||||
* @param kind - The event kind number |
||||
* @returns HSL color string |
||||
*/ |
||||
export function getEventKindColor(kind: number): string { |
||||
// Use golden ratio for better distribution
|
||||
const hue = (kind * GOLDEN_RATIO * 360) % 360; |
||||
|
||||
// Use different saturation/lightness for better visibility
|
||||
const saturation = 65 + (kind % 20); // 65-85%
|
||||
const lightness = 55 + ((kind * 3) % 15); // 55-70%
|
||||
|
||||
return `hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`; |
||||
} |
||||
|
||||
/** |
||||
* Get a friendly name for an event kind |
||||
* @param kind - The event kind number |
||||
* @returns Human-readable name |
||||
*/ |
||||
export function getEventKindName(kind: number): string { |
||||
const kindNames: Record<number, string> = { |
||||
0: 'Metadata', |
||||
1: 'Text Note', |
||||
2: 'Recommend Relay', |
||||
3: 'Contact List', |
||||
4: 'Encrypted DM', |
||||
5: 'Event Deletion', |
||||
6: 'Repost', |
||||
7: 'Reaction', |
||||
8: 'Badge Award', |
||||
16: 'Generic Repost', |
||||
40: 'Channel Creation', |
||||
41: 'Channel Metadata', |
||||
42: 'Channel Message', |
||||
43: 'Channel Hide Message', |
||||
44: 'Channel Mute User', |
||||
1984: 'Reporting', |
||||
9734: 'Zap Request', |
||||
9735: 'Zap', |
||||
10000: 'Mute List', |
||||
10001: 'Pin List', |
||||
10002: 'Relay List', |
||||
22242: 'Client Authentication', |
||||
24133: 'Nostr Connect', |
||||
27235: 'HTTP Auth', |
||||
30000: 'Categorized People List', |
||||
30001: 'Categorized Bookmark List', |
||||
30008: 'Profile Badges', |
||||
30009: 'Badge Definition', |
||||
30017: 'Create or update a stall', |
||||
30018: 'Create or update a product', |
||||
30023: 'Long-form Content', |
||||
30024: 'Draft Long-form Content', |
||||
30040: 'Publication Index', |
||||
30041: 'Publication Content', |
||||
30078: 'Application-specific Data', |
||||
30311: 'Live Event', |
||||
30402: 'Classified Listing', |
||||
30403: 'Draft Classified Listing', |
||||
30617: 'Repository', |
||||
30818: 'Wiki Page', |
||||
31922: 'Date-Based Calendar Event', |
||||
31923: 'Time-Based Calendar Event', |
||||
31924: 'Calendar', |
||||
31925: 'Calendar Event RSVP', |
||||
31989: 'Handler recommendation', |
||||
31990: 'Handler information', |
||||
34550: 'Community Definition', |
||||
}; |
||||
|
||||
return kindNames[kind] || `Kind ${kind}`; |
||||
} |
||||
|
||||
@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
|
||||
/** |
||||
* Deduplicate content events by keeping only the most recent version |
||||
* @param contentEventSets Array of event sets from different sources |
||||
* @returns Map of coordinate to most recent event |
||||
*/ |
||||
export function deduplicateContentEvents(contentEventSets: Set<NDKEvent>[]): Map<string, NDKEvent> { |
||||
const eventsByCoordinate = new Map<string, NDKEvent>(); |
||||
|
||||
// Track statistics for debugging
|
||||
let totalEvents = 0; |
||||
let duplicateCoordinates = 0; |
||||
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; |
||||
|
||||
contentEventSets.forEach((eventSet) => { |
||||
eventSet.forEach(event => { |
||||
totalEvents++; |
||||
const dTag = event.tagValue("d"); |
||||
const author = event.pubkey; |
||||
const kind = event.kind; |
||||
|
||||
if (dTag && author && kind) { |
||||
const coordinate = `${kind}:${author}:${dTag}`; |
||||
const existing = eventsByCoordinate.get(coordinate); |
||||
|
||||
if (existing) { |
||||
// We found a duplicate coordinate
|
||||
duplicateCoordinates++; |
||||
|
||||
// Track details for the first few duplicates
|
||||
if (duplicateDetails.length < 5) { |
||||
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); |
||||
if (existingDetails) { |
||||
existingDetails.count++; |
||||
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); |
||||
} else { |
||||
duplicateDetails.push({ |
||||
coordinate, |
||||
count: 2, // existing + current
|
||||
events: [ |
||||
`${existing.id} (created_at: ${existing.created_at})`, |
||||
`${event.id} (created_at: ${event.created_at})` |
||||
] |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Keep the most recent event (highest created_at)
|
||||
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { |
||||
eventsByCoordinate.set(coordinate, event); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Log deduplication results if any duplicates were found
|
||||
if (duplicateCoordinates > 0) { |
||||
console.log(`[eventDeduplication] Found ${duplicateCoordinates} duplicate events out of ${totalEvents} total events`); |
||||
console.log(`[eventDeduplication] Reduced to ${eventsByCoordinate.size} unique coordinates`); |
||||
console.log(`[eventDeduplication] Duplicate details:`, duplicateDetails); |
||||
} else if (totalEvents > 0) { |
||||
console.log(`[eventDeduplication] No duplicates found in ${totalEvents} events`); |
||||
} |
||||
|
||||
return eventsByCoordinate; |
||||
} |
||||
|
||||
/** |
||||
* Deduplicate and combine all events, keeping only the most recent version of replaceable events |
||||
* @param nonPublicationEvents Array of non-publication events |
||||
* @param validIndexEvents Set of valid index events |
||||
* @param contentEvents Set of content events |
||||
* @returns Array of deduplicated events |
||||
*/ |
||||
export function deduplicateAndCombineEvents( |
||||
nonPublicationEvents: NDKEvent[], |
||||
validIndexEvents: Set<NDKEvent>, |
||||
contentEvents: Set<NDKEvent> |
||||
): NDKEvent[] { |
||||
// Track statistics for debugging
|
||||
const initialCount = nonPublicationEvents.length + validIndexEvents.size + contentEvents.size; |
||||
let replaceableEventsProcessed = 0; |
||||
let duplicateCoordinatesFound = 0; |
||||
const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; |
||||
|
||||
// First, build coordinate map for replaceable events
|
||||
const coordinateMap = new Map<string, NDKEvent>(); |
||||
const allEventsToProcess = [ |
||||
...nonPublicationEvents, // Non-publication events fetched earlier
|
||||
...Array.from(validIndexEvents),
|
||||
...Array.from(contentEvents) |
||||
]; |
||||
|
||||
// First pass: identify the most recent version of each replaceable event
|
||||
allEventsToProcess.forEach(event => { |
||||
if (!event.id) return; |
||||
|
||||
// For replaceable events (30000-39999), track by coordinate
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) { |
||||
replaceableEventsProcessed++; |
||||
const dTag = event.tagValue("d"); |
||||
const author = event.pubkey; |
||||
|
||||
if (dTag && author) { |
||||
const coordinate = `${event.kind}:${author}:${dTag}`; |
||||
const existing = coordinateMap.get(coordinate); |
||||
|
||||
if (existing) { |
||||
// We found a duplicate coordinate
|
||||
duplicateCoordinatesFound++; |
||||
|
||||
// Track details for the first few duplicates
|
||||
if (duplicateDetails.length < 5) { |
||||
const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); |
||||
if (existingDetails) { |
||||
existingDetails.count++; |
||||
existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); |
||||
} else { |
||||
duplicateDetails.push({ |
||||
coordinate, |
||||
count: 2, // existing + current
|
||||
events: [ |
||||
`${existing.id} (created_at: ${existing.created_at})`, |
||||
`${event.id} (created_at: ${event.created_at})` |
||||
] |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Keep the most recent version
|
||||
if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { |
||||
coordinateMap.set(coordinate, event); |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
|
||||
// Second pass: build final event map
|
||||
const finalEventMap = new Map<string, NDKEvent>(); |
||||
const seenCoordinates = new Set<string>(); |
||||
|
||||
allEventsToProcess.forEach(event => { |
||||
if (!event.id) return; |
||||
|
||||
// For replaceable events, only add if it's the chosen version
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) { |
||||
const dTag = event.tagValue("d"); |
||||
const author = event.pubkey; |
||||
|
||||
if (dTag && author) { |
||||
const coordinate = `${event.kind}:${author}:${dTag}`; |
||||
const chosenEvent = coordinateMap.get(coordinate); |
||||
|
||||
// Only add this event if it's the chosen one for this coordinate
|
||||
if (chosenEvent && chosenEvent.id === event.id) { |
||||
if (!seenCoordinates.has(coordinate)) { |
||||
finalEventMap.set(event.id, event); |
||||
seenCoordinates.add(coordinate); |
||||
} |
||||
} |
||||
return; |
||||
} |
||||
} |
||||
|
||||
// Non-replaceable events are added directly
|
||||
finalEventMap.set(event.id, event); |
||||
}); |
||||
|
||||
const finalCount = finalEventMap.size; |
||||
const reduction = initialCount - finalCount; |
||||
|
||||
// Log deduplication results if any duplicates were found
|
||||
if (duplicateCoordinatesFound > 0) { |
||||
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Found ${duplicateCoordinatesFound} duplicate coordinates out of ${replaceableEventsProcessed} replaceable events`); |
||||
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Reduced from ${initialCount} to ${finalCount} events (${reduction} removed)`); |
||||
console.log(`[eventDeduplication] deduplicateAndCombineEvents: Duplicate details:`, duplicateDetails); |
||||
} else if (replaceableEventsProcessed > 0) { |
||||
console.log(`[eventDeduplication] deduplicateAndCombineEvents: No duplicates found in ${replaceableEventsProcessed} replaceable events`); |
||||
} |
||||
|
||||
return Array.from(finalEventMap.values()); |
||||
} |
||||
|
||||
/** |
||||
* Check if an event is a replaceable event (kinds 30000-39999) |
||||
* @param event The event to check |
||||
* @returns True if the event is replaceable |
||||
*/ |
||||
export function isReplaceableEvent(event: NDKEvent): boolean { |
||||
return event.kind !== undefined && event.kind >= 30000 && event.kind < 40000; |
||||
} |
||||
|
||||
/** |
||||
* Get the coordinate for a replaceable event |
||||
* @param event The event to get the coordinate for |
||||
* @returns The coordinate string (kind:pubkey:d-tag) or null if not a valid replaceable event |
||||
*/ |
||||
export function getEventCoordinate(event: NDKEvent): string | null { |
||||
if (!isReplaceableEvent(event)) { |
||||
return null; |
||||
} |
||||
|
||||
const dTag = event.tagValue("d"); |
||||
const author = event.pubkey; |
||||
|
||||
if (!dTag || !author) { |
||||
return null; |
||||
} |
||||
|
||||
return `${event.kind}:${author}:${dTag}`; |
||||
}
|
||||
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
import type { EventKindConfig } from '$lib/stores/visualizationConfig'; |
||||
|
||||
/** |
||||
* Validates an event kind input value. |
||||
* @param value - The input value to validate (string or number). |
||||
* @param existingKinds - Array of existing event kind numbers to check for duplicates. |
||||
* @returns The validated kind number, or null if validation fails. |
||||
*/ |
||||
export function validateEventKind( |
||||
value: string | number,
|
||||
existingKinds: number[] |
||||
): { kind: number | null; error: string } { |
||||
// Convert to string for consistent handling
|
||||
const strValue = String(value); |
||||
if (strValue === null || strValue === undefined || strValue.trim() === '') { |
||||
return { kind: null, error: '' }; |
||||
} |
||||
|
||||
const kind = parseInt(strValue.trim()); |
||||
if (isNaN(kind)) { |
||||
return { kind: null, error: 'Must be a number' }; |
||||
} |
||||
|
||||
if (kind < 0) { |
||||
return { kind: null, error: 'Must be non-negative' }; |
||||
} |
||||
|
||||
if (existingKinds.includes(kind)) { |
||||
return { kind: null, error: 'Already added' }; |
||||
} |
||||
|
||||
return { kind, error: '' }; |
||||
} |
||||
|
||||
/** |
||||
* Handles adding a new event kind with validation and state management. |
||||
* @param newKind - The new kind value to add. |
||||
* @param existingKinds - Array of existing event kind numbers. |
||||
* @param addKindFunction - Function to call when adding the kind. |
||||
* @param resetStateFunction - Function to call to reset the input state. |
||||
* @returns Object with success status and any error message. |
||||
*/ |
||||
export function handleAddEventKind( |
||||
newKind: string, |
||||
existingKinds: number[], |
||||
addKindFunction: (kind: number) => void, |
||||
resetStateFunction: () => void |
||||
): { success: boolean; error: string } { |
||||
console.log('[handleAddEventKind] called with:', newKind); |
||||
|
||||
const validation = validateEventKind(newKind, existingKinds); |
||||
console.log('[handleAddEventKind] Validation result:', validation); |
||||
|
||||
if (validation.kind !== null) { |
||||
console.log('[handleAddEventKind] Adding event kind:', validation.kind); |
||||
addKindFunction(validation.kind); |
||||
resetStateFunction(); |
||||
return { success: true, error: '' }; |
||||
} else { |
||||
console.log('[handleAddEventKind] Validation failed:', validation.error); |
||||
return { success: false, error: validation.error }; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Handles keyboard events for event kind input. |
||||
* @param e - The keyboard event. |
||||
* @param onEnter - Function to call when Enter is pressed. |
||||
* @param onEscape - Function to call when Escape is pressed. |
||||
*/ |
||||
export function handleEventKindKeydown( |
||||
e: KeyboardEvent, |
||||
onEnter: () => void, |
||||
onEscape: () => void |
||||
): void { |
||||
if (e.key === 'Enter') { |
||||
onEnter(); |
||||
} else if (e.key === 'Escape') { |
||||
onEscape(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gets the display name for an event kind. |
||||
* @param kind - The event kind number. |
||||
* @returns The display name for the kind. |
||||
*/ |
||||
export function getEventKindDisplayName(kind: number): string { |
||||
switch (kind) { |
||||
case 30040: return 'Publication Index'; |
||||
case 30041: return 'Publication Content'; |
||||
case 30818: return 'Wiki'; |
||||
case 1: return 'Text Note'; |
||||
case 0: return 'Metadata'; |
||||
case 3: return 'Follow List'; |
||||
default: return `Kind ${kind}`; |
||||
} |
||||
}
|
||||
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
import { VALIDATION } from './search_constants'; |
||||
import type { NostrEventId } from './nostr_identifiers'; |
||||
|
||||
/** |
||||
* Nostr identifier types |
||||
*/ |
||||
export type NostrCoordinate = string; // kind:pubkey:d-tag format
|
||||
export type NostrIdentifier = NostrEventId | NostrCoordinate; |
||||
|
||||
/** |
||||
* Interface for parsed Nostr coordinate |
||||
*/ |
||||
export interface ParsedCoordinate { |
||||
kind: number; |
||||
pubkey: string; |
||||
dTag: string; |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is a valid hex event ID |
||||
* @param id The string to check |
||||
* @returns True if it's a valid hex event ID |
||||
*/ |
||||
export function isEventId(id: string): id is NostrEventId { |
||||
return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id); |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is a valid Nostr coordinate (kind:pubkey:d-tag) |
||||
* @param coordinate The string to check |
||||
* @returns True if it's a valid coordinate |
||||
*/ |
||||
export function isCoordinate(coordinate: string): coordinate is NostrCoordinate { |
||||
const parts = coordinate.split(':'); |
||||
if (parts.length < 3) return false; |
||||
|
||||
const [kindStr, pubkey, ...dTagParts] = parts; |
||||
|
||||
// Check if kind is a valid number
|
||||
const kind = parseInt(kindStr, 10); |
||||
if (isNaN(kind) || kind < 0) return false; |
||||
|
||||
// Check if pubkey is a valid hex string
|
||||
if (!isEventId(pubkey)) return false; |
||||
|
||||
// Check if d-tag exists (can contain colons)
|
||||
if (dTagParts.length === 0) return false; |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Parse a Nostr coordinate into its components |
||||
* @param coordinate The coordinate string to parse |
||||
* @returns Parsed coordinate or null if invalid |
||||
*/ |
||||
export function parseCoordinate(coordinate: string): ParsedCoordinate | null { |
||||
if (!isCoordinate(coordinate)) return null; |
||||
|
||||
const parts = coordinate.split(':'); |
||||
const [kindStr, pubkey, ...dTagParts] = parts; |
||||
|
||||
return { |
||||
kind: parseInt(kindStr, 10), |
||||
pubkey, |
||||
dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons
|
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Create a coordinate string from components |
||||
* @param kind The event kind |
||||
* @param pubkey The author's public key |
||||
* @param dTag The d-tag value |
||||
* @returns The coordinate string |
||||
*/ |
||||
export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate { |
||||
return `${kind}:${pubkey}:${dTag}`; |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is any valid Nostr identifier |
||||
* @param identifier The string to check |
||||
* @returns True if it's a valid Nostr identifier |
||||
*/ |
||||
export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier { |
||||
return isEventId(identifier) || isCoordinate(identifier); |
||||
}
|
||||
@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { ndkInstance } from "$lib/ndk"; |
||||
import { get } from "svelte/store"; |
||||
import { nip19 } from "nostr-tools"; |
||||
|
||||
interface ProfileData { |
||||
display_name?: string; |
||||
name?: string; |
||||
picture?: string; |
||||
about?: string; |
||||
} |
||||
|
||||
// Cache for user profiles
|
||||
const profileCache = new Map<string, ProfileData>(); |
||||
|
||||
/** |
||||
* Fetches profile data for a pubkey |
||||
* @param pubkey - The public key to fetch profile for |
||||
* @returns Profile data or null if not found |
||||
*/ |
||||
async function fetchProfile(pubkey: string): Promise<ProfileData | null> { |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
const profileEvents = await ndk.fetchEvents({ |
||||
kinds: [0], |
||||
authors: [pubkey], |
||||
limit: 1 |
||||
}); |
||||
|
||||
if (profileEvents.size === 0) { |
||||
return null; |
||||
} |
||||
|
||||
// Get the most recent profile event
|
||||
const profileEvent = Array.from(profileEvents)[0]; |
||||
|
||||
try { |
||||
const content = JSON.parse(profileEvent.content); |
||||
return content as ProfileData; |
||||
} catch (e) { |
||||
console.error("Failed to parse profile content:", e); |
||||
return null; |
||||
} |
||||
} catch (e) { |
||||
console.error("Failed to fetch profile:", e); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gets the display name for a pubkey, using cache |
||||
* @param pubkey - The public key to get display name for |
||||
* @returns Display name, name, or shortened pubkey |
||||
*/ |
||||
export async function getDisplayName(pubkey: string): Promise<string> { |
||||
// Check cache first
|
||||
if (profileCache.has(pubkey)) { |
||||
const profile = profileCache.get(pubkey)!; |
||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
||||
} |
||||
|
||||
// Fetch profile
|
||||
const profile = await fetchProfile(pubkey); |
||||
if (profile) { |
||||
profileCache.set(pubkey, profile); |
||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
||||
} |
||||
|
||||
// Fallback to shortened pubkey
|
||||
return shortenPubkey(pubkey); |
||||
} |
||||
|
||||
/** |
||||
* Batch fetches profiles for multiple pubkeys |
||||
* @param pubkeys - Array of public keys to fetch profiles for |
||||
* @param onProgress - Optional callback for progress updates |
||||
* @returns Array of profile events |
||||
*/ |
||||
export async function batchFetchProfiles( |
||||
pubkeys: string[],
|
||||
onProgress?: (fetched: number, total: number) => void |
||||
): Promise<NDKEvent[]> { |
||||
const allProfileEvents: NDKEvent[] = []; |
||||
|
||||
// Filter out already cached pubkeys
|
||||
const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk)); |
||||
|
||||
if (uncachedPubkeys.length === 0) { |
||||
if (onProgress) onProgress(pubkeys.length, pubkeys.length); |
||||
return allProfileEvents; |
||||
} |
||||
|
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
|
||||
// Report initial progress
|
||||
const cachedCount = pubkeys.length - uncachedPubkeys.length; |
||||
if (onProgress) onProgress(cachedCount, pubkeys.length); |
||||
|
||||
// Batch fetch in chunks to avoid overwhelming relays
|
||||
const CHUNK_SIZE = 50; |
||||
let fetchedCount = cachedCount; |
||||
|
||||
for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) { |
||||
const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length)); |
||||
|
||||
const profileEvents = await ndk.fetchEvents({ |
||||
kinds: [0], |
||||
authors: chunk |
||||
}); |
||||
|
||||
// Process each profile event
|
||||
profileEvents.forEach((event: NDKEvent) => { |
||||
try { |
||||
const content = JSON.parse(event.content); |
||||
profileCache.set(event.pubkey, content as ProfileData); |
||||
allProfileEvents.push(event); |
||||
fetchedCount++; |
||||
} catch (e) { |
||||
console.error("Failed to parse profile content:", e); |
||||
} |
||||
}); |
||||
|
||||
// Update progress
|
||||
if (onProgress) { |
||||
onProgress(fetchedCount, pubkeys.length); |
||||
} |
||||
} |
||||
|
||||
// Final progress update
|
||||
if (onProgress) onProgress(pubkeys.length, pubkeys.length); |
||||
} catch (e) { |
||||
console.error("Failed to batch fetch profiles:", e); |
||||
} |
||||
|
||||
return allProfileEvents; |
||||
} |
||||
|
||||
/** |
||||
* Gets display name synchronously from cache |
||||
* @param pubkey - The public key to get display name for |
||||
* @returns Display name, name, or shortened pubkey |
||||
*/ |
||||
export function getDisplayNameSync(pubkey: string): string { |
||||
if (profileCache.has(pubkey)) { |
||||
const profile = profileCache.get(pubkey)!; |
||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
||||
} |
||||
return shortenPubkey(pubkey); |
||||
} |
||||
|
||||
/** |
||||
* Shortens a pubkey for display |
||||
* @param pubkey - The public key to shorten |
||||
* @returns Shortened pubkey (first 8 chars...last 4 chars) |
||||
*/ |
||||
function shortenPubkey(pubkey: string): string { |
||||
if (pubkey.length <= 12) return pubkey; |
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
||||
} |
||||
|
||||
/** |
||||
* Clears the profile cache |
||||
*/ |
||||
export function clearProfileCache(): void { |
||||
profileCache.clear(); |
||||
} |
||||
|
||||
/** |
||||
* Extracts all pubkeys from events (authors and p tags) |
||||
* @param events - Array of events to extract pubkeys from |
||||
* @returns Set of unique pubkeys |
||||
*/ |
||||
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> { |
||||
const pubkeys = new Set<string>(); |
||||
|
||||
events.forEach(event => { |
||||
// Add author pubkey
|
||||
if (event.pubkey) { |
||||
pubkeys.add(event.pubkey); |
||||
} |
||||
|
||||
// Add pubkeys from p tags
|
||||
const pTags = event.getMatchingTags("p"); |
||||
pTags.forEach(tag => { |
||||
if (tag[1]) { |
||||
pubkeys.add(tag[1]); |
||||
} |
||||
}); |
||||
|
||||
// Extract pubkeys from content (nostr:npub1... format)
|
||||
const npubPattern = /nostr:npub1[a-z0-9]{58}/g; |
||||
const matches = event.content?.match(npubPattern) || []; |
||||
matches.forEach(match => { |
||||
try { |
||||
const npub = match.replace('nostr:', ''); |
||||
const decoded = nip19.decode(npub); |
||||
if (decoded.type === 'npub') { |
||||
pubkeys.add(decoded.data as string); |
||||
} |
||||
} catch (e) { |
||||
// Invalid npub, ignore
|
||||
} |
||||
}); |
||||
}); |
||||
|
||||
return pubkeys; |
||||
} |
||||
|
||||
/** |
||||
* Replaces pubkeys in content with display names |
||||
* @param content - The content to process |
||||
* @returns Content with pubkeys replaced by display names |
||||
*/ |
||||
export function replaceContentPubkeys(content: string): string { |
||||
if (!content) return content; |
||||
|
||||
// Replace nostr:npub1... references
|
||||
const npubPattern = /nostr:npub[a-z0-9]{58}/g; |
||||
let result = content; |
||||
|
||||
const matches = content.match(npubPattern) || []; |
||||
matches.forEach(match => { |
||||
try { |
||||
const npub = match.replace('nostr:', ''); |
||||
const decoded = nip19.decode(npub); |
||||
if (decoded.type === 'npub') { |
||||
const pubkey = decoded.data as string; |
||||
const displayName = getDisplayNameSync(pubkey); |
||||
result = result.replace(match, `@${displayName}`); |
||||
} |
||||
} catch (e) { |
||||
// Invalid npub, leave as is
|
||||
} |
||||
}); |
||||
|
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Replaces pubkey references in text with display names |
||||
* @param text - Text that may contain pubkey references |
||||
* @returns Text with pubkeys replaced by display names |
||||
*/ |
||||
export function replacePubkeysWithDisplayNames(text: string): string { |
||||
// Match hex pubkeys (64 characters)
|
||||
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g; |
||||
|
||||
return text.replace(pubkeyRegex, (match) => { |
||||
return getDisplayNameSync(match); |
||||
}); |
||||
} |
||||
@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { ndkInstance } from "../ndk"; |
||||
import { get } from "svelte/store"; |
||||
import { extractPubkeysFromEvents, batchFetchProfiles } from "./profileCache"; |
||||
|
||||
// Constants for publication event kinds
|
||||
const INDEX_EVENT_KIND = 30040; |
||||
const CONTENT_EVENT_KINDS = [30041, 30818]; |
||||
|
||||
/** |
||||
* Interface for tag expansion fetch results |
||||
*/ |
||||
export interface TagExpansionResult { |
||||
publications: NDKEvent[]; |
||||
contentEvents: NDKEvent[]; |
||||
} |
||||
|
||||
/** |
||||
* Fetches publications and their content events from relays based on tags |
||||
*
|
||||
* This function handles the relay-based fetching portion of tag expansion: |
||||
* 1. Fetches publication index events that have any of the specified tags |
||||
* 2. Extracts content event references from those publications |
||||
* 3. Fetches the referenced content events |
||||
*
|
||||
* @param tags Array of tags to search for in publications |
||||
* @param existingEventIds Set of existing event IDs to avoid duplicates |
||||
* @param baseEvents Array of base events to check for existing content |
||||
* @param debug Optional debug function for logging |
||||
* @returns Promise resolving to publications and content events |
||||
*/ |
||||
export async function fetchTaggedEventsFromRelays( |
||||
tags: string[], |
||||
existingEventIds: Set<string>, |
||||
baseEvents: NDKEvent[], |
||||
debug?: (...args: any[]) => void |
||||
): Promise<TagExpansionResult> { |
||||
const log = debug || console.debug; |
||||
|
||||
log("Fetching from relays for tags:", tags); |
||||
|
||||
// Fetch publications that have any of the specified tags
|
||||
const ndk = get(ndkInstance); |
||||
const taggedPublications = await ndk.fetchEvents({ |
||||
kinds: [INDEX_EVENT_KIND], |
||||
"#t": tags, // Match any of these tags
|
||||
limit: 30 // Reasonable default limit
|
||||
}); |
||||
|
||||
log("Found tagged publications from relays:", taggedPublications.size); |
||||
|
||||
// Filter to avoid duplicates
|
||||
const newPublications = Array.from(taggedPublications).filter( |
||||
(event: NDKEvent) => !existingEventIds.has(event.id) |
||||
); |
||||
|
||||
// Extract content event d-tags from new publications
|
||||
const contentEventDTags = new Set<string>(); |
||||
const existingContentDTags = new Set( |
||||
baseEvents |
||||
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) |
||||
.map(e => e.tagValue("d")) |
||||
.filter(d => d !== undefined) |
||||
); |
||||
|
||||
newPublications.forEach((event: NDKEvent) => { |
||||
const aTags = event.getMatchingTags("a"); |
||||
aTags.forEach((tag: string[]) => { |
||||
// Parse the 'a' tag identifier: kind:pubkey:d-tag
|
||||
if (tag[1]) { |
||||
const parts = tag[1].split(':'); |
||||
if (parts.length >= 3) { |
||||
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons
|
||||
if (!existingContentDTags.has(dTag)) { |
||||
contentEventDTags.add(dTag); |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Fetch the content events
|
||||
let newContentEvents: NDKEvent[] = []; |
||||
if (contentEventDTags.size > 0) { |
||||
const contentEventsSet = await ndk.fetchEvents({ |
||||
kinds: CONTENT_EVENT_KINDS, |
||||
"#d": Array.from(contentEventDTags), // Use d-tag filter
|
||||
}); |
||||
newContentEvents = Array.from(contentEventsSet); |
||||
} |
||||
|
||||
return { |
||||
publications: newPublications, |
||||
contentEvents: newContentEvents |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Searches through already fetched events for publications with specified tags |
||||
*
|
||||
* This function handles the local search portion of tag expansion: |
||||
* 1. Searches through existing events for publications with matching tags |
||||
* 2. Extracts content event references from those publications |
||||
* 3. Finds the referenced content events in existing events |
||||
*
|
||||
* @param allEvents Array of all fetched events to search through |
||||
* @param tags Array of tags to search for in publications |
||||
* @param existingEventIds Set of existing event IDs to avoid duplicates |
||||
* @param baseEvents Array of base events to check for existing content |
||||
* @param debug Optional debug function for logging |
||||
* @returns Promise resolving to publications and content events |
||||
*/ |
||||
export function findTaggedEventsInFetched( |
||||
allEvents: NDKEvent[], |
||||
tags: string[], |
||||
existingEventIds: Set<string>, |
||||
baseEvents: NDKEvent[], |
||||
debug?: (...args: any[]) => void |
||||
): TagExpansionResult { |
||||
const log = debug || console.debug; |
||||
|
||||
log("Searching through already fetched events for tags:", tags); |
||||
|
||||
// Find publications in allEvents that have the specified tags
|
||||
const taggedPublications = allEvents.filter(event => { |
||||
if (event.kind !== INDEX_EVENT_KIND) return false; |
||||
if (existingEventIds.has(event.id)) return false; // Skip base events
|
||||
|
||||
// Check if event has any of the specified tags
|
||||
const eventTags = event.getMatchingTags("t").map(tag => tag[1]); |
||||
return tags.some(tag => eventTags.includes(tag)); |
||||
}); |
||||
|
||||
const newPublications = taggedPublications; |
||||
log("Found", newPublications.length, "publications in fetched events"); |
||||
|
||||
// For content events, also search in allEvents
|
||||
const existingContentDTags = new Set( |
||||
baseEvents |
||||
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) |
||||
.map(e => e.tagValue("d")) |
||||
.filter(d => d !== undefined) |
||||
); |
||||
|
||||
const contentEventDTags = new Set<string>(); |
||||
newPublications.forEach((event: NDKEvent) => { |
||||
const aTags = event.getMatchingTags("a"); |
||||
aTags.forEach((tag: string[]) => { |
||||
// Parse the 'a' tag identifier: kind:pubkey:d-tag
|
||||
if (tag[1]) { |
||||
const parts = tag[1].split(':'); |
||||
if (parts.length >= 3) { |
||||
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons
|
||||
if (!existingContentDTags.has(dTag)) { |
||||
contentEventDTags.add(dTag); |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Find content events in allEvents
|
||||
const newContentEvents = allEvents.filter(event => { |
||||
if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false; |
||||
const dTag = event.tagValue("d"); |
||||
return dTag !== undefined && contentEventDTags.has(dTag); |
||||
}); |
||||
|
||||
return { |
||||
publications: newPublications, |
||||
contentEvents: newContentEvents |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Fetches profiles for new events and updates progress |
||||
*
|
||||
* @param newPublications Array of new publication events |
||||
* @param newContentEvents Array of new content events |
||||
* @param onProgressUpdate Callback to update progress state |
||||
* @param debug Optional debug function for logging |
||||
* @returns Promise that resolves when profile fetching is complete |
||||
*/ |
||||
export async function fetchProfilesForNewEvents( |
||||
newPublications: NDKEvent[], |
||||
newContentEvents: NDKEvent[], |
||||
onProgressUpdate: (progress: { current: number; total: number } | null) => void, |
||||
debug?: (...args: any[]) => void |
||||
): Promise<void> { |
||||
const log = debug || console.debug; |
||||
|
||||
// Extract pubkeys from new events
|
||||
const newPubkeys = extractPubkeysFromEvents([...newPublications, ...newContentEvents]); |
||||
|
||||
if (newPubkeys.size > 0) { |
||||
log("Fetching profiles for", newPubkeys.size, "new pubkeys from tag expansion"); |
||||
|
||||
onProgressUpdate({ current: 0, total: newPubkeys.size }); |
||||
|
||||
await batchFetchProfiles(Array.from(newPubkeys), (fetched, total) => { |
||||
onProgressUpdate({ current: fetched, total }); |
||||
}); |
||||
|
||||
onProgressUpdate(null); |
||||
} |
||||
}
|
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import type { PageLoad } from './$types'; |
||||
|
||||
export const load: PageLoad = async ({ url }) => { |
||||
const eventId = url.searchParams.get('event'); |
||||
|
||||
return { |
||||
eventId |
||||
}; |
||||
}; |
||||
@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
|
||||
import { test, expect } from "@playwright/test"; |
||||
|
||||
test("has title", async ({ page }) => { |
||||
await page.goto("https://playwright.dev/"); |
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/); |
||||
}); |
||||
|
||||
test("get started link", async ({ page }) => { |
||||
await page.goto("https://playwright.dev/"); |
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole("link", { name: "Get started" }).click(); |
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect( |
||||
page.getByRole("heading", { name: "Installation" }), |
||||
).toBeVisible(); |
||||
}); |
||||
@ -1,115 +0,0 @@
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect } from "vitest"; |
||||
import { parseBasicmarkup } from "../../src/lib/utils/markup/basicMarkupParser"; |
||||
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser"; |
||||
import { readFileSync } from "fs"; |
||||
import { join } from "path"; |
||||
|
||||
const testFilePath = join(__dirname, "./markupTestfile.md"); |
||||
const md = readFileSync(testFilePath, "utf-8"); |
||||
|
||||
describe("Markup Integration Test", () => { |
||||
it("parses markupTestfile.md with the basic parser", async () => { |
||||
const output = await parseBasicmarkup(md); |
||||
// Headers (should be present as raw text, not HTML tags)
|
||||
expect(output).toContain("This is a test"); |
||||
expect(output).toContain("# This is a test"); |
||||
expect(output).toContain("### Disclaimer"); |
||||
// Unordered list
|
||||
expect(output).toContain("<ul"); |
||||
expect(output).toContain("but"); |
||||
// Ordered list
|
||||
expect(output).toContain("<ol"); |
||||
expect(output).toContain("first"); |
||||
// Nested lists
|
||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||
// Blockquotes
|
||||
expect(output).toContain("<blockquote"); |
||||
expect(output).toContain("This is important information"); |
||||
// Inline code
|
||||
expect(output).toContain( |
||||
'<div class="leather min-h-full w-full flex flex-col items-center">', |
||||
); |
||||
// Images
|
||||
expect(output).toMatch( |
||||
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/, |
||||
); |
||||
// Links
|
||||
expect(output).toMatch( |
||||
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/, |
||||
); |
||||
// Hashtags
|
||||
expect(output).toContain("text-primary-600"); |
||||
// Nostr identifiers (should be Alexandria links)
|
||||
expect(output).toContain( |
||||
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z", |
||||
); |
||||
// Wikilinks
|
||||
expect(output).toContain("wikilink"); |
||||
// YouTube iframe
|
||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||
expect(output).not.toMatch(/utm_/); |
||||
expect(output).not.toMatch(/fbclid/); |
||||
expect(output).not.toMatch(/gclid/); |
||||
// Horizontal rule (should be present as --- in basic)
|
||||
expect(output).toContain("---"); |
||||
// Footnote references (should be present as [^1] in basic)
|
||||
expect(output).toContain("[^1]"); |
||||
// Table (should be present as | Syntax | Description | in basic)
|
||||
expect(output).toContain("| Syntax | Description |"); |
||||
}); |
||||
|
||||
it("parses markupTestfile.md with the advanced parser", async () => { |
||||
const output = await parseAdvancedmarkup(md); |
||||
// Headers
|
||||
expect(output).toContain("<h1"); |
||||
expect(output).toContain("<h2"); |
||||
expect(output).toContain("Disclaimer"); |
||||
// Unordered list
|
||||
expect(output).toContain("<ul"); |
||||
expect(output).toContain("but"); |
||||
// Ordered list
|
||||
expect(output).toContain("<ol"); |
||||
expect(output).toContain("first"); |
||||
// Nested lists
|
||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||
// Blockquotes
|
||||
expect(output).toContain("<blockquote"); |
||||
expect(output).toContain("This is important information"); |
||||
// Inline code
|
||||
expect(output).toMatch( |
||||
/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s, |
||||
); |
||||
// Images
|
||||
expect(output).toMatch( |
||||
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/, |
||||
); |
||||
// Links
|
||||
expect(output).toMatch( |
||||
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/, |
||||
); |
||||
// Hashtags
|
||||
expect(output).toContain("text-primary-600"); |
||||
// Nostr identifiers (should be Alexandria links)
|
||||
expect(output).toContain( |
||||
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z", |
||||
); |
||||
// Wikilinks
|
||||
expect(output).toContain("wikilink"); |
||||
// YouTube iframe
|
||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||
expect(output).not.toMatch(/utm_/); |
||||
expect(output).not.toMatch(/fbclid/); |
||||
expect(output).not.toMatch(/gclid/); |
||||
// Horizontal rule
|
||||
expect(output).toContain("<hr"); |
||||
// Footnote references and section
|
||||
expect(output).toContain("Footnotes"); |
||||
expect(output).toMatch(/<li id=\"fn-1\">/); |
||||
// Table
|
||||
expect(output).toContain("<table"); |
||||
// Code blocks
|
||||
expect(output).toContain("<pre"); |
||||
}); |
||||
}); |
||||
@ -1,267 +0,0 @@
@@ -1,267 +0,0 @@
|
||||
# This is a test |
||||
|
||||
### Disclaimer |
||||
|
||||
It is _only_ a test, for **sure**. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of _one asterisk_.[^1] |
||||
|
||||
# H1 |
||||
|
||||
## H2 |
||||
|
||||
### H3 |
||||
|
||||
#### H4 |
||||
|
||||
##### H5 |
||||
|
||||
###### H6 |
||||
|
||||
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser. |
||||
|
||||
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]] |
||||
|
||||
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. |
||||
|
||||
> This is important information |
||||
|
||||
> This is multiple |
||||
> lines of |
||||
> important information |
||||
> with a second[^2] footnote. |
||||
> [^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984. |
||||
|
||||
This is a youtube link |
||||
https://www.youtube.com/watch?v=9aqVxNCpx9s |
||||
|
||||
And here is a link with tracking tokens: |
||||
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU |
||||
|
||||
This is an unordered list: |
||||
|
||||
- but |
||||
- not |
||||
- really |
||||
|
||||
This is an unordered list with nesting: |
||||
|
||||
- but |
||||
- not |
||||
- really |
||||
- but |
||||
- yes, |
||||
- really |
||||
|
||||
## More testing |
||||
|
||||
An ordered list: |
||||
|
||||
1. first |
||||
2. second |
||||
3. third |
||||
|
||||
Let's nest that: |
||||
|
||||
1. first 2. second indented |
||||
2. third 4. fourth indented 5. fifth indented even more 6. sixth under the fourth 7. seventh under the sixth |
||||
3. eighth under the third |
||||
|
||||
This is ordered and unordered mixed: |
||||
|
||||
1. first 2. second indented |
||||
2. third |
||||
- make this a bullet point 4. fourth indented even more |
||||
- second bullet point |
||||
|
||||
Here is a horizontal rule: |
||||
|
||||
--- |
||||
|
||||
Try embedded a nostr note with nevent: |
||||
|
||||
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske |
||||
|
||||
Here a note with no prefix |
||||
|
||||
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz |
||||
|
||||
Here with a naddr: |
||||
|
||||
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz |
||||
|
||||
Here's a nonsense one: |
||||
|
||||
nevent123 |
||||
|
||||
And a nonsense one with a prefix: |
||||
|
||||
nostr:naddrwhatever |
||||
|
||||
And some Nostr addresses that should be preserved and have a internal link appended: |
||||
|
||||
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z |
||||
|
||||
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr |
||||
|
||||
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk |
||||
|
||||
URL with a tracking parameter, no markup: |
||||
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale |
||||
|
||||
Image without markup: |
||||
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg |
||||
|
||||
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. |
||||
|
||||
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses: |
||||
https://next-alexandria.gitcitadel.eu/events?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw |
||||
|
||||
But not if they have d-tags: |
||||
https://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1 |
||||
|
||||
And within a markup tag: [markup link title](https://next-alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c). |
||||
|
||||
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25 |
||||
|
||||
https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf |
||||
|
||||
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or |
||||
|
||||
``` |
||||
in a code block |
||||
``` |
||||
|
||||
You can even use a multi-line code block, with a json tag. |
||||
|
||||
````json |
||||
{ |
||||
"created_at": 1745038670, |
||||
"content": "# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n\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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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