Browse Source

Merge branch 'issue#173-events-and-lightning'

master
buttercat1791 10 months ago
parent
commit
98b70e854b
  1. 1
      .gitignore
  2. 16
      .vscode/settings.json
  3. 8
      README.md
  4. 2087
      package-lock.json
  5. 5
      package.json
  6. 1
      src/app.css
  7. 317
      src/lib/components/CommentBox.svelte
  8. 184
      src/lib/components/EventDetails.svelte
  9. 204
      src/lib/components/EventSearch.svelte
  10. 7
      src/lib/components/Login.svelte
  11. 5
      src/lib/components/Navigation.svelte
  12. 7
      src/lib/components/Preview.svelte
  13. 2
      src/lib/components/Publication.svelte
  14. 245
      src/lib/components/PublicationFeed.svelte
  15. 7
      src/lib/components/PublicationHeader.svelte
  16. 3
      src/lib/components/PublicationSection.svelte
  17. 190
      src/lib/components/RelayActions.svelte
  18. 59
      src/lib/components/RelayDisplay.svelte
  19. 6
      src/lib/components/cards/BlogHeader.svelte
  20. 120
      src/lib/components/cards/ProfileHeader.svelte
  21. 7
      src/lib/components/util/ArticleNav.svelte
  22. 212
      src/lib/components/util/CardActions.svelte
  23. 33
      src/lib/components/util/CopyToClipboard.svelte
  24. 37
      src/lib/components/util/Details.svelte
  25. 59
      src/lib/components/util/InlineProfile.svelte
  26. 2
      src/lib/components/util/Profile.svelte
  27. 17
      src/lib/components/util/QrCode.svelte
  28. 12
      src/lib/consts.ts
  29. 7
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  30. 8
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  31. 6
      src/lib/ndk.ts
  32. 60
      src/lib/parser.ts
  33. 20
      src/lib/snippets/UserSnippets.svelte
  34. 4
      src/lib/stores/relayStore.ts
  35. 41
      src/lib/utils.ts
  36. 2
      src/lib/utils/markup/MarkupInfo.md
  37. 2
      src/lib/utils/markup/basicMarkupParser.ts
  38. 2
      src/lib/utils/mime.ts
  39. 275
      src/lib/utils/nostrUtils.ts
  40. 4
      src/lib/utils/npubCache.ts
  41. 29
      src/routes/+page.svelte
  42. 1
      src/routes/[...catchall]/+page.svelte
  43. 8
      src/routes/about/+page.svelte
  44. 8
      src/routes/contact/+page.svelte
  45. 79
      src/routes/events/+page.svelte
  46. 3
      src/routes/publication/+page.ts
  47. 3
      src/routes/visualize/+page.svelte
  48. 5
      src/styles/events.css
  49. 2
      test_data/AsciidocFiles/21lessons.adoc
  50. 2
      test_data/AsciidocFiles/Rauhnaechte.adoc
  51. 8
      tests/integration/markupIntegration.test.ts
  52. 8
      tests/integration/markupTestfile.md
  53. 2
      tests/unit/advancedMarkupParser.test.ts
  54. 2
      tests/unit/basicMarkupParser.test.ts
  55. 5
      vite.config.ts

1
.gitignore vendored

