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. 2061
      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. 209
      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. 12
      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. 267
      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. 6
      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 @@ -8,7 +8,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
# tests
/tests/e2e/html-report/*.html

16
.vscode/settings.json vendored

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

8
README.md

@ -5,11 +5,13 @@ @@ -5,11 +5,13 @@
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).
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
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
@ -73,7 +75,7 @@ To run the container, in detached mode (-d): @@ -73,7 +75,7 @@ To run the container, in detached mode (-d):
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:
@ -118,4 +120,4 @@ npx playwright test @@ -118,4 +120,4 @@ npx playwright test
## 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).

2061
package-lock.json generated

File diff suppressed because it is too large Load Diff

5
package.json

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

1
src/app.css

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

317
src/lib/components/CommentBox.svelte

@ -0,0 +1,317 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -1,13 +1,10 @@
<script lang='ts'>
import { type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { activePubkey, loginWithExtension, logout, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover, Tooltip } from 'flowbite-svelte';
import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover } from 'flowbite-svelte';
import Profile from "$components/util/Profile.svelte";
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 signInFailed = $state<boolean>(false);

5
src/lib/components/Navigation.svelte

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

7
src/lib/components/Preview.svelte

@ -4,7 +4,8 @@ @@ -4,7 +4,8 @@
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.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.
@ -101,14 +102,14 @@ @@ -101,14 +102,14 @@
function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries);
const event = blogEntries[index][1];
const author = event ? event.getMatchingTags("author")[0][1] : '';
const author = event ? getMatchingTags(event, 'author')[0][1] : '';
return author ?? "";
}
function hasCoverImage(rootId: string, index: number) {
console.log(rootId);
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 ?? '';
}

2
src/lib/components/Publication.svelte

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte";
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 TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser';

209
src/lib/components/PublicationFeed.svelte

@ -1,29 +1,115 @@ @@ -1,29 +1,115 @@
<script lang='ts'>
import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { filterValidIndexEvents, debounce } from '$lib/utils';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.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 loadingMore: 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(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
);
async function getEvents(
before: number | undefined = undefined,
): Promise<void> {
let eventSet = await $ndkInstance.fetchEvents(
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
console.debug('[PublicationFeed] Search query changed:', query);
if (query.trim()) {
console.debug('[PublicationFeed] Clearing events and searching with query:', query);
eventsInView = [];
await getEvents(undefined, query, true);
} else {
console.debug('[PublicationFeed] Clearing events and resetting search');
eventsInView = [];
await getEvents(undefined, '', true);
}
}, 300);
$effect(() => {
console.debug('[PublicationFeed] Search query effect triggered:', searchQuery);
debouncedSearch(searchQuery);
});
async function getEvents(before: number | undefined = undefined, search: string = '', reset: boolean = false) {
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
console.debug('[getEvents] Called with before:', before, 'search:', search);
// Function to filter events based on search query
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;
};
// First, try primary relays
let foundEventsInPrimary = false;
await Promise.all(
primaryRelays.map(async (relay: string) => {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 16,
limit: 30,
until: before,
},
{
@ -31,37 +117,86 @@ @@ -31,37 +117,86 @@
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(relays, $ndkInstance)
);
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
let eventArray = Array.from(eventSet);
eventArray?.sort((a, b) => b.created_at! - a.created_at!);
if (!eventArray) {
return;
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' };
}
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id;
if (endOfFeed) {
return;
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' };
}
})
);
const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event]));
const allEvents = Array.from(eventMap.values());
const uniqueIds = new Set(allEvents.map(event => event.id));
eventsInView = Array.from(uniqueIds)
.map(id => eventMap.get(id))
.filter(event => event != null) as NDKEvent[];
// 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 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 skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`);
@ -71,7 +206,7 @@ @@ -71,7 +206,7 @@
async function loadMorePublications() {
loadingMore = true;
await getEvents(cutoffTimestamp);
await getEvents(cutoffTimestamp, searchQuery, false);
loadingMore = false;
}
@ -80,8 +215,9 @@ @@ -80,8 +215,9 @@
});
</script>
<div class='leather flex flex-col space-y-4'>
{#if eventsInView.length === 0}
<div class='leather'>
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
@ -90,11 +226,14 @@ @@ -90,11 +226,14 @@
<ArticleHeader {event} />
{/each}
{:else}
<div class='col-span-full'>
<p class='text-center'>No publications found.</p>
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<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();
}}>
Show more publications
@ -102,7 +241,7 @@ @@ -102,7 +241,7 @@
</div>
{:else if loadingMore}
<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' />
Loading...
</Button>

7
src/lib/components/PublicationHeader.svelte

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

3
src/lib/components/PublicationSection.svelte

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

190
src/lib/components/RelayActions.svelte

@ -0,0 +1,190 @@ @@ -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 @@ @@ -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 @@ @@ -2,7 +2,7 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { scale } from 'svelte/transition';
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 { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte";
@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
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 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 authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let hashtags: string = $derived(event.getMatchingTags('t') ?? null);
@ -38,7 +38,7 @@ @@ -38,7 +38,7 @@
<div class='space-y-4'>
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
<InlineProfile pubkey={authorPubkey} title={author} />
{@render userBadge(authorPubkey, author)}
<span class='text-gray-500'>{publishedAt()}</span>
</div>
<CardActions event={event} />

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

@ -0,0 +1,120 @@ @@ -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 @@ @@ -2,12 +2,11 @@
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
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 { onDestroy, onMount } from "svelte";
let {
rootId,
publicationType,
indexEvent
} = $props<{
@ -17,7 +16,7 @@ @@ -17,7 +16,7 @@
}>();
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 isLeaf: boolean = $derived(indexEvent.kind === 30041);
@ -131,7 +130,7 @@ @@ -131,7 +130,7 @@
{/if}
</div>
<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 class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner}

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

@ -1,76 +1,112 @@ @@ -1,76 +1,112 @@
<script lang="ts">
import {
ClipboardCheckOutline,
ClipboardCleanOutline,
CodeOutline,
DotsVerticalOutline,
EyeOutline,
ShareNodesOutline
} from "flowbite-svelte-icons";
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 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 eventIdCopied: boolean = $state(false);
let shareLinkCopied: boolean = $state(false);
let isOpen: 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() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true;
}
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false;
const menu = document.getElementById('dots-' + event.id);
if (menu) menu.blur();
}
function shareNjump() {
const relays: string[] = standardRelays;
try {
const naddr = naddrEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
setTimeout(() => {
shareLinkCopied = false;
}, 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;
/**
* Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string
*/
function getIdentifier(type: 'nevent' | 'naddr'): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays);
console.debug("[CardActions] ${type} identifier for event ${event.id}:", identifier);
return identifier;
}
/**
* Opens the event details modal
*/
function viewDetails() {
console.log('Details');
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
});
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>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
@ -99,41 +135,79 @@ @@ -99,41 +135,79 @@
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={shareNjump}>
{#if shareLinkCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<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>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier('naddr')}
icon={ShareNodesOutline}
/>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</button>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier('nevent')}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
</div>
</div>
</Popover>
{/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 -->
<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>
</div>

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

@ -1,27 +1,44 @@ @@ -1,27 +1,44 @@
<script lang='ts'>
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);
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(copyText);
await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true;
setTimeout(() => {
await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)),
4000
).then(() => {
copied = false;
}, 4000);
}).catch(() => {
// If timeout occurs, still reset the state
copied = false;
});
} catch (err) {
console.error("Failed to copy: ", err);
console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err);
}
}
</script>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}>
<button class='btn-leather w-full text-left' onclick={copyToClipboard}>
{#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied!
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{: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}
</button>

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

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

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

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

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

@ -0,0 +1,17 @@ @@ -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 @@ @@ -1,8 +1,18 @@
export const wikiKind = 30818;
export const indexKind = 30040;
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 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 {
StandardRelays = 'standard',

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

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

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

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

6
src/lib/ndk.ts

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

60
src/lib/parser.ts

@ -13,6 +13,7 @@ import type { @@ -13,6 +13,7 @@ import type {
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts';
import { getMatchingTags } from '$lib/utils/nostrUtils';
interface IndexMetadata {
authors?: string[];
@ -152,6 +153,10 @@ export default class Pharos { @@ -152,6 +153,10 @@ export default class Pharos {
}
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 {
this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
@ -624,7 +629,7 @@ export default class Pharos { @@ -624,7 +629,7 @@ export default class Pharos {
let content: string = '';
// Format title into AsciiDoc header.
const title = event.getMatchingTags('title')[0][1];
const title = getMatchingTags(event, 'title')[0][1];
let titleLevel = '';
for (let i = 0; i <= depth; i++) {
titleLevel += '=';
@ -632,9 +637,9 @@ export default class Pharos { @@ -632,9 +637,9 @@ export default class Pharos {
content += `${titleLevel} ${title}\n\n`;
// 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) {
tags = event.getMatchingTags('e');
tags = getMatchingTags(event, 'e');
}
// Base case: The event is a zettel.
@ -649,10 +654,10 @@ export default class Pharos { @@ -649,10 +654,10 @@ export default class Pharos {
);
// 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 => {
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 { @@ -661,8 +666,8 @@ export default class Pharos {
if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString();
}
if (event.getMatchingTags('image').length > 0) {
this.rootIndexMetadata.coverImage = event.getMatchingTags('image')[0][1];
if (getMatchingTags(event, 'image').length > 0) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1];
}
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
@ -1119,3 +1124,44 @@ export const tocUpdate = writable(0); @@ -1119,3 +1124,44 @@ export const tocUpdate = writable(0);
// Whenever you update the publication tree, call:
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');
}

12
src/lib/snippets/UserSnippets.svelte

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

4
src/lib/stores/relayStore.ts

@ -0,0 +1,4 @@ @@ -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 @@ @@ -1,5 +1,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils";
export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({
@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -11,7 +12,7 @@ export function neventEncode(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) {
throw new Error('Event does not have a d tag');
}
@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) { @@ -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) {
const months = [
"Jan",
@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -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.
if (
(event.content != null && event.content.length > 0)
|| event.getMatchingTags('title').length === 0
|| event.getMatchingTags('d').length === 0
|| getMatchingTags(event, 'title').length === 0
|| getMatchingTags(event, 'd').length === 0
|| (
event.getMatchingTags('a').length === 0
&& event.getMatchingTags('e').length === 0
getMatchingTags(event, 'a').length === 0
&& getMatchingTags(event, 'e').length === 0
)
) {
events.delete(event);
@ -158,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>( @@ -158,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>(
): Promise<number> {
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: @@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **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
- **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

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

@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string { @@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string {
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(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
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 @@ @@ -6,7 +6,7 @@
* - Addressable: 30000-39999 (latest per d-tag stored)
* - 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
if (kind >= 30000 && kind < 40000) {
return 'addressable';

267
src/lib/utils/nostrUtils.ts

@ -2,7 +2,13 @@ import { get } from 'svelte/store'; @@ -2,7 +2,13 @@ import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk';
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>'
@ -12,6 +18,17 @@ const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" ari @@ -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_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
*/
@ -29,7 +46,7 @@ function escapeHtml(text: string): string { @@ -29,7 +46,7 @@ function escapeHtml(text: string): string {
/**
* 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
const cleanId = identifier.replace(/^nostr:/, '');
@ -63,22 +80,18 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -63,22 +80,18 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(cleanId, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(cleanId, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName,
nip05: profile?.nip05,
picture: profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16
};
npubCache.set(cleanId, metadata);
@ -87,10 +100,6 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -87,10 +100,6 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
npubCache.set(cleanId, fallback);
return fallback;
}
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
}
/**
@ -102,7 +111,7 @@ export function createProfileLink(identifier: string, displayText: string | unde @@ -102,7 +111,7 @@ export function createProfileLink(identifier: string, displayText: string | unde
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
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 @@ -125,7 +134,19 @@ export async function createProfileLinkWithVerification(identifier: string, disp
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;
if (!nip05) {
@ -146,9 +167,9 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -146,9 +167,9 @@ export async function createProfileLinkWithVerification(identifier: string, disp
const type = nip05.endsWith('edu') ? 'edu' : 'standard';
switch (type) {
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':
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 { @@ -160,7 +181,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId);
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> { @@ -231,3 +252,195 @@ export async function getNpubFromNip05(nip05: string): Promise<string | 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 @@ @@ -1,4 +1,6 @@
export type NpubMetadata = { name?: string; displayName?: string };
import type { NostrProfile } from './nostrUtils';
export type NpubMetadata = NostrProfile;
class NpubCache {
private cache: Record<string, NpubMetadata> = {};

29
src/routes/+page.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio } from "flowbite-svelte";
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
@ -20,6 +20,8 @@ @@ -20,6 +20,8 @@
return '';
}
};
let searchQuery = $state('');
</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'>
@ -31,13 +33,22 @@ @@ -31,13 +33,22 @@
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else}
<div class='leather w-full flex justify-end'>
<Button>
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' />
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
<ChevronDownOutline class='w-6 h-6' />
</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>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li>
@ -47,9 +58,9 @@ @@ -47,9 +58,9 @@
</Dropdown>
</div>
{#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} />
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if}
{/if}
</main>

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

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

6
src/routes/about/+page.svelte

@ -20,15 +20,15 @@ @@ -20,15 +20,15 @@
>
{/if}
</div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" />
<Img src="./screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="mb-3">
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
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
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
>.
</P>

8
src/routes/contact/+page.svelte

@ -290,7 +290,7 @@ @@ -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.
</P>
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off">
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div>
<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 />
@ -306,7 +306,7 @@ @@ -306,7 +306,7 @@
<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'}"
on:click={() => activeTab = 'write'}
onclick={() => activeTab = 'write'}
role="tab"
>
Write
@ -316,7 +316,7 @@ @@ -316,7 +316,7 @@
<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'}"
on:click={() => activeTab = 'preview'}
onclick={() => activeTab = 'preview'}
role="tab"
>
Preview
@ -417,7 +417,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -417,7 +417,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- Close button -->
<button
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"
>
<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 @@ @@ -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'; @@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
/**
* Decodes an naddr identifier and returns a filter object
@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1];
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {

3
src/routes/visualize/+page.svelte

@ -11,9 +11,6 @@ @@ -11,9 +11,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils";
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
const DEBUG = false; // Set to true to enable debug logging

5
src/styles/events.css

@ -0,0 +1,5 @@ @@ -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 @@ -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.
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

2
test_data/AsciidocFiles/Rauhnaechte.adoc

@ -157,7 +157,7 @@ Namesake: *Silvester* @@ -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._
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:

8
tests/integration/markupIntegration.test.ts

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

8
tests/integration/markupTestfile.md

@ -111,16 +111,16 @@ https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg @@ -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.
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:
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
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

2
tests/unit/advancedMarkupParser.test.ts

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

2
tests/unit/basicMarkupParser.test.ts

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

5
vite.config.ts

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

Loading…
Cancel
Save