@ -8,7 +8,6 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
package-lock.json
# tests # tests
/tests/e2e/html-report/*.html /tests/e2e/html-report/*.html

16
.vscode/settings.json vendored

@ -1,6 +1,14 @@
{ {
"editor.tabSize": 2, "css.validate": false,
"files.associations": { "tailwindCSS.includeLanguages": {
"*.css": "postcss" "svelte": "html",
} "typescript": "javascript",
"javascript": "javascript"
},
"editor.quickSuggestions": {
"strings": true
},
"files.associations": {
"*.svelte": "svelte"
}
} }

8
README.md

@ -5,11 +5,13 @@
Alexandria is a reader and writer for curated publications, including e-books. Alexandria is a reader and writer for curated publications, including e-books.
For a thorough introduction, please refer to our [project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](https://next-alexandria.gitcitadel.eu/about). For a thorough introduction, please refer to our [project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](https://next-alexandria.gitcitadel.eu/about).
It also contains a [universal event viewer](https://next-alexandria.gitcitadel.eu/events), with which you can search our relays, some aggregator relays, and your own relay list, to find and view event data.
## Issues and Patches ## Issues and Patches
If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact). If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact).
You can also contact us [on Nostr](https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly. You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly.
## Developing ## Developing
@ -73,7 +75,7 @@ To run the container, in detached mode (-d):
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
``` ```
The container is then viewable on your [local machine](http://localhost:4174). The container is then viewable on your [local machine](http://localhost:4173).
If you want to see the container process (assuming it's the last process to start), enter: If you want to see the container process (assuming it's the last process to start), enter:
@ -118,4 +120,4 @@ npx playwright test
## Markup Support ## Markup Support
Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](src/lib/utils/markup/MarkupInfo.md). Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md).

2087
package-lock.json generated

File diff suppressed because it is too large Load Diff

5
package.json

@ -20,11 +20,13 @@
"@tailwindcss/forms": "0.5.x", "@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"bech32": "^2.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"he": "1.2.x", "he": "1.2.x",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"node-emoji": "^2.2.0", "node-emoji": "^2.2.0",
"nostr-tools": "2.10.x" "nostr-tools": "2.10.x",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
@ -36,6 +38,7 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/he": "1.2.x", "@types/he": "1.2.x",
"@types/node": "22.x", "@types/node": "22.x",
"@types/qrcode": "^1.5.5",
"autoprefixer": "10.x", "autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x", "eslint-plugin-svelte": "2.x",
"flowbite": "2.x", "flowbite": "2.x",

1
src/app.css

@ -2,6 +2,7 @@
@import './styles/scrollbar.css'; @import './styles/scrollbar.css';
@import './styles/publications.css'; @import './styles/publications.css';
@import './styles/visualize.css'; @import './styles/visualize.css';
@import "./styles/events.css";
/* Custom styles */ /* Custom styles */
@layer base { @layer base {

317
src/lib/components/CommentBox.svelte

@ -0,0 +1,317 @@
<script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte';
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser';
import { nip19 } from 'nostr-tools';
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils';
import { standardRelays, fallbackRelays } from '$lib/consts';
import { userRelays } from '$lib/stores/relayStore';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { onMount } from 'svelte';
const props = $props<{
event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean;
}>();
let content = $state('');
let preview = $state('');
let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null);
let showOtherRelays = $state(false);
let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
// Fetch user profile on mount
onMount(async () => {
if (props.userPubkey) {
const npub = nip19.npubEncode(props.userPubkey);
userProfile = await getUserMetadata(npub);
}
});
// Markup buttons
const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') },
{ label: 'Italic', action: () => insertMarkup('_', '_') },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') },
{ label: 'Link', action: () => insertMarkup('[', '](url)') },
{ label: 'Image', action: () => insertMarkup('![', '](url)') },
{ label: 'Quote', action: () => insertMarkup('> ', '') },
{ label: 'List', action: () => insertMarkup('- ', '') },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') },
{ label: 'Hashtag', action: () => insertMarkup('#', '') }
];
function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end);
updatePreview();
// Set cursor position after the inserted markup
setTimeout(() => {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length;
}, 0);
}
async function updatePreview() {
preview = await parseBasicmarkup(content);
}
function clearForm() {
content = '';
preview = '';
error = null;
success = null;
showOtherRelays = false;
showFallbackRelays = false;
}
function removeFormatting() {
content = content
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/^>\s*/gm, '')
.replace(/^[-*]\s*/gm, '')
.replace(/^\d+\.\s*/gm, '')
.replace(/#(\w+)/g, '$1');
updatePreview();
}
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) {
isSubmitting = true;
error = null;
success = null;
try {
if (!props.event.kind) {
throw new Error('Invalid event: missing kind');
}
const kind = props.event.kind === 1 ? 1 : 1111;
const tags: string[][] = [];
if (kind === 1) {
// NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']);
tags.push(['p', props.event.pubkey]);
if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root');
if (rootTag) {
tags.push(['e', rootTag[1], '', 'root']);
}
// Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', t[1]]);
}
});
}
} else {
// NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]);
}
const eventToSign = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
pubkey: props.userPubkey
};
const id = getEventHash(eventToSign);
const sig = await signEvent(eventToSign);
const signedEvent = {
...eventToSign,
id,
sig
};
// Determine which relays to use
let relays = props.userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = props.userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
}
// Try to publish to relays
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === 'OK' && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
success = { relay: relayUrl, eventId: signedEvent.id };
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error('WebSocket error'));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (!published) {
if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true;
error = 'Failed to publish to primary relays. Would you like to try the other relays?';
} else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true;
error = 'Failed to publish to other relays. Would you like to try the fallback relays?';
} else {
error = 'Failed to publish to any relays. Please try again later.';
}
} else {
// Navigate to the event page
const nevent = nip19.neventEncode({ id: signedEvent.id });
goto(`/events?id=${nevent}`);
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
isSubmitting = false;
}
}
</script>
<div class="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button>
{/each}
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Textarea
bind:value={content}
on:input={updatePreview}
placeholder="Write your comment..."
rows={10}
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg">
{@html preview}
</div>
</div>
{#if error}
<Alert color="red" dismissable>
{error}
{#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button>
{/if}
{#if showFallbackRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button>
{/if}
</Alert>
{/if}
{#if success}
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline">
View your comment
</a>
</Alert>
{/if}
<div class="flex justify-end items-center gap-4">
{#if userProfile}
<div class="flex items-center gap-2 text-sm">
{#if userProfile.picture}
<img
src={userProfile.picture}
alt={userProfile.name || 'Profile'}
class="w-8 h-8 rounded-full"
onerror={(e) => {
const img = e.target as HTMLImageElement;
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`;
}}
/>
{/if}
<span class="text-gray-700 dark:text-gray-300">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'}
</span>
</div>
{/if}
<Button
on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey}
class="w-full md:w-auto"
>
{#if !props.userPubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
{:else}
Post Comment
{/if}
</Button>
</div>
{#if !props.userPubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your current account.
</Alert>
{/if}
</div>
<style>
/* Add styles for disabled state */
:global(.disabled) {
opacity: 0.6;
cursor: not-allowed;
}
</style>

184
src/lib/components/EventDetails.svelte

@ -0,0 +1,184 @@
<script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { getMatchingTags } from '$lib/utils/nostrUtils';
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
const { event, profile = null, searchValue = null } = $props<{
event: NDKEvent;
profile?: {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null;
searchValue?: string | null;
}>();
let showFullContent = $state(false);
let parsedContent = $state('');
let contentPreview = $state('');
function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled';
}
function getEventSummary(event: NDKEvent): string {
return getMatchingTags(event, 'summary')[0]?.[1] || '';
}
function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]);
}
function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`;
}
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} else if (tag[0] === 'e' && tag.length > 1) {
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
}
}
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
}
});
// --- Identifier helpers ---
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] {
const ids: { label: string, value: string, link?: string }[] = [];
if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1];
// npub
const npub = toNpub(event.pubkey);
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` });
// nprofile
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, standardRelays), link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}` });
// nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` });
// hex pubkey
ids.push({ label: 'pubkey', value: event.pubkey });
} else {
// nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` });
// naddr (if addressable)
try {
const naddr = naddrEncode(event, standardRelays);
ids.push({ label: 'naddr', value: naddr, link: `/events?id=${naddr}` });
} catch {}
// hex id
ids.push({ label: 'id', value: event.id });
}
return ids;
}
function isCurrentSearch(value: string): boolean {
if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, '').toLowerCase();
return norm(value) === norm(searchValue);
}
</script>
<div class="flex flex-col space-y-4">
{#if event.kind !== 0 && getEventTitle(event)}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2>
{/if}
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span>
{:else}
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span>
{/if}
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600 dark:text-gray-400">Kind:</span>
<span class="font-mono">{event.kind}</span>
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span>
</div>
{#if getEventSummary(event)}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Summary:</span>
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p>
</div>
{/if}
{#if getEventHashtags(event).length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Tags:</span>
<div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag}
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span>
{/each}
</div>
</div>
{/if}
<!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0}
<span class="text-gray-600 dark:text-gray-400">Content:</span>
<div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250}
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button>
{/if}
</div>
{/if}
</div>
<!-- If event is profile -->
{#if event.kind === 0}
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} />
{/if}
<!-- Tags Array -->
{#if event.tags && event.tags.length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span>
<div class="flex flex-wrap gap-2">
{#each event.tags as tag}
{@html renderTag(tag)}
{/each}
</div>
</div>
{/if}
<!-- Raw Event JSON -->
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4">
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2">
Show Raw Event JSON
</summary>
<pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono"
style="line-height: 1.7; font-size: 1rem;"
>
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>
</details>
</div>

204
src/lib/components/EventSearch.svelte

@ -0,0 +1,204 @@
<script lang="ts">
import { Input, Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils';
import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay from './RelayDisplay.svelte';
const { loading, error, searchValue, onEventFound, event } = $props<{
loading: boolean;
error: string | null;
searchValue: string | null;
onEventFound: (event: NDKEvent) => void;
event: NDKEvent | null;
}>();
let searchQuery = $state("");
let localError = $state<string | null>(null);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false);
$effect(() => {
if (searchValue) {
searchEvent(false, searchValue);
}
});
$effect(() => {
foundEvent = event;
});
async function searchEvent(clearInput: boolean = true, queryOverride?: string) {
localError = null;
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim();
if (!query) return;
// Only update the URL if this is a manual search
if (clearInput) {
const encoded = encodeURIComponent(query);
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true });
}
if (clearInput) {
searchQuery = '';
}
// Clean the query
let cleanedQuery = query.replace(/^nostr:/, '');
let filterOrId: any = cleanedQuery;
console.log('[Events] Cleaned query:', cleanedQuery);
// NIP-05 address pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) {
try {
const [name, domain] = cleanedQuery.split('@');
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`);
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
filterOrId = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (profileEvent) {
handleFoundEvent(profileEvent);
return;
} else {
localError = 'No profile found for this NIP-05 address.';
return;
}
} else {
localError = 'NIP-05 address not found.';
return;
}
} catch (e) {
localError = 'Error resolving NIP-05 address.';
return;
}
}
// If it's a 64-char hex, try as event id first, then as pubkey (profile)
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
// Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback($ndkInstance, profileFilter, 10000);
// Prefer profile if found and pubkey matches query
if (profileEvent && profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()) {
handleFoundEvent(profileEvent);
} else if (eventResult) {
handleFoundEvent(eventResult);
}
return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error('Invalid identifier');
console.log('[Events] Decoded NIP-19:', decoded);
switch (decoded.type) {
case 'nevent':
filterOrId = decoded.data.id;
break;
case 'note':
filterOrId = decoded.data;
break;
case 'naddr':
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier],
};
break;
case 'nprofile':
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
break;
case 'npub':
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
}
console.log('[Events] Using filterOrId:', filterOrId);
} catch (e) {
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e);
localError = 'Invalid Nostr identifier.';
return;
}
}
try {
console.log('Searching for event:', filterOrId);
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (!event) {
console.warn('[Events] Event not found for filterOrId:', filterOrId);
localError = 'Event not found';
} else {
console.log('[Events] Event found:', event);
handleFoundEvent(event);
}
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', query);
localError = 'Error fetching event. Please check the ID and try again.';
}
}
function handleFoundEvent(event: NDKEvent) {
foundEvent = event;
onEventFound(event);
}
</script>
<div class="flex flex-col space-y-6">
<div class="flex gap-2">
<Input
bind:value={searchQuery}
placeholder="Enter event ID, nevent, or naddr..."
class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)}
/>
<Button on:click={() => searchEvent(true)} disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
{#if localError || error}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{localError || error}
{#if searchQuery.trim()}
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
>Njump</a>.
</div>
{/if}
</div>
{/if}
<div class="mt-4">
<div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]}
<RelayDisplay {relay} showStatus={true} status={status} />
{/each}
</div>
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')}
<div class="text-gray-500 mt-2">Searching relays...</div>
{/if}
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')}
<div class="text-red-500 mt-2">Event not found on any relay.</div>
{/if}
</div>
</div>

7
src/lib/components/Login.svelte

@ -1,13 +1,10 @@
<script lang='ts'> <script lang='ts'>
import { type NDKUserProfile } from '@nostr-dev-kit/ndk'; import { type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { activePubkey, loginWithExtension, logout, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk'; import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover, Tooltip } from 'flowbite-svelte'; import { Avatar, Button, Popover } from 'flowbite-svelte';
import Profile from "$components/util/Profile.svelte"; import Profile from "$components/util/Profile.svelte";
let profile = $state<NDKUserProfile | null>(null); let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
let username = $derived(profile?.name);
let tag = $derived(profile?.name);
let npub = $state<string | undefined >(undefined); let npub = $state<string | undefined >(undefined);
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);

5
src/lib/components/Navigation.svelte

@ -10,8 +10,6 @@
import Login from "./Login.svelte"; import Login from "./Login.svelte";
let { class: className = "" } = $props(); let { class: className = "" } = $props();
let leftMenuOpen = $state(false);
</script> </script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}> <Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
@ -25,9 +23,10 @@
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
<NavLi href="/new/edit">Publish</NavLi> <NavLi href="/">Publications</NavLi>
<NavLi href="/visualize">Visualize</NavLi> <NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi> <NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>
<NavLi href="/about">About</NavLi> <NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi> <NavLi href="/contact">Contact</NavLi>
<NavLi> <NavLi>

7
src/lib/components/Preview.svelte

@ -4,7 +4,8 @@
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons'; import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte'; import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte'; import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
import BlogHeader from "./blog/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -101,14 +102,14 @@
function byline(rootId: string, index: number) { function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries); console.log(rootId, index, blogEntries);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const author = event ? event.getMatchingTags("author")[0][1] : ''; const author = event ? getMatchingTags(event, 'author')[0][1] : '';
return author ?? ""; return author ?? "";
} }
function hasCoverImage(rootId: string, index: number) { function hasCoverImage(rootId: string, index: number) {
console.log(rootId); console.log(rootId);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const image = event && event.getMatchingTags("image")[0] ? event.getMatchingTags("image")[0][1] : ''; const image = event && getMatchingTags(event, 'image')[0] ? getMatchingTags(event, 'image')[0][1] : '';
return image ?? ''; return image ?? '';
} }

2
src/lib/components/Publication.svelte

@ -18,7 +18,7 @@
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte"; import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/blog/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte"; import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser'; import { pharosInstance } from '$lib/parser';

245
src/lib/components/PublicationFeed.svelte

@ -1,67 +1,202 @@
<script lang='ts'> <script lang='ts'>
import { indexKind } from '$lib/consts'; import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils'; import { filterValidIndexEvents, debounce } from '$lib/utils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte'; import ArticleHeader from './PublicationHeader.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils';
let { relays } = $props<{ relays: string[] }>(); let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>();
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false); let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived( let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime() eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
); );
async function getEvents( // Debounced search function
before: number | undefined = undefined, const debouncedSearch = debounce(async (query: string) => {
): Promise<void> { console.debug('[PublicationFeed] Search query changed:', query);
let eventSet = await $ndkInstance.fetchEvents( if (query.trim()) {
{ console.debug('[PublicationFeed] Clearing events and searching with query:', query);
kinds: [indexKind], eventsInView = [];
limit: 16, await getEvents(undefined, query, true);
until: before, } else {
}, console.debug('[PublicationFeed] Clearing events and resetting search');
{ eventsInView = [];
groupable: false, await getEvents(undefined, '', true);
skipVerification: false, }
skipValidation: false, }, 300);
},
NDKRelaySet.fromRelayUrls(relays, $ndkInstance)
);
eventSet = filterValidIndexEvents(eventSet);
let eventArray = Array.from(eventSet); $effect(() => {
eventArray?.sort((a, b) => b.created_at! - a.created_at!); console.debug('[PublicationFeed] Search query effect triggered:', searchQuery);
debouncedSearch(searchQuery);
});
if (!eventArray) { async function getEvents(before: number | undefined = undefined, search: string = '', reset: boolean = false) {
return; loading = true;
} const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r));
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending']));
let allEvents: NDKEvent[] = [];
let fetchedCount = 0; // Track number of new events
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id; console.debug('[getEvents] Called with before:', before, 'search:', search);
if (endOfFeed) { // Function to filter events based on search query
return; const filterEventsBySearch = (events: NDKEvent[]) => {
} if (!search) return events;
const query = search.toLowerCase();
console.debug('[PublicationFeed] Filtering events with query:', query, 'Total events before filter:', events.length);
// Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
console.debug('[PublicationFeed] Is NIP-05 query:', isNip05Query);
const filtered = events.filter(event => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? '';
const authorName = getMatchingTags(event, 'author')[0]?.[1]?.toLowerCase() ?? '';
const authorPubkey = event.pubkey.toLowerCase();
const nip05 = getMatchingTags(event, 'nip05')[0]?.[1]?.toLowerCase() ?? '';
// For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) {
const matches = nip05 === query;
if (matches) {
console.debug('[PublicationFeed] Event matches NIP-05 search:', {
id: event.id,
nip05,
authorPubkey
});
}
return matches;
}
// For regular queries, match against all fields
const matches = (
title.includes(query) ||
authorName.includes(query) ||
authorPubkey.includes(query) ||
nip05.includes(query)
);
if (matches) {
console.debug('[PublicationFeed] Event matches search:', {
id: event.id,
title,
authorName,
authorPubkey,
nip05
});
}
return matches;
});
console.debug('[PublicationFeed] Events after filtering:', filtered.length);
return filtered;
};
const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event])); // First, try primary relays
const allEvents = Array.from(eventMap.values()); let foundEventsInPrimary = false;
const uniqueIds = new Set(allEvents.map(event => event.id)); await Promise.all(
eventsInView = Array.from(uniqueIds) primaryRelays.map(async (relay: string) => {
.map(id => eventMap.get(id)) try {
.filter(event => event != null) as NDKEvent[]; const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 30,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
foundEventsInPrimary = true;
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from primary relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
// Only try fallback relays if no events were found in primary relays
if (!foundEventsInPrimary && fallback.length > 0) {
console.debug('[getEvents] No events found in primary relays, trying fallback relays');
relayStatuses = { ...relayStatuses, ...Object.fromEntries(fallback.map((r: string) => [r, 'pending'])) };
await Promise.all(
fallback.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 18,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`);
} catch (err) {
console.error(`Error fetching from fallback relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
}
// Deduplicate and sort
const eventMap = reset
? new Map(allEvents.map(event => [event.tagAddress(), event]))
: new Map([...eventsInView, ...allEvents].map(event => [event.tagAddress(), event]));
const uniqueEvents = Array.from(eventMap.values());
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!);
eventsInView = uniqueEvents;
const pageSize = fallback.length > 0 ? 18 : 30;
if (fetchedCount < pageSize) {
endOfFeed = true;
} else {
endOfFeed = false;
}
console.debug(`[getEvents] Total unique events after deduplication: ${uniqueEvents.length}`);
console.debug(`[getEvents] endOfFeed set to: ${endOfFeed} (fetchedCount: ${fetchedCount}, pageSize: ${pageSize})`);
loading = false;
console.debug('Relay statuses:', relayStatuses);
} }
const getSkeletonIds = (): string[] => { const getSkeletonIds = (): string[] => {
const skeletonHeight = 124; // The height of the skeleton component in pixels. const skeletonHeight = 124; // The height of the skeleton component in pixels.
// Determine the number of skeletons to display based on the height of the screen.
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = []; const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) { for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`); skeletonIds.push(`skeleton-${i}`);
@ -71,7 +206,7 @@
async function loadMorePublications() { async function loadMorePublications() {
loadingMore = true; loadingMore = true;
await getEvents(cutoffTimestamp); await getEvents(cutoffTimestamp, searchQuery, false);
loadingMore = false; loadingMore = false;
} }
@ -80,21 +215,25 @@
}); });
</script> </script>
<div class='leather flex flex-col space-y-4'> <div class='leather'>
{#if eventsInView.length === 0} <div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{#each getSkeletonIds() as id} {#if loading && eventsInView.length === 0}
<Skeleton divClass='skeleton-leather w-full' size='lg' /> {#each getSkeletonIds() as id}
{/each} <Skeleton divClass='skeleton-leather w-full' size='lg' />
{:else if eventsInView.length > 0} {/each}
{#each eventsInView as event} {:else if eventsInView.length > 0}
<ArticleHeader {event} /> {#each eventsInView as event}
{/each} <ArticleHeader {event} />
{:else} {/each}
<p class='text-center'>No publications found.</p> {:else}
{/if} <div class='col-span-full'>
<p class='text-center'>No publications found.</p>
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed} {#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'> <div class='flex justify-center mt-4 mb-8'>
<Button outline class="w-full" onclick={async () => { <Button outline class="w-full max-w-md" onclick={async () => {
await loadMorePublications(); await loadMorePublications();
}}> }}>
Show more publications Show more publications
@ -102,7 +241,7 @@
</div> </div>
{:else if loadingMore} {:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'> <div class='flex justify-center mt-4 mb-8'>
<Button outline disabled class="w-full"> <Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-300' size='4' /> <Spinner class='mr-3 text-gray-300' size='4' />
Loading... Loading...
</Button> </Button>

7
src/lib/components/PublicationHeader.svelte

@ -5,7 +5,7 @@
import { standardRelays } from '../consts'; import { standardRelays } from '../consts';
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import InlineProfile from "$components/util/InlineProfile.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -24,11 +24,12 @@
); );
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown'); let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
@ -45,7 +46,7 @@
<h3 class='text-base font-normal'> <h3 class='text-base font-normal'>
by by
{#if authorPubkey != null} {#if authorPubkey != null}
<InlineProfile pubkey={authorPubkey} title={author} /> {@render userBadge(authorPubkey, author)}
{:else} {:else}
{author} {author}
{/if} {/if}

3
src/lib/components/PublicationSection.svelte

@ -5,6 +5,7 @@
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor"; import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils';
let { let {
address, address,
@ -109,7 +110,7 @@
<TextPlaceholder size='xxl' /> <TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} {@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{/each} {/each}
{#if leafTitle} {#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1} {@const leafDepth = leafHierarchy.length - 1}

190
src/lib/components/RelayActions.svelte

@ -0,0 +1,190 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { get } from 'svelte/store';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte';
import { standardRelays, fallbackRelays } from "$lib/consts";
const { event } = $props<{
event: NDKEvent;
}>();
let searchingRelays = $state(false);
let foundRelays = $state<string[]>([]);
let broadcasting = $state(false);
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false);
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG
const searchIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>`;
// Broadcast icon SVG
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/>
</svg>`;
async function broadcastEvent() {
if (!event || !$ndkInstance?.activeUser) return;
broadcasting = true;
broadcastSuccess = false;
broadcastError = null;
try {
const connectedRelays = getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays available');
}
// Create a new event with the same content
const newEvent = createNDKEvent($ndkInstance, {
...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000),
sig: ''
});
// Publish to all relays
await newEvent.publish();
broadcastSuccess = true;
} catch (err) {
console.error('Error broadcasting event:', err);
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event';
} finally {
broadcasting = false;
}
}
function openRelayModal() {
showRelayModal = true;
relaySearchResults = {};
searchAllRelaysLive();
}
async function searchAllRelaysLive() {
if (!event) return;
relaySearchResults = {};
const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url);
allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending']));
await Promise.all(
allRelays.map(async (relay: string) => {
try {
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent(
{ ids: [event?.id || ''] },
undefined,
relaySet
).withTimeout(3000);
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' };
} catch {
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' };
}
})
);
}
function closeRelayModal() {
showRelayModal = false;
}
</script>
<div class="mt-4 flex flex-wrap gap-2">
<Button
on:click={openRelayModal}
class="flex items-center"
>
{@html searchIcon}
Where can I find this event?
</Button>
{#if $ndkInstance?.activeUser}
<Button
on:click={broadcastEvent}
disabled={broadcasting}
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? 'Broadcasting...' : 'Broadcast'}
</Button>
{/if}
</div>
{#if foundRelays.length > 0}
<div class="mt-2">
<span class="font-semibold">Found on {foundRelays.length} relay(s):</span>
<div class="flex flex-wrap gap-2 mt-1">
{#each foundRelays as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastSuccess}
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded">
Event broadcast successfully to:
<div class="flex flex-wrap gap-2 mt-1">
{#each getConnectedRelays() as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastError}
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded">
{broadcastError}
</div>
{/if}
<div class="mt-2">
<span class="font-semibold">Found on:</span>
<div class="flex flex-wrap gap-2 mt-1">
{#each getEventRelays(event) as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{#if showRelayModal}
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative">
<button class="absolute top-2 right-2 text-gray-500 hover:text-gray-800" onclick={closeRelayModal}>&times;</button>
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries({
'Standard Relays': standardRelays,
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url),
'Fallback Relays': fallbackRelays
}) as [groupName, groupRelays]}
{#if groupRelays.length > 0}
<div class="flex flex-col gap-2">
<h3 class="font-medium text-gray-700 dark:text-gray-300 sticky top-0 bg-white dark:bg-gray-900 py-2">
{groupName}
</h3>
{#each groupRelays as relay}
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} />
{/each}
</div>
{/if}
{/each}
</div>
<div class="mt-4 flex justify-end">
<Button onclick={closeRelayModal}>Close</Button>
</div>
</div>
</div>
{/if}

59
src/lib/components/RelayDisplay.svelte

@ -0,0 +1,59 @@
<script lang="ts" context="module">
import type { NDKEvent } from '$lib/utils/nostrUtils';
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays)
export function getEventRelays(event: NDKEvent): string[] {
if (event && (event as any).relay) {
const relay = (event as any).relay;
return [typeof relay === 'string' ? relay : relay.url];
}
if (event && (event as any).relays && (event as any).relays.length) {
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url);
}
return standardRelays;
}
export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url);
}
</script>
<script lang="ts">
import { get } from 'svelte/store';
import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
export let relay: string;
export let showStatus = false;
export let status: 'pending' | 'found' | 'notfound' | null = null;
// Use a static fallback icon for all relays
function relayFavicon(relay: string): string {
return '/favicon.png';
}
</script>
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<img
src={relayFavicon(relay)}
alt="relay icon"
class="w-5 h-5 object-contain"
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }}
/>
<span class="font-mono text-xs flex-1">{relay}</span>
{#if showStatus && status}
{#if status === 'pending'}
<svg class="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
{:else if status === 'found'}
<span class="text-green-600">&#10003;</span>
{:else}
<span class="text-red-500">&#10007;</span>
{/if}
{/if}
</div>

6
src/lib/components/blog/BlogHeader.svelte → src/lib/components/cards/BlogHeader.svelte

@ -2,7 +2,7 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import InlineProfile from "$components/util/InlineProfile.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
@ -10,7 +10,7 @@
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>(); const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown'); let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let hashtags: string = $derived(event.getMatchingTags('t') ?? null); let hashtags: string = $derived(event.getMatchingTags('t') ?? null);
@ -38,7 +38,7 @@
<div class='space-y-4'> <div class='space-y-4'>
<div class="flex flex-row justify-between my-2"> <div class="flex flex-row justify-between my-2">
<div class="flex flex-col"> <div class="flex flex-col">
<InlineProfile pubkey={authorPubkey} title={author} /> {@render userBadge(authorPubkey, author)}
<span class='text-gray-500'>{publishedAt()}</span> <span class='text-gray-500'>{publishedAt()}</span>
</div> </div>
<CardActions event={event} /> <CardActions event={event} />

120
src/lib/components/cards/ProfileHeader.svelte

@ -0,0 +1,120 @@
<script lang="ts">
import { Card, Img, Modal, Button, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// @ts-ignore
import { bech32 } from 'https://esm.sh/bech32';
import type { NDKEvent } from "@nostr-dev-kit/ndk";
const { event, profile, identifiers = [] } = $props<{ event: NDKEvent, profile: NostrProfile, identifiers?: { label: string, value: string, link?: string }[] }>();
let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null);
onMount(async () => {
if (profile?.lud16) {
try {
// Convert LN address to LNURL
const [name, domain] = profile?.lud16.split('@');
const url = `https://${domain}/.well-known/lnurlp/${name}`;
const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode('lnurl', words);
} catch {
console.log('Error converting LN address to LNURL');
}
}
});
</script>
{#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl">
<div class='space-y-4'>
{#if profile.banner}
<div class="ArticleBoxImage flex col justify-center">
<Img src={profile.banner} class="rounded w-full max-h-72 object-cover" alt="Profile banner" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none';}} />
</div>
{/if}
<div class='flex flex-row space-x-4 items-center'>
{#if profile.picture}
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} />
{/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.name || event.pubkey)}
</div>
<div>
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
{#if profile.name}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Name:</dt>
<dd>{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Display Name:</dt>
<dd>{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd>
</div>
{/if}
{#if profile.website}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt>
<dd>
<a href={profile.website} target="_blank" class="underline text-primary-700 dark:text-primary-200">{profile.website}</a>
</dd>
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd>
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd>
</div>
{/each}
</dl>
</div>
</div>
</div>
</Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'>
{#if profile.lud16}
<div>
<div class='flex flex-col items-center'>
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)}
<P>{profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
{/if}

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

@ -2,12 +2,11 @@
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons"; import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import InlineProfile from "$components/util/InlineProfile.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
let { let {
rootId,
publicationType, publicationType,
indexEvent indexEvent
} = $props<{ } = $props<{
@ -17,7 +16,7 @@
}>(); }>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]); let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags('author')[0]?.[1] ?? 'unknown'); let author: string = $derived(indexEvent.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null); let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);
@ -131,7 +130,7 @@
{/if} {/if}
</div> </div>
<div class="flex flex-grow text justify-center items-center"> <div class="flex flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by <InlineProfile pubkey={pubkey} title={author} /></span></p> <p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span></p>
</div> </div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner} {#if $publicationColumnVisibility.inner}

212
src/lib/components/util/CardActions.svelte

@ -1,76 +1,112 @@
<script lang="ts"> <script lang="ts">
import { import {
ClipboardCheckOutline,
ClipboardCleanOutline, ClipboardCleanOutline,
CodeOutline,
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ShareNodesOutline ShareNodesOutline
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte"; import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts"; import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import Details from "./Details.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
let { event } = $props(); // Component props
let { event } = $props<{ event: NDKEvent }>();
let jsonModalOpen: boolean = $state(false); // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? '');
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null);
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? '');
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null);
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? '');
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null);
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null);
let language = $derived(event.tags.find((t: string[]) => t[0] === 'language')?.[1] ?? null);
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null);
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null);
// UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
let eventIdCopied: boolean = $state(false); let isOpen: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
/**
* Selects the appropriate relay set based on user state and feed type
* - Uses user's inbox relays when signed in and viewing personal feed
* - Falls back to standard relays for anonymous users or standard feed
*/
let activeRelays = $derived(
(() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: $ndkSignedIn,
feedType: $feedType,
isUserFeed,
relayCount: relays.length,
relayUrls: relays
});
let isOpen = $state(false); return relays;
})()
);
/**
* Opens the actions popover menu
*/
function openPopover() { function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true; isOpen = true;
} }
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() { function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false; isOpen = false;
const menu = document.getElementById('dots-' + event.id); const menu = document.getElementById('dots-' + event.id);
if (menu) menu.blur(); if (menu) menu.blur();
} }
function shareNjump() { /**
const relays: string[] = standardRelays; * Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
try { * @returns The encoded identifier string
const naddr = naddrEncode(event, relays); */
console.debug(naddr); function getIdentifier(type: 'nevent' | 'naddr'): string {
navigator.clipboard.writeText(`https://njump.me/${naddr}`); const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
shareLinkCopied = true; const identifier = encodeFn(event, activeRelays);
setTimeout(() => { console.debug("[CardActions] ${type} identifier for event ${event.id}:", identifier);
shareLinkCopied = false; return identifier;
}, 4000);
}
catch (e) {
console.error('Failed to encode naddr:', e);
}
}
function copyEventId() {
console.debug("copyEventID");
const relays: string[] = standardRelays;
const nevent = neventEncode(event, relays);
navigator.clipboard.writeText(nevent);
eventIdCopied = true;
setTimeout(() => {
eventIdCopied = false;
}, 4000);
}
function viewJson() {
console.debug("viewJSON");
jsonModalOpen = true;
} }
/**
* Opens the event details modal
*/
function viewDetails() { function viewDetails() {
console.log('Details'); console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
});
detailsModalOpen = true; detailsModalOpen = true;
} }
// Log component initialization
console.debug("[CardActions] Initialized", {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author
});
</script> </script>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}> <div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
@ -99,41 +135,79 @@
</button> </button>
</li> </li>
<li> <li>
<button class='btn-leather w-full text-left' onclick={shareNjump}> <CopyToClipboard
{#if shareLinkCopied} displayText="Copy naddr address"
<ClipboardCheckOutline class="inline mr-2" /> Copied! copyText={getIdentifier('naddr')}
{:else} icon={ShareNodesOutline}
<ShareNodesOutline class="inline mr-2" /> Share via NJump />
{/if}
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={copyEventId}>
{#if eventIdCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ClipboardCleanOutline class="inline mr-2" /> Copy event ID
{/if}
</button>
</li> </li>
<li> <li>
<button class='btn-leather w-full text-left' onclick={viewJson}> <CopyToClipboard
<CodeOutline class="inline mr-2" /> View JSON displayText="Copy nevent address"
</button> copyText={getIdentifier('nevent')}
icon={ClipboardCleanOutline}
/>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</Popover> </Popover>
{/if} {/if}
<!-- Event JSON -->
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='lg'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1" style="max-height: 70vh;">
<pre><code>{JSON.stringify(event.rawEvent(), null, 2)}</code></pre>
</div>
</Modal>
<!-- Event details --> <!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'> <Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<Details event={event} isModal={true} /> <div class="flex flex-row space-x-4">
{#if image}
<div class="flex col">
<img src={image} alt="Publication cover" class="w-32 h-32 object-cover rounded" />
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || 'Untitled'}</h1>
<h2 class="text-base font-bold">by
{#if originalAuthor}
{@render userBadge(originalAuthor, author)}
{:else}
{author || 'Unknown'}
{/if}
</h2>
{#if version}
<h4 class='text-base font-thin mt-2'>Version: {version}</h4>
{/if}
</div>
</div>
{#if summary}
<div class="flex flex-row">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
<div class="flex flex-row">
<h4 class='text-base font-normal mt-2'>Index author: {@render userBadge(event.pubkey, author)}</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank" rel="noopener noreferrer">{source}</a></h5>
{/if}
{#if type}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5>
{/if}
<a
href="/events?id={getIdentifier('nevent')}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
>
View Event Details
</a>
</div>
</Modal> </Modal>
</div> </div>

33
src/lib/components/util/CopyToClipboard.svelte

@ -1,27 +1,44 @@
<script lang='ts'> <script lang='ts'>
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons";
import { withTimeout } from "$lib/utils/nostrUtils";
import type { Component } from "svelte";
let { displayText, copyText = displayText} = $props(); let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{
displayText: string;
copyText?: string;
icon?: Component | false;
}>();
let copied: boolean = $state(false); let copied: boolean = $state(false);
async function copyToClipboard() { async function copyToClipboard() {
try { try {
await navigator.clipboard.writeText(copyText); await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true; copied = true;
setTimeout(() => { await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)),
4000
).then(() => {
copied = false; copied = false;
}, 4000); }).catch(() => {
// If timeout occurs, still reset the state
copied = false;
});
} catch (err) { } catch (err) {
console.error("Failed to copy: ", err); console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err);
} }
} }
</script> </script>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}> <button class='btn-leather w-full text-left' onclick={copyToClipboard}>
{#if copied} {#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied! <ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else} {:else}
<ClipboardCleanOutline class="!fill-none dark:!fill-none inline mr-1" /> {displayText} {#if icon === ClipboardCleanOutline}
<ClipboardCleanOutline class="inline mr-2" />
{:else if icon === ClipboardCheckOutline}
<ClipboardCheckOutline class="inline mr-2" />
{/if}
{displayText}
{/if} {/if}
</button> </button>

37
src/lib/components/util/Details.svelte

@ -1,27 +1,28 @@
<script lang="ts"> <script lang="ts">
import InlineProfile from "$components/util/InlineProfile.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte"; import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props(); let { event, isModal = false } = $props();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown'); let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null);
let summary: string = $derived(event.getMatchingTags('summary')[0]?.[1] ?? null); let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null);
let type: string = $derived(event.getMatchingTags('type')[0]?.[1] ?? null); let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null);
let language: string = $derived(event.getMatchingTags('l')[0]?.[1] ?? null); let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null);
let source: string = $derived(event.getMatchingTags('source')[0]?.[1] ?? null); let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null);
let publisher: string = $derived(event.getMatchingTags('published_by')[0]?.[1] ?? null); let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null);
let identifier: string = $derived(event.getMatchingTags('i')[0]?.[1] ?? null); let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null);
let hashtags: [] = $derived(event.getMatchingTags('t') ?? []); let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1]));
let rootId: string = $derived(event.getMatchingTags('d')[0]?.[1] ?? null); let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null);
let kind = $derived(event.kind); let kind = $derived(event.kind);
@ -31,7 +32,7 @@
<div class="flex flex-col relative mb-2"> <div class="flex flex-col relative mb-2">
{#if !isModal} {#if !isModal}
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'><InlineProfile pubkey={event.pubkey} /></P> <P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P>
<CardActions event={event}></CardActions> <CardActions event={event}></CardActions>
</div> </div>
{/if} {/if}
@ -46,7 +47,7 @@
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if originalAuthor !== null} {#if originalAuthor !== null}
<InlineProfile pubkey={originalAuthor} title={author} /> {@render userBadge(originalAuthor, author)}
{:else} {:else}
{author} {author}
{/if} {/if}
@ -67,7 +68,7 @@
{#if hashtags.length} {#if hashtags.length}
<div class="tags my-2"> <div class="tags my-2">
{#each hashtags as tag} {#each hashtags as tag}
<span class="text-sm">#{tag[1]}</span> <span class="text-sm">#{tag}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -80,7 +81,7 @@
{:else} {:else}
<span>Author:</span> <span>Author:</span>
{/if} {/if}
<InlineProfile pubkey={event.pubkey} /> {@render userBadge(event.pubkey, author)}
</h4> </h4>
</div> </div>

59
src/lib/components/util/InlineProfile.svelte

@ -1,59 +0,0 @@
<script lang='ts'>
import { Avatar } from 'flowbite-svelte';
import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
import { ndkInstance } from '$lib/ndk';
import { userBadge } from '$lib/snippets/UserSnippets.svelte';
let { pubkey, title = null } = $props();
const externalProfileDestination = 'https://njump.me/'
let loading = $state(true);
let anon = $state(false);
let npub = $state('');
let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
let username = $derived(profile?.name);
async function fetchUserData(pubkey: string) {
let user;
user = $ndkInstance
.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub;
user.fetchProfile()
.then(userProfile => {
profile = userProfile;
if (!profile?.name) anon = true;
loading = false;
});
}
// Fetch data when component mounts
$effect(() => {
if (pubkey) {
fetchUserData(pubkey);
}
});
function shortenNpub(long: string|undefined) {
if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4);
}
</script>
{#if loading}
{title ?? '…'}
{:else if anon }
{@render userBadge(npub, username)}
{:else if npub }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'>
<Avatar rounded
class='h-7 w-7 mx-1 cursor-pointer inline bg-transparent'
src={pfp}
alt={username} />
{@render userBadge(npub, username)}
</a>
{:else}
{title ?? pubkey}
{/if}

2
src/lib/components/util/Profile.svelte

@ -5,7 +5,7 @@ import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flow
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = 'https://njump.me/' const externalProfileDestination = './events?id='
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();

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

@ -0,0 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import QRCode from 'qrcode';
export let value: string;
let canvas: HTMLCanvasElement;
async function renderQR() {
if (canvas && value) {
await QRCode.toCanvas(canvas, value, { width: 240 });
}
}
onMount(renderQR);
</script>
<canvas class="qr-code" bind:this={canvas}></canvas>

12
src/lib/consts.ts

@ -1,8 +1,18 @@
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ]; export const zettelKinds = [ 30041, 30818 ];
export const communityRelay = [ 'wss://theforest.nostr1.com' ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; export const fallbackRelays = [
'wss://purplepag.es',
'wss://indexer.coracle.social',
'wss://relay.noswhere.com',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://relay.lumina.rocks',
'wss://nostr.wine',
'wss://nostr.land'
];
export enum FeedType { export enum FeedType {
StandardRelays = 'standard', StandardRelays = 'standard',

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

@ -7,6 +7,7 @@
<script lang="ts"> <script lang="ts">
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Component props // Component props
let { node, selected = false, x, y, onclose } = $props<{ let { node, selected = false, x, y, onclose } = $props<{
@ -30,7 +31,7 @@
*/ */
function getAuthorTag(node: NetworkNode): string { function getAuthorTag(node: NetworkNode): string {
if (node.event) { if (node.event) {
const authorTags = node.event.getMatchingTags("author"); const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) { if (authorTags.length > 0) {
return authorTags[0][1]; return authorTags[0][1];
} }
@ -43,7 +44,7 @@
*/ */
function getSummaryTag(node: NetworkNode): string | null { function getSummaryTag(node: NetworkNode): string | null {
if (node.event) { if (node.event) {
const summaryTags = node.event.getMatchingTags("summary"); const summaryTags = getMatchingTags(node.event, "summary");
if (summaryTags.length > 0) { if (summaryTags.length > 0) {
return summaryTags[0][1]; return summaryTags[0][1];
} }
@ -56,7 +57,7 @@
*/ */
function getDTag(node: NetworkNode): string { function getDTag(node: NetworkNode): string {
if (node.event) { if (node.event) {
const dTags = node.event.getMatchingTags("d"); const dTags = getMatchingTags(node.event, "d");
if (dTags.length > 0) { if (dTags.length > 0) {
return dTags[0][1]; return dTags[0][1];
} }

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

@ -9,6 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Configuration // Configuration
const DEBUG = false; // Set to true to enable debug logging const DEBUG = false; // Set to true to enable debug logging
@ -158,7 +159,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events // Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>(); const referencedIds = new Set<string>();
events.forEach((event) => { events.forEach((event) => {
const aTags = event.getMatchingTags("a"); const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", { debug("Processing a-tags for event", {
eventId: event.id, eventId: event.id,
aTagCount: aTags.length aTagCount: aTags.length
@ -279,8 +280,7 @@ export function processIndexEvent(
if (level >= maxLevel) return; if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index // Extract the sequence of nodes referenced by this index
const sequence = indexEvent const sequence = getMatchingTags(indexEvent, "a")
.getMatchingTags("a")
.map((tag) => extractEventIdFromATag(tag)) .map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null) .filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id)) .map((id) => state.nodeMap.get(id))
@ -321,7 +321,7 @@ export function generateGraph(
rootIndices.forEach((rootIndex) => { rootIndices.forEach((rootIndex) => {
debug("Processing root index", { debug("Processing root index", {
rootId: rootIndex.id, rootId: rootIndex.id,
aTags: rootIndex.getMatchingTags("a").length aTags: getMatchingTags(rootIndex, "a").length
}); });
processIndexEvent(rootIndex, 0, state, maxLevel); processIndexEvent(rootIndex, 0, state, maxLevel);
}); });

6
src/lib/ndk.ts

@ -1,6 +1,6 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { bootstrapRelays, FeedType, loginStorageKey, standardRelays } from './consts'; import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts';
import { feedType } from './stores'; import { feedType } from './stores';
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
@ -199,7 +199,7 @@ export function logout(user: NDKUser): void {
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
bootstraps: readonly string[] = bootstrapRelays fallbacks: readonly string[] = fallbackRelays
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
@ -211,7 +211,7 @@ async function getUserPreferredRelays(
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
}, },
NDKRelaySet.fromRelayUrls(bootstraps, ndk), NDKRelaySet.fromRelayUrls(fallbacks, ndk),
); );
const inboxRelays = new Set<NDKRelay>(); const inboxRelays = new Set<NDKRelay>();

60
src/lib/parser.ts

@ -13,6 +13,7 @@ import type {
import he from 'he'; import he from 'he';
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts'; import { zettelKinds } from './consts.ts';
import { getMatchingTags } from '$lib/utils/nostrUtils';
interface IndexMetadata { interface IndexMetadata {
authors?: string[]; authors?: string[];
@ -152,6 +153,10 @@ export default class Pharos {
} }
parse(content: string, options?: ProcessorOptions | undefined): void { parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content);
try { try {
this.html = this.asciidoctor.convert(content, { this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions, 'extension_registry': this.pharosExtensions,
@ -624,7 +629,7 @@ export default class Pharos {
let content: string = ''; let content: string = '';
// Format title into AsciiDoc header. // Format title into AsciiDoc header.
const title = event.getMatchingTags('title')[0][1]; const title = getMatchingTags(event, 'title')[0][1];
let titleLevel = ''; let titleLevel = '';
for (let i = 0; i <= depth; i++) { for (let i = 0; i <= depth; i++) {
titleLevel += '='; titleLevel += '=';
@ -632,9 +637,9 @@ export default class Pharos {
content += `${titleLevel} ${title}\n\n`; content += `${titleLevel} ${title}\n\n`;
// TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62. // TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62.
let tags = event.getMatchingTags('a'); let tags = getMatchingTags(event, 'a');
if (tags.length === 0) { if (tags.length === 0) {
tags = event.getMatchingTags('e'); tags = getMatchingTags(event, 'e');
} }
// Base case: The event is a zettel. // Base case: The event is a zettel.
@ -649,10 +654,10 @@ export default class Pharos {
); );
// if a blog, save complete events for later // if a blog, save complete events for later
if (event.getMatchingTags("type").length > 0 && event.getMatchingTags("type")[0][1] === 'blog') { if (getMatchingTags(event, 'type').length > 0 && getMatchingTags(event, 'type')[0][1] === 'blog') {
childEvents.forEach(child => { childEvents.forEach(child => {
if (child) { if (child) {
this.blogEntries.set(child?.getMatchingTags("d")?.[0]?.[1], child); this.blogEntries.set(getMatchingTags(child, 'd')?.[0]?.[1], child);
} }
}) })
} }
@ -661,8 +666,8 @@ export default class Pharos {
if (event.created_at) { if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString(); this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString();
} }
if (event.getMatchingTags('image').length > 0) { if (getMatchingTags(event, 'image').length > 0) {
this.rootIndexMetadata.coverImage = event.getMatchingTags('image')[0][1]; this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1];
} }
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching // Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
@ -1119,3 +1124,44 @@ export const tocUpdate = writable(0);
// Whenever you update the publication tree, call: // Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1); tocUpdate.update(n => n + 1);
function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/);
let headerIndex = -1;
let hasDoctype = false;
// Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
if (lines[i].trim().startsWith('=')) {
headerIndex = i;
console.debug('[Pharos] AsciiDoc document header:', lines[i].trim());
break;
} else {
throw new Error('AsciiDoc document is missing a header at the top.');
}
}
if (headerIndex === -1) {
throw new Error('AsciiDoc document is missing a header.');
}
// Check for doctype in the next non-empty line after header
let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') {
nextLine++;
}
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) {
hasDoctype = true;
}
// Insert doctype immediately after header if not present
if (!hasDoctype) {
lines.splice(headerIndex + 1, 0, ':doctype: book');
}
// Log the state of the lines before returning
console.debug('[Pharos] AsciiDoc lines after header/doctype normalization:', lines.slice(0, 5));
return lines.join('\n');
}

20
src/lib/snippets/UserSnippets.svelte

@ -1,15 +1,19 @@
<script module lang='ts'> <script module lang='ts'>
import { createProfileLink, createProfileLinkWithVerification } from '$lib/utils/nostrUtils'; import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils';
export { userBadge }; export { userBadge };
</script> </script>
{#snippet userBadge(identifier: string, displayText: string | undefined)} {#snippet userBadge(identifier: string, displayText: string | undefined)}
{#await createProfileLinkWithVerification(identifier, displayText)} {#if toNpub(identifier)}
{@html createProfileLink(identifier, displayText)} {#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)}
{:then html} {@html createProfileLink(toNpub(identifier) as string, displayText)}
{@html html} {:then html}
{:catch} {@html html}
{@html createProfileLink(identifier, displayText)} {:catch}
{/await} {@html createProfileLink(toNpub(identifier) as string, displayText)}
{/await}
{:else}
{displayText ?? ''}
{/if}
{/snippet} {/snippet}

4
src/lib/stores/relayStore.ts

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
// Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]);

41
src/lib/utils.ts

@ -1,5 +1,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils";
export function neventEncode(event: NDKEvent, relays: string[]) { export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({ return nip19.neventEncode({
@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
} }
export function naddrEncode(event: NDKEvent, relays: string[]) { export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = event.getMatchingTags('d')[0]?.[1]; const dTag = getMatchingTags(event, 'd')[0]?.[1];
if (!dTag) { if (!dTag) {
throw new Error('Event does not have a d tag'); throw new Error('Event does not have a d tag');
} }
@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
}); });
} }
export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays });
}
export function formatDate(unixtimestamp: number) { export function formatDate(unixtimestamp: number) {
const months = [ const months = [
"Jan", "Jan",
@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// Index events have no content, and they must have `title`, `d`, and `e` tags. // Index events have no content, and they must have `title`, `d`, and `e` tags.
if ( if (
(event.content != null && event.content.length > 0) (event.content != null && event.content.length > 0)
|| event.getMatchingTags('title').length === 0 || getMatchingTags(event, 'title').length === 0
|| event.getMatchingTags('d').length === 0 || getMatchingTags(event, 'd').length === 0
|| ( || (
event.getMatchingTags('a').length === 0 getMatchingTags(event, 'a').length === 0
&& event.getMatchingTags('e').length === 0 && getMatchingTags(event, 'e').length === 0
) )
) { ) {
events.delete(event); events.delete(event);
@ -158,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>(
): Promise<number> { ): Promise<number> {
return findIndexAsync(this, predicate); return findIndexAsync(this, predicate);
}; };
/**
* Creates a debounced function that delays invoking func until after wait milliseconds have elapsed
* since the last time the debounced function was invoked.
* @param func The function to debounce
* @param wait The number of milliseconds to delay
* @returns A debounced version of the function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = undefined;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

2
src/lib/utils/markup/MarkupInfo.md

@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **Tables:** Pipe-delimited tables with or without headers - **Tables:** Pipe-delimited tables with or without headers
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers - **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended - **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.) - **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](./wiki?d=nip-54)
## Publications and Wikis ## Publications and Wikis

2
src/lib/utils/markup/basicMarkupParser.ts

@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string {
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./publication?d=${normalized}`; const url = `./wiki?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
}); });

2
src/lib/utils/mime.ts

@ -6,7 +6,7 @@
* - Addressable: 30000-39999 (latest per d-tag stored) * - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays) * - Regular: all other kinds (stored by relays)
*/ */
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
// Check special ranges first // Check special ranges first
if (kind >= 30000 && kind < 40000) { if (kind >= 30000 && kind < 40000) {
return 'addressable'; return 'addressable';

275
src/lib/utils/nostrUtils.ts

@ -2,7 +2,13 @@ import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache'; import { npubCache } from './npubCache';
import NDK, { NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>' const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'
@ -12,6 +18,17 @@ const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" ari
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
/** /**
* HTML escape a string * HTML escape a string
*/ */
@ -29,7 +46,7 @@ function escapeHtml(text: string): string {
/** /**
* Get user metadata for a nostr identifier (npub or nprofile) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { export async function getUserMetadata(identifier: string): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, '');
@ -63,30 +80,22 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
return fallback; return fallback;
} }
const user = ndk.getUser({ pubkey: pubkey }); const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
if (!user) { const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
npubCache.set(cleanId, fallback);
return fallback; const metadata: NostrProfile = {
} name: profile?.name || fallback.name,
displayName: profile?.displayName,
try { nip05: profile?.nip05,
const profile = await user.fetchProfile(); picture: profile?.image,
if (!profile) { about: profile?.about,
npubCache.set(cleanId, fallback); banner: profile?.banner,
return fallback; website: profile?.website,
} lud16: profile?.lud16
};
const metadata = {
name: profile.name || fallback.name, npubCache.set(cleanId, metadata);
displayName: profile.displayName return metadata;
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
} catch (e) { } catch (e) {
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
@ -102,7 +111,7 @@ export function createProfileLink(identifier: string, displayText: string | unde
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
return `<a href="https://njump.me/${escapedId}" class="npub-badge" target="_blank">@${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="npub-badge" target="_blank">@${escapedText}</a>`;
} }
/** /**
@ -125,7 +134,19 @@ export async function createProfileLinkWithVerification(identifier: string, disp
user = ndk.getUser({ pubkey: cleanId }); user = ndk.getUser({ pubkey: cleanId });
} }
const profile = await user.fetchProfile(); const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url);
const allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
relaySet
);
const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null;
const nip05 = profile?.nip05; const nip05 = profile?.nip05;
if (!nip05) { if (!nip05) {
@ -146,9 +167,9 @@ export async function createProfileLinkWithVerification(identifier: string, disp
const type = nip05.endsWith('edu') ? 'edu' : 'standard'; const type = nip05.endsWith('edu') ? 'edu' : 'standard';
switch (type) { switch (type) {
case 'edu': case 'edu':
return `<span class="npub-badge"><a href="https://njump.me/${escapedId}" target="_blank">@${displayIdentifier}</a>${graduationCapSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case 'standard': case 'standard':
return `<span class="npub-badge"><a href="https://njump.me/${escapedId}" target="_blank">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}" target="_blank">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
} }
} }
/** /**
@ -160,7 +181,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId); const escapedText = escapeHtml(shortId);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
} }
/** /**
@ -231,3 +252,195 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
return null; return null;
} }
} }
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
* 1. Method style: promise.withTimeout(5000)
* 2. Function style: withTimeout(promise, 5000)
*
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
* @returns The promise result if completed before timeout, otherwise throws an error
* @throws Error with message 'Timeout' if the promise doesn't resolve within timeoutMs
*/
export function withTimeout<T>(
thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T>
): Promise<T> {
// Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') {
const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Handle function-style call (withTimeout(promise, 5000))
const promise = thisOrPromise;
const timeoutMs = timeoutMsOrPromise as number;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
}
// Add the method to Promise prototype
declare global {
interface Promise<T> {
withTimeout(timeoutMs: number): Promise<T>;
}
}
Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number): Promise<T> {
return withTimeout(timeoutMs, this);
};
/**
* Fetches an event using a two-step relay strategy:
* 1. First tries standard relays with timeout
* 2. Falls back to all relays if not found
* Always wraps result as NDKEvent
*/
export async function fetchEventWithFallback(
ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000
): Promise<NDKEvent | null> {
// Get user relays if logged in
const userRelays = ndk.activeUser ?
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url) :
[];
// Create three relay sets in priority order
const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
];
try {
let found: NDKEvent | null = null;
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
} else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
}
}
// Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? 'standard relays' :
index === 1 ? 'user relays' :
'fallback relays';
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
}
if (!found) {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => {
const setName = i === 0 ? 'standard relays' :
i === 1 ? 'user relays' :
'fallback relays';
const urls = Array.from(set.relays).map(r => r.url);
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null;
}).filter(Boolean).join(', then ');
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`);
return null;
}
// Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) {
console.error('Error in fetchEventWithFallback:', err);
return null;
}
}
/**
* Converts a hex pubkey to npub, or returns npub if already encoded.
*/
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
if (/^[a-f0-9]{64}$/i.test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith('npub1')) return pubkey;
return null;
} catch {
return null;
}
}
export type { NDKEvent, NDKRelaySet, NDKUser };
export { NDKRelaySetFromNDK };
export { nip19 };
export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
}
export function createNDKEvent(ndk: NDK, rawEvent: any) {
return new NDKEvent(ndk, rawEvent);
}
/**
* Returns all tags from the event that match the given tag name.
* @param event The NDKEvent object.
* @param tagName The tag name to match (e.g., 'a', 'd', 'title').
* @returns An array of matching tags.
*/
export function getMatchingTags(event: NDKEvent, tagName: string): string[][] {
return event.tags.filter((tag: string[]) => tag[0] === tagName);
}
export function getEventHash(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): string {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
return bytesToHex(sha256(serialized));
}
export async function signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): Promise<string> {
const id = getEventHash(event);
const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig);
}

4
src/lib/utils/npubCache.ts

@ -1,4 +1,6 @@
export type NpubMetadata = { name?: string; displayName?: string }; import type { NostrProfile } from './nostrUtils';
export type NpubMetadata = NostrProfile;
class NpubCache { class NpubCache {
private cache: Record<string, NpubMetadata> = {}; private cache: Record<string, NpubMetadata> = {};

29
src/routes/+page.svelte

@ -1,6 +1,6 @@
<script lang='ts'> <script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts'; import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio } from "flowbite-svelte"; import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from '$lib/components/PublicationFeed.svelte';
@ -20,6 +20,8 @@
return ''; return '';
} }
}; };
let searchQuery = $state('');
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> <Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'>
@ -31,13 +33,22 @@
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn} {#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else} {:else}
<div class='leather w-full flex justify-end'> <div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button> <Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' /> {`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
<ChevronDownOutline class='w-6 h-6' />
</Button> </Button>
<Dropdown class='w-fit p-2 space-y-2 text-sm'> <Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
<Dropdown
class='w-fit p-2 space-y-2 text-sm'
triggeredBy="#feed-toggle-btn"
>
<li> <li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> <Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li> </li>
@ -47,9 +58,9 @@
</Dropdown> </Dropdown>
</div> </div>
{#if $feedType === FeedType.StandardRelays} {#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays} {:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} /> <PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if} {/if}
{/if} {/if}
</main> </main>

1
src/routes/[...catchall]/+page.svelte

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Button, P } from 'flowbite-svelte'; import { Button, P } from 'flowbite-svelte';
</script> </script>

8
src/routes/about/+page.svelte

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte"; import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables // Get the git tag version from environment variables
@ -20,15 +20,15 @@
> >
{/if} {/if}
</div> </div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" /> <Img src="./screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="mb-3"> <P class="mb-3">
Alexandria is a reader and writer for <A Alexandria is a reader and writer for <A
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1" href="./publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A >curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form > (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (markup). It is produced by the <A articles (markup). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1" href="./publication?d=gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A >GitCitadel project team</A
>. >.
</P> </P>

8
src/routes/contact/+page.svelte

@ -290,7 +290,7 @@
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P> </P>
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off"> <form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div> <div>
<Label for="subject" class="mb-2">Subject</Label> <Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus /> <Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
@ -306,7 +306,7 @@
<button <button
type="button" type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'write'} onclick={() => activeTab = 'write'}
role="tab" role="tab"
> >
Write Write
@ -316,7 +316,7 @@
<button <button
type="button" type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'preview'} onclick={() => activeTab = 'preview'}
role="tab" role="tab"
> >
Preview Preview
@ -417,7 +417,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- Close button --> <!-- Close button -->
<button <button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100" class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage} onclick={closeSuccessMessage}
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

79
src/routes/events/+page.svelte

@ -0,0 +1,79 @@
<script lang="ts">
import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte';
let loading = $state(false);
let error = $state<string | null>(null);
let searchValue = $state<string | null>(null);
let event = $state<NDKEvent | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null>(null);
let userPubkey = $state<string | null>(null);
let userRelayPreference = $state(false);
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
} catch {
profile = null;
}
} else {
profile = null;
}
}
onMount(async () => {
const id = $page.url.searchParams.get('id');
if (id) {
searchValue = id;
}
// Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
});
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
</div>
<P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID).
</P>
<EventSearch {loading} {error} {searchValue} {event} onEventFound={handleEventFound} />
{#if event}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
{/if}
</main>
</div>

3
src/routes/publication/+page.ts

@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk'; import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
/** /**
* Decodes an naddr identifier and returns a filter object * Decodes an naddr identifier and returns a filter object
@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id) ? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!); : await fetchEventByDTag(ndk, dTag!);
const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1]; const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent); const fetchPromise = parser.fetch(indexEvent);
return { return {

3
src/routes/visualize/+page.svelte

@ -11,9 +11,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils"; import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state"; import { networkFetchLimit } from "$lib/state";
import { CogSolid } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import Settings from "$lib/navigator/EventNetwork/Settings.svelte";
// Configuration // Configuration
const DEBUG = false; // Set to true to enable debug logging const DEBUG = false; // Set to true to enable debug logging

5
src/styles/events.css

@ -0,0 +1,5 @@
@layer components {
canvas.qr-code {
@apply block mx-auto my-4;
}
}

2
test_data/AsciidocFiles/21lessons.adoc

@ -1372,7 +1372,7 @@ Thanks to the countless authors and content producers who influenced my thinking
Last but not least, thanks to all the bitcoin maximalists, shitcoin minimalists, shills, bots, and shitposters which reside in the beautiful garden that is Bitcoin twitter. And finally, thank _you_ for reading this. I hope you enjoyed it as much as I did enjoy writing it. Last but not least, thanks to all the bitcoin maximalists, shitcoin minimalists, shills, bots, and shitposters which reside in the beautiful garden that is Bitcoin twitter. And finally, thank _you_ for reading this. I hope you enjoyed it as much as I did enjoy writing it.
Feel free to reach out to me https://twitter.com/dergigi[on X] or https://njump.me/npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc[on Nostr] nostr:npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc. My DMs are open. Feel free to reach out to me https://twitter.com/dergigi[on X] or https://next-alexandria.gitcitadel.eu/events?id=npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc[on Nostr] nostr:npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc. My DMs are open.
== Thank You == Thank You

2
test_data/AsciidocFiles/Rauhnaechte.adoc

@ -157,7 +157,7 @@ Namesake: *Silvester*
The seventh Rauhnacht, associated with the month of July and the namesake _Silvester_, carries the theme of _Preparation for What’s to Come._ The seventh Rauhnacht, associated with the month of July and the namesake _Silvester_, carries the theme of _Preparation for What’s to Come._
It represents the gateway — the transition from a past phase into a new one. Since the introduction of the Gregorian calendar, this transition has been celebrated by many on December 31st, a day dedicated to the Roman bishop Silvester. His death commemorates the end of Christian persecution and the establishment of Christianity as a state religion. (For spiritual insights on Christ consciousness, I recommend https://njump.me/naddr1qvzqqqr4gupzp35mw8w9vn7ux59vmhle98e96usz4s28pjr53psgh4ke3epxhfmrqyvhwumn8ghj7un9d3shjtnndehhyapwwdhkx6tpdshszythwden5te0dehhxarj9emkjmn99uqp2d3ctaynzefcvex5vamxvf04sunzfu6nqdgtaz38t[this article I recently shared]). It represents the gateway — the transition from a past phase into a new one. Since the introduction of the Gregorian calendar, this transition has been celebrated by many on December 31st, a day dedicated to the Roman bishop Silvester. His death commemorates the end of Christian persecution and the establishment of Christianity as a state religion. (For spiritual insights on Christ consciousness, I recommend https://next-alexandria.gitcitadel.eu/events?id=naddr1qvzqqqr4gupzp35mw8w9vn7ux59vmhle98e96usz4s28pjr53psgh4ke3epxhfmrqyvhwumn8ghj7un9d3shjtnndehhyapwwdhkx6tpdshszythwden5te0dehhxarj9emkjmn99uqp2d3ctaynzefcvex5vamxvf04sunzfu6nqdgtaz38t[this article I recently shared]).
Every transition holds the opportunity to change, reshape, and perceive life with fresh eyes. Use this day to prepare for the new year: Every transition holds the opportunity to change, reshape, and perceive life with fresh eyes. Use this day to prepare for the new year:

8
tests/integration/markupIntegration.test.ts

@ -33,8 +33,8 @@ describe('Markup Integration Test', () => {
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags // Hashtags
expect(output).toContain('text-primary-600'); expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links) // Nostr identifiers (should be Alexandria links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks // Wikilinks
expect(output).toContain('wikilink'); expect(output).toContain('wikilink');
// YouTube iframe // YouTube iframe
@ -76,8 +76,8 @@ describe('Markup Integration Test', () => {
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags // Hashtags
expect(output).toContain('text-primary-600'); expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links) // Nostr identifiers (should be Alexandria links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks // Wikilinks
expect(output).toContain('wikilink'); expect(output).toContain('wikilink');
// YouTube iframe // YouTube iframe

8
tests/integration/markupTestfile.md

@ -111,16 +111,16 @@ 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. 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: You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw https://next-alexandria.gitcitadel.eu/events?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags: But not if they have d-tags:
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1 https://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c). 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 And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf 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 You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or

2
tests/unit/advancedMarkupParser.test.ts

@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => {
it('parses nostr identifiers', async () => { it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseAdvancedmarkup(input); const output = await parseAdvancedmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
}); });
it('parses emoji shortcodes', async () => { it('parses emoji shortcodes', async () => {

2
tests/unit/basicMarkupParser.test.ts

@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => {
it('parses nostr identifiers', async () => { it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseBasicmarkup(input); const output = await parseBasicmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
}); });
it('parses emoji shortcodes', async () => { it('parses emoji shortcodes', async () => {

5
vite.config.ts

@ -26,6 +26,11 @@ export default defineConfig({
$components: './src/components' $components: './src/components'
} }
}, },
build: {
rollupOptions: {
external: ['bech32']
}
},
test: { test: {
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts'] include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts']
}, },

Loading…
Cancel
Save