Browse Source

Merges pull request #44

Feature/text entry
master
silberengel 8 months ago
parent
commit
434fa12c65
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 3
      .vscode/settings.json
  2. 736
      deno.lock
  3. 2
      import_map.json
  4. 1902
      package-lock.json
  5. 18
      package.json
  6. 48
      src/app.css
  7. 279
      src/lib/components/CommentBox.svelte
  8. 350
      src/lib/components/EventDetails.svelte
  9. 352
      src/lib/components/EventInput.svelte
  10. 787
      src/lib/components/EventSearch.svelte
  11. 78
      src/lib/components/Login.svelte
  12. 13
      src/lib/components/LoginModal.svelte
  13. 8
      src/lib/components/Navigation.svelte
  14. 59
      src/lib/components/NetworkStatus.svelte
  15. 1
      src/lib/components/Preview.svelte
  16. 93
      src/lib/components/PublicationHeader.svelte
  17. 10
      src/lib/components/RelayActions.svelte
  18. 11
      src/lib/components/RelayDisplay.svelte
  19. 25
      src/lib/components/RelayStatus.svelte
  20. 28
      src/lib/components/Toc.svelte
  21. 180
      src/lib/components/ZettelEditor.svelte
  22. 44
      src/lib/components/cards/BlogHeader.svelte
  23. 64
      src/lib/components/cards/ProfileHeader.svelte
  24. 92
      src/lib/components/publications/Publication.svelte
  25. 260
      src/lib/components/publications/PublicationFeed.svelte
  26. 90
      src/lib/components/publications/PublicationHeader.svelte
  27. 14
      src/lib/components/publications/PublicationSection.svelte
  28. 182
      src/lib/components/publications/TableOfContents.svelte
  29. 111
      src/lib/components/publications/svelte_publication_tree.svelte.ts
  30. 297
      src/lib/components/publications/table_of_contents.svelte.ts
  31. 37
      src/lib/components/util/ArticleNav.svelte
  32. 88
      src/lib/components/util/CardActions.svelte
  33. 115
      src/lib/components/util/ContainingIndexes.svelte
  34. 61
      src/lib/components/util/Details.svelte
  35. 90
      src/lib/components/util/LazyImage.svelte
  36. 652
      src/lib/components/util/Profile.svelte
  37. 150
      src/lib/components/util/TocToggle.svelte
  38. 22
      src/lib/components/util/ViewPublicationLink.svelte
  39. 55
      src/lib/consts.ts
  40. 281
      src/lib/data_structures/publication_tree.ts
  41. 34
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  42. 408
      src/lib/ndk.ts
  43. 37
      src/lib/parser.ts
  44. 115
      src/lib/services/publisher.ts
  45. 87
      src/lib/snippets/UserSnippets.svelte
  46. 23
      src/lib/stores.ts
  47. 4
      src/lib/stores/authStore.Svelte.ts
  48. 55
      src/lib/stores/networkStore.ts
  49. 4
      src/lib/stores/relayStore.ts
  50. 436
      src/lib/stores/userStore.ts
  51. 116
      src/lib/utils/ZettelParser.ts
  52. 119
      src/lib/utils/community_checker.ts
  53. 252
      src/lib/utils/event_input_utils.ts
  54. 115
      src/lib/utils/event_search.ts
  55. 31
      src/lib/utils/image_utils.ts
  56. 38
      src/lib/utils/indexEventCache.ts
  57. 10
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  58. 39
      src/lib/utils/markup/advancedMarkupParser.ts
  59. 13
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  60. 6
      src/lib/utils/markup/basicMarkupParser.ts
  61. 19
      src/lib/utils/mime.ts
  62. 189
      src/lib/utils/network_detection.ts
  63. 354
      src/lib/utils/nostrEventService.ts
  64. 313
      src/lib/utils/nostrUtils.ts
  65. 466
      src/lib/utils/profile_search.ts
  66. 64
      src/lib/utils/relayDiagnostics.ts
  67. 531
      src/lib/utils/relay_management.ts
  68. 16
      src/lib/utils/searchCache.ts
  69. 49
      src/lib/utils/search_constants.ts
  70. 8
      src/lib/utils/search_types.ts
  71. 32
      src/lib/utils/search_utility.ts
  72. 37
      src/lib/utils/search_utils.ts
  73. 704
      src/lib/utils/subscription_search.ts
  74. 4
      src/routes/+layout.svelte
  75. 116
      src/routes/+layout.ts
  76. 98
      src/routes/+page.svelte
  77. 9
      src/routes/about/+page.svelte
  78. 26
      src/routes/contact/+page.svelte
  79. 422
      src/routes/events/+page.svelte
  80. 117
      src/routes/new/compose/+page.svelte
  81. 97
      src/routes/publication/+page.svelte
  82. 11
      src/routes/publication/+page.ts
  83. 38
      src/routes/start/+page.svelte
  84. 13
      src/routes/visualize/+page.svelte
  85. 53
      src/styles/asciidoc.css
  86. 22
      test_data/LaTeXtestfile.json
  87. 21
      test_data/LaTeXtestfile.md
  88. 56
      tests/unit/latexRendering.test.ts

3
.vscode/settings.json vendored

@ -10,5 +10,6 @@ @@ -10,5 +10,6 @@
},
"files.associations": {
"*.svelte": "svelte"
}
},
"editor.tabSize": 2
}

736
deno.lock

File diff suppressed because it is too large Load Diff

2
import_map.json

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
"tailwind-merge": "npm:tailwind-merge@2.5.x",
"svelte": "npm:svelte@5.0.x",
"flowbite": "npm:flowbite@2.2.x",
"flowbite-svelte": "npm:flowbite-svelte@0.44.x",
"flowbite-svelte": "npm:flowbite-svelte@0.48.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x",
"child_process": "node:child_process"
}

1902
package-lock.json generated

File diff suppressed because it is too large Load Diff

18
package.json

@ -14,8 +14,8 @@ @@ -14,8 +14,8 @@
"test": "vitest"
},
"dependencies": {
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.6.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x",
@ -25,17 +25,17 @@ @@ -25,17 +25,17 @@
"he": "1.2.x",
"highlight.js": "^11.11.1",
"node-emoji": "^2.2.0",
"nostr-tools": "2.10.x",
"nostr-tools": "2.15.x",
"plantuml-encoder": "^1.4.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "4.x",
"@sveltejs/kit": "^2.25.0",
"@sveltejs/vite-plugin-svelte": "5.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/node": "22.x",
@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
"autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x",
"flowbite": "2.x",
"flowbite-svelte": "0.x",
"flowbite-svelte": "0.48.x",
"flowbite-svelte-icons": "2.1.x",
"playwright": "^1.50.1",
"postcss": "8.x",
@ -55,8 +55,8 @@ @@ -55,8 +55,8 @@
"tailwind-merge": "^3.3.0",
"tailwindcss": "3.x",
"tslib": "2.8.x",
"typescript": "5.7.x",
"vite": "5.x",
"typescript": "5.8.x",
"vite": "6.x",
"vitest": "^3.1.3"
}
}

48
src/app.css

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
@import "./styles/publications.css";
@import "./styles/visualize.css";
@import "./styles/events.css";
@import "./styles/asciidoc.css";
/* Custom styles */
@layer base {
@ -154,24 +155,14 @@ @@ -154,24 +155,14 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
/* Sidebar */
aside.sidebar-leather {
@apply fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10;
@apply bg-primary-0 dark:bg-primary-1000 px-5 w-full sm:w-auto sm:max-w-xl;
}
aside.sidebar-leather > div {
@apply bg-primary-50 dark:bg-gray-800 h-full px-5 py-0;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-gray-800;
}
div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
}
div.skeleton-leather {
@apply h-48;
}
div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000;
}
@ -259,7 +250,7 @@ @@ -259,7 +250,7 @@
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72;
@apply max-h-40;
}
.tags span {
@ -297,6 +288,8 @@ @@ -297,6 +288,8 @@
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
scroll-margin-top: 150px;
scroll-behavior: smooth;
h1,
h2,
@ -450,6 +443,21 @@ @@ -450,6 +443,21 @@
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
}
/* Section scroll behavior */
section[id] {
scroll-margin-top: 150px;
}
/* Ensure section headers maintain their padding */
section[id] h1,
section[id] h2,
section[id] h3,
section[id] h4,
section[id] h5,
section[id] h6 {
@apply pt-4;
}
.description-textarea {
min-height: 100% !important;
}
@ -495,4 +503,14 @@ @@ -495,4 +503,14 @@
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-600 dark:focus:border-primary-400;
}
/* Table of Contents highlighting */
.toc-highlight {
@apply bg-primary-200 dark:bg-primary-700 border-l-4 border-primary-600 dark:border-primary-400 font-medium;
transition: all 0.2s ease-in-out;
}
.toc-highlight:hover {
@apply bg-primary-300 dark:bg-primary-600;
}
}

279
src/lib/components/CommentBox.svelte

@ -4,9 +4,12 @@ @@ -4,9 +4,12 @@
import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_utility";
import type {
NostrProfile,
ProfileSearchResult,
} from "$lib/utils/search_utility";
import { activePubkey } from '$lib/ndk';
import { userPubkey } from "$lib/stores/authStore.Svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
extractRootEventInfo,
@ -16,14 +19,9 @@ @@ -16,14 +19,9 @@
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { NDKRelay } from '@nostr-dev-kit/ndk';
import { communityRelay } from '$lib/consts';
import { tick } from 'svelte';
import { tick } from "svelte";
import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
const props = $props<{
event: NDKEvent;
@ -36,17 +34,17 @@ @@ -36,17 +34,17 @@
let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null);
let showOtherRelays = $state(false);
let showFallbackRelays = $state(false);
let showSecondaryRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
// Add state for modals and search
let showMentionModal = $state(false);
let showWikilinkModal = $state(false);
let mentionSearch = $state('');
let mentionSearch = $state("");
let mentionResults = $state<NostrProfile[]>([]);
let mentionLoading = $state(false);
let wikilinkTarget = $state('');
let wikilinkLabel = $state('');
let wikilinkTarget = $state("");
let wikilinkLabel = $state("");
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let mentionSearchInput: HTMLInputElement | undefined;
@ -54,7 +52,7 @@ @@ -54,7 +52,7 @@
$effect(() => {
if (showMentionModal) {
// Reset search when modal opens
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
// Focus the search input after a brief delay to ensure modal is rendered
@ -63,23 +61,23 @@ @@ -63,23 +61,23 @@
}, 100);
} else {
// Reset search when modal closes
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
}
});
$effect(() => {
const trimmedPubkey = $activePubkey?.trim();
const trimmedPubkey = $userPubkey?.trim();
const npub = toNpub(trimmedPubkey);
if (npub) {
// Call an async function, but don't make the effect itself async
getUserMetadata(npub).then(metadata => {
getUserMetadata(npub).then((metadata) => {
userProfile = metadata;
});
} else if (trimmedPubkey) {
userProfile = null;
error = 'Invalid public key: must be a 64-character hex string.';
error = "Invalid public key: must be a 64-character hex string.";
} else {
userProfile = null;
error = null;
@ -88,11 +86,10 @@ @@ -88,11 +86,10 @@
$effect(() => {
if (!success) return;
content = '';
preview = '';
}
);
content = "";
preview = "";
});
// Markup buttons
const markupButtons = [
@ -105,8 +102,20 @@ @@ -105,8 +102,20 @@
{ label: "List", action: () => insertMarkup("* ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") },
{ label: '@', action: () => { mentionSearch = ''; mentionResults = []; showMentionModal = true; } },
{ label: 'Wikilink', action: () => { showWikilinkModal = true; } },
{
label: "@",
action: () => {
mentionSearch = "";
mentionResults = [];
showMentionModal = true;
},
},
{
label: "Wikilink",
action: () => {
showWikilinkModal = true;
},
},
];
function insertMarkup(prefix: string, suffix: string) {
@ -142,7 +151,7 @@ @@ -142,7 +151,7 @@
preview = "";
error = null;
showOtherRelays = false;
showFallbackRelays = false;
showSecondaryRelays = false;
}
function removeFormatting() {
@ -161,22 +170,24 @@ @@ -161,22 +170,24 @@
async function handleSubmit(
useOtherRelays = false,
useFallbackRelays = false,
useSecondaryRelays = false,
) {
isSubmitting = true;
error = null;
success = null;
try {
const pk = $activePubkey || '';
const pk = $userPubkey || "";
const npub = toNpub(pk);
if (!npub) {
throw new Error('Invalid public key: must be a 64-character hex string.');
throw new Error(
"Invalid public key: must be a 64-character hex string.",
);
}
if (props.event.kind === undefined || props.event.kind === null) {
throw new Error('Invalid event: missing kind');
throw new Error("Invalid event: missing kind");
}
const parent = props.event;
@ -191,34 +202,37 @@ @@ -191,34 +202,37 @@
const tags = buildReplyTags(parent, rootInfo, parentInfo, kind);
// Create and sign the event
const { event: signedEvent } = await createSignedEvent(content, pk, kind, tags);
// Publish the event
const result = await publishEvent(
signedEvent,
useOtherRelays,
useFallbackRelays,
props.userRelayPreference
const { event: signedEvent } = await createSignedEvent(
content,
pk,
kind,
tags,
);
if (result.success) {
success = { relay: result.relay!, eventId: result.eventId! };
// Navigate to the published event
navigateToEvent(result.eventId!);
} else {
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 comment. Please try again later.";
}
// Publish the event using the new relay system
let relays = $activeOutboxRelays;
if (useOtherRelays && !useSecondaryRelays) {
relays = [...$activeOutboxRelays, ...$activeInboxRelays];
} else if (useSecondaryRelays) {
// For secondary relays, use a subset of outbox relays
relays = $activeOutboxRelays.slice(0, 3); // Use first 3 outbox relays
}
} catch (e: unknown) {
console.error('Error publishing comment:', e);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
const successfulRelays = await publishEvent(signedEvent, relays);
success = {
relay: successfulRelays[0] || "Unknown relay",
eventId: signedEvent.id,
};
// Clear form after successful submission
content = "";
preview = "";
showOtherRelays = false;
showSecondaryRelays = false;
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error occurred";
} finally {
isSubmitting = false;
}
@ -226,8 +240,8 @@ @@ -226,8 +240,8 @@
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
if (!npub) return "";
return npub.slice(0, 8) + "…" + npub.slice(-4);
}
async function insertAtCursor(text: string) {
@ -239,10 +253,10 @@ @@ -239,10 +253,10 @@
content = content.substring(0, start) + text + content.substring(end);
updatePreview();
// Wait for DOM updates to complete
await tick();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}
@ -250,39 +264,62 @@ @@ -250,39 +264,62 @@
// Add mention search functionality using centralized search utility
let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false);
async function searchMentions() {
if (!mentionSearch.trim()) {
mentionResults = [];
communityStatus = {};
return;
}
// Prevent multiple concurrent searches
if (isSearching) {
return;
}
console.log("Starting search for:", mentionSearch.trim());
// Set loading state
mentionLoading = true;
isSearching = true;
try {
console.log("Search promise created, waiting for result...");
const result = await searchProfiles(mentionSearch.trim());
console.log("Search completed, found profiles:", result.profiles.length);
console.log("Profile details:", result.profiles);
console.log("Community status:", result.Status);
// Update state
mentionResults = result.profiles;
communityStatus = result.Status;
console.log(
"State updated - mentionResults length:",
mentionResults.length,
);
console.log(
"State updated - communityStatus keys:",
Object.keys(communityStatus),
);
} catch (error) {
console.error('Error searching mentions:', error);
console.error("Error searching mentions:", error);
mentionResults = [];
communityStatus = {};
} finally {
mentionLoading = false;
isSearching = false;
console.log(
"Search finished - loading:",
mentionLoading,
"searching:",
isSearching,
);
}
}
function selectMention(profile: NostrProfile) {
let mention = '';
let mention = "";
if (profile.pubkey) {
try {
const npub = toNpub(profile.pubkey);
@ -293,22 +330,22 @@ @@ -293,22 +330,22 @@
mention = `nostr:${profile.pubkey}`;
}
} catch (e) {
console.error('Error in toNpub:', e);
console.error("Error in toNpub:", e);
// Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`;
}
} else {
console.warn('No pubkey in profile, falling back to display name');
console.warn("No pubkey in profile, falling back to display name");
mention = `@${profile.displayName || profile.name}`;
}
insertAtCursor(mention);
showMentionModal = false;
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
}
function insertWikilink() {
let markup = '';
let markup = "";
if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
} else {
@ -316,8 +353,8 @@ @@ -316,8 +353,8 @@
}
insertAtCursor(markup);
showWikilinkModal = false;
wikilinkTarget = '';
wikilinkLabel = '';
wikilinkTarget = "";
wikilinkLabel = "";
}
function handleViewComment() {
@ -356,15 +393,15 @@ @@ -356,15 +393,15 @@
bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) {
if (e.key === "Enter" && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5"
/>
<Button
size="xs"
color="primary"
<Button
size="xs"
color="primary"
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
@ -379,27 +416,51 @@ @@ -379,27 +416,51 @@
{/if}
</Button>
</div>
{#if mentionLoading}
<div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0}
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="text-center py-2 text-xs text-gray-500">
Found {mentionResults.length} results
</div>
<div
class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"
>
<ul class="space-y-1 p-2">
{#each mentionResults as profile}
<button type="button" class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" onclick={() => selectMention(profile)}>
<button
type="button"
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)}
>
{#if profile.pubkey && communityStatus[profile.pubkey]}
<div class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
{#if profile.picture}
<img src={profile.picture} alt="Profile" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"></div>
<div
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"
></div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
@ -407,11 +468,24 @@ @@ -407,11 +468,24 @@
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg class="inline w-4 h-4 text-primary-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
>
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono truncate">{shortenNpub(profile.pubkey)}</span>
<span class="text-xs text-gray-400 font-mono truncate"
>{shortenNpub(profile.pubkey)}</span
>
</div>
</button>
{/each}
@ -420,7 +494,9 @@ @@ -420,7 +494,9 @@
{:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else}
<div class="text-center py-4 text-gray-500">Enter a search term to find users</div>
<div class="text-center py-4 text-gray-500">
Enter a search term to find users
</div>
{/if}
</div>
</Modal>
@ -447,8 +523,15 @@ @@ -447,8 +523,15 @@
class="mb-4"
/>
<div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button>
<Button size="xs" color="alternative" on:click={() => { showWikilinkModal = false; }}>Cancel</Button>
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button
>
<Button
size="xs"
color="alternative"
on:click={() => {
showWikilinkModal = false;
}}>Cancel</Button
>
</div>
</Modal>
@ -462,7 +545,9 @@ @@ -462,7 +545,9 @@
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg">
<div
class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg"
>
{@html preview}
</div>
</div>
@ -475,7 +560,7 @@ @@ -475,7 +560,7 @@
>Try Other Relays</Button
>
{/if}
{#if showFallbackRelays}
{#if showSecondaryRelays}
<Button
size="xs"
class="mt-2"
@ -487,7 +572,7 @@ @@ -487,7 +572,7 @@
{#if success}
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!<br/>
Comment published successfully to {success.relay}!<br />
Event ID: <span class="font-mono">{success.eventId}</span>
<button
onclick={handleViewComment}
@ -515,16 +600,16 @@ @@ -515,16 +600,16 @@
<span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName ||
userProfile.name ||
nip19.npubEncode($activePubkey || '').slice(0, 8) + "..."}
nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
</span>
</div>
{/if}
<Button
on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$activePubkey}
disabled={isSubmitting || !content.trim() || !$userPubkey}
class="w-full md:w-auto"
>
{#if !$activePubkey}
{#if !$userPubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
@ -534,7 +619,7 @@ @@ -534,7 +619,7 @@
</Button>
</div>
{#if !$activePubkey}
{#if !$userPubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your
current account.

350
src/lib/components/EventDetails.svelte

@ -4,13 +4,16 @@ @@ -4,13 +4,16 @@
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 { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { goto } from "$app/navigation";
import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
const {
event,
@ -42,7 +45,7 @@ @@ -42,7 +45,7 @@
if (titleTag) {
return titleTag;
}
// For kind 30023 events, extract title from markdown content if no title tag
if (event.kind === 30023 && event.content) {
const match = event.content.match(/^#\s+(.+)$/m);
@ -50,22 +53,25 @@ @@ -50,22 +53,25 @@
return match[1].trim();
}
}
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag
if ((event.kind === 30040 || event.kind === 30041 || event.kind === 30818) && event.content) {
if (
(event.kind === 30040 || event.kind === 30041 || event.kind === 30818) &&
event.content
) {
// First try to find a document header (= )
const docMatch = event.content.match(/^=\s+(.+)$/m);
if (docMatch) {
return docMatch[1].trim();
}
// If no document header, try to find the first section header (== )
const sectionMatch = event.content.match(/^==\s+(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
}
}
return "Untitled";
}
@ -82,59 +88,218 @@ @@ -82,59 +88,218 @@
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
}
function renderTag(tag: string[]): string {
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode naddr for a tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn("Invalid pubkey in a tag in renderTag:", pubkey);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn("Invalid a tag format in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode nevent for e tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else {
console.warn("Invalid event ID in e tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode nevent for note tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else {
console.warn("Invalid event ID in note tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${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>`;
}
}
function getTagButtonInfo(tag: string[]): {
text: string;
gotoValue?: string;
} {
if (tag[0] === "a" && tag.length > 1) {
// Parse the a-tag: kind:pubkey:d
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
try {
const naddr = naddrEncode(
{
kind: parseInt(kind),
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any,
standardRelays,
);
console.log("Converted a-tag to naddr:", tag[1], "->", naddr);
return { text: `a:${tag[1]}`, gotoValue: naddr };
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return {
text: `a:${tag[1]}`,
gotoValue: naddr,
};
} catch (error) {
console.warn("Failed to encode naddr for a tag:", tag[1], error);
return { text: `a:${tag[1]}` };
}
} else {
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
}
} else {
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
}
} else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return {
text: `e:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.error("Error encoding a-tag to naddr:", error);
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` };
}
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
}
if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode(
{
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any,
standardRelays,
);
return { text: `e:${tag[1]}`, gotoValue: nevent };
} else if (tag[0] === "p" && tag.length > 1) {
const npub = toNpub(tag[1]);
return {
text: `p:${npub || tag[1]}`,
gotoValue: npub ? npub : undefined,
};
} else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return {
text: `note:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for note tag:", tag[1], error);
return { text: `note:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in note tag:", tag[1]);
return { text: `note:${tag[1]}` };
}
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return {
text: `d:${tag[1]}`,
gotoValue: `d:${tag[1]}`,
};
} else if (tag[0] === "t" && tag.length > 1) {
// 't' tags are hashtags - navigate to t-tag search
return {
text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}`,
};
}
return { text: "" };
return { text: `${tag[0]}:${tag[1]}` };
}
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function navigateToEvent(gotoValue: string) {
console.log("Navigating to event:", gotoValue);
// Add a small delay to ensure the current search state is cleared
setTimeout(() => {
goto(`/events?id=${encodeURIComponent(gotoValue)}`);
}, 10);
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function getNprofileUrl(pubkey: string): string {
return nprofileEncode(pubkey, $activeInboxRelays);
}
$effect(() => {
@ -147,17 +312,17 @@ @@ -147,17 +312,17 @@
});
$effect(() => {
if(!event?.pubkey) {
if (!event?.pubkey) {
authorDisplayName = undefined;
return;
}
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
});
// --- Identifier helpers ---
@ -176,14 +341,14 @@ @@ -176,14 +341,14 @@
// nprofile
ids.push({
label: "nprofile",
value: nprofileEncode(event.pubkey, standardRelays),
link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}`,
value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
});
// nevent
ids.push({
label: "nevent",
value: neventEncode(event, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// hex pubkey
ids.push({ label: "pubkey", value: event.pubkey });
@ -191,12 +356,12 @@ @@ -191,12 +356,12 @@
// nevent
ids.push({
label: "nevent",
value: neventEncode(event, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// naddr (if addressable)
try {
const naddr = naddrEncode(event, standardRelays);
const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {}
// hex id
@ -211,6 +376,21 @@ @@ -211,6 +376,21 @@
const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue);
}
onMount(() => {
function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.tagName === "A") {
const href = (target as HTMLAnchorElement).getAttribute("href");
if (href && href.startsWith("/")) {
event.preventDefault();
goto(href);
}
}
}
document.addEventListener("click", handleInternalLinkClick);
return () => document.removeEventListener("click", handleInternalLinkClick);
});
</script>
<div class="flex flex-col space-y-4">
@ -222,16 +402,16 @@ @@ -222,16 +402,16 @@
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<span class="text-gray-700 dark:text-gray-300">
Author: {@render userBadge(
<span class="text-gray-600 dark:text-gray-400"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
authorDisplayName,
)}
</span>
profile?.display_name || event.pubkey,
)}</span
>
{:else}
<span class="text-gray-700 dark:text-gray-300">
Author: {authorDisplayName}
</span>
<span class="text-gray-600 dark:text-gray-400"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
@ -255,15 +435,19 @@ @@ -255,15 +435,19 @@
<span class="text-gray-700 dark:text-gray-300">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-800 text-sm font-medium"
>#{tag}</span
<button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium hover:bg-primary-200 cursor-pointer"
>#{tag}</button
>
{/each}
</div>
</div>
{/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
<!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0}
@ -298,8 +482,36 @@ @@ -298,8 +482,36 @@
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() =>
navigateToEvent(tagInfo.gotoValue!)}
onclick={() => {
// Handle different types of gotoValue
if (
tagInfo.gotoValue!.startsWith("naddr") ||
tagInfo.gotoValue!.startsWith("nevent") ||
tagInfo.gotoValue!.startsWith("npub") ||
tagInfo.gotoValue!.startsWith("nprofile") ||
tagInfo.gotoValue!.startsWith("note")
) {
// For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith("/")) {
// For relative URLs - navigate directly
goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith("d:")) {
// For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith("t:")) {
// For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) {
// For hex event IDs - use navigateToEvent
navigateToEvent(tagInfo.gotoValue!);
} else {
// For other cases, try direct navigation
goto(`/events?id=${tagInfo.gotoValue!}`);
}
}}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
>
{tagInfo.text}

352
src/lib/components/EventInput.svelte

@ -1,30 +1,44 @@ @@ -1,30 +1,44 @@
<script lang='ts'>
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { userPubkey } from '$lib/stores/authStore.Svelte';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts';
<script lang="ts">
import {
getTitleTagForEvent,
getDTagForEvent,
requiresDTag,
hasDTag,
validateNotAsciidoc,
validateAsciiDoc,
build30040EventSet,
titleToDTag,
validate30040EventSet,
get30040EventDescription,
analyze30040Event,
get30040FixGuidance,
} from "$lib/utils/event_input_utils";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { Button } from "flowbite-svelte";
import { nip19 } from "nostr-tools";
import { goto } from "$app/navigation";
let kind = $state<number>(30023);
let tags = $state<[string, string][]>([]);
let content = $state('');
let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]);
let title = $state('');
let dTag = $state('');
let title = $state("");
let dTag = $state("");
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state('');
let dTagError = $state("");
let lastPublishedEventId = $state<string | null>(null);
/**
@ -33,14 +47,14 @@ @@ -33,14 +47,14 @@
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : '';
return match ? match[2].trim() : "";
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
console.log('Content input - extracted title:', extracted);
console.log("Content input - extracted title:", extracted);
title = extracted;
}
}
@ -56,19 +70,24 @@ @@ -56,19 +70,24 @@
}
$effect(() => {
console.log('Effect running - title:', title, 'dTagManuallyEdited:', dTagManuallyEdited);
console.log(
"Effect running - title:",
title,
"dTagManuallyEdited:",
dTagManuallyEdited,
);
if (!dTagManuallyEdited) {
const newDTag = titleToDTag(title);
console.log('Setting dTag to:', newDTag);
console.log("Setting dTag to:", newDTag);
dTag = newDTag;
}
});
function updateTag(index: number, key: string, value: string): void {
tags = tags.map((t, i) => i === index ? [key, value] : t);
tags = tags.map((t, i) => (i === index ? [key, value] : t));
}
function addTag(): void {
tags = [...tags, ['', '']];
tags = [...tags, ["", ""]];
}
function removeTag(index: number): void {
tags = tags.filter((_, i) => i !== index);
@ -81,9 +100,13 @@ @@ -81,9 +100,13 @@
function validate(): { valid: boolean; reason?: string } {
const currentUserPubkey = get(userPubkey as any);
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' };
const pubkey = String(currentUserPubkey);
if (!content.trim()) return { valid: false, reason: 'Content required.' };
const userState = get(userStore);
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!pubkey) return { valid: false, reason: "Not logged in." };
if (!content.trim()) return { valid: false, reason: "Content required." };
if (kind === 30023) {
const v = validateNotAsciidoc(content);
if (!v.valid) return v;
@ -101,9 +124,9 @@ @@ -101,9 +124,9 @@
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = '';
if (requiresDTag(kind) && (!dTag || dTag.trim() === '')) {
dTagError = 'A d-tag is required.';
dTagError = "";
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required.";
return;
}
handlePublish();
@ -115,19 +138,23 @@ @@ -115,19 +138,23 @@
publishedRelays = [];
loading = true;
createdAt = Math.floor(Date.now() / 1000);
try {
const ndk = get(ndkInstance);
const currentUserPubkey = get(userPubkey as any);
if (!ndk || !currentUserPubkey) {
error = 'NDK or pubkey missing.';
const userState = get(userStore);
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!ndk || !pubkey) {
error = "NDK or pubkey missing.";
loading = false;
return;
}
const pubkey = String(currentUserPubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
error = 'Invalid public key: must be a 64-character hex string.';
const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) {
error = "Invalid public key: must be a 64-character hex string.";
loading = false;
return;
}
@ -135,132 +162,150 @@ @@ -135,132 +162,150 @@
// Validate before proceeding
const validation = validate();
if (!validation.valid) {
error = validation.reason || 'Validation failed.';
error = validation.reason || "Validation failed.";
loading = false;
return;
}
const baseEvent = { pubkey, created_at: createdAt };
const baseEvent = { pubkey: pubkeyString, created_at: createdAt };
let events: NDKEvent[] = [];
console.log('Publishing event with kind:', kind);
console.log('Content length:', content.length);
console.log('Content preview:', content.substring(0, 100));
console.log('Tags:', tags);
console.log('Title:', title);
console.log('DTag:', dTag);
console.log("Publishing event with kind:", kind);
console.log("Content length:", content.length);
console.log("Content preview:", content.substring(0, 100));
console.log("Tags:", tags);
console.log("Title:", title);
console.log("DTag:", dTag);
if (Number(kind) === 30040) {
console.log('=== 30040 EVENT CREATION START ===');
console.log('Creating 30040 event set with content:', content);
console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", content);
try {
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
console.log('Index event:', indexEvent);
console.log('Section events:', sectionEvents);
const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log('Total events to publish:', events.length);
console.log("Total events to publish:", events.length);
// Debug the index event to ensure it's correct
const indexEventData = {
content: indexEvent.content,
tags: indexEvent.tags.map(tag => [tag[0], tag[1]] as [string, string]),
kind: indexEvent.kind || 30040
tags: indexEvent.tags.map(
(tag) => [tag[0], tag[1]] as [string, string],
),
kind: indexEvent.kind || 30040,
};
const analysis = debug30040Event(indexEventData);
if (!analysis.valid) {
console.warn('30040 index event has issues:', analysis.issues);
console.warn("30040 index event has issues:", analysis.issues);
}
console.log('=== 30040 EVENT CREATION END ===');
console.log("=== 30040 EVENT CREATION END ===");
} catch (error) {
console.error('Error in build30040EventSet:', error);
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : 'Unknown error'}`;
console.error("Error in build30040EventSet:", error);
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`;
loading = false;
return;
}
} else {
let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events
if (requiresDTag(kind)) {
const dTagIndex = eventTags.findIndex(([k]) => k === 'd');
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, '');
const dTagIndex = eventTags.findIndex(([k]) => k === "d");
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, "");
if (dTagValue) {
if (dTagIndex >= 0) {
// Update existing d-tag
eventTags[dTagIndex] = ['d', dTagValue];
eventTags[dTagIndex] = ["d", dTagValue];
} else {
// Add new d-tag
eventTags = [...eventTags, ['d', dTagValue]];
eventTags = [...eventTags, ["d", dTagValue]];
}
}
}
// Add title tag if we have a title
const titleValue = title.trim() || getTitleTagForEvent(kind, content);
if (titleValue) {
eventTags = [...eventTags, ['title', titleValue]];
eventTags = [...eventTags, ["title", titleValue]];
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// Create event with proper serialization
const eventData = {
kind,
content: prefixedContent,
tags: eventTags,
pubkey,
pubkey: pubkeyString,
created_at: createdAt,
};
events = [new NDKEventClass(ndk, eventData)];
}
let atLeastOne = false;
let relaysPublished: string[] = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
try {
console.log('Publishing event:', {
console.log("Publishing event:", {
kind: event.kind,
content: event.content,
tags: event.tags,
hasContent: event.content && event.content.length > 0
hasContent: event.content && event.content.length > 0,
});
// Always sign with a plain object if window.nostr is available
// Create a completely plain object to avoid proxy cloning issues
const plainEvent = {
kind: Number(event.kind),
pubkey: String(event.pubkey),
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)),
tags: event.tags.map(tag => [String(tag[0]), String(tag[1])]),
created_at: Number(
event.created_at ?? Math.floor(Date.now() / 1000),
),
tags: event.tags.map((tag) => [String(tag[0]), String(tag[1])]),
content: String(event.content),
};
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ('id' in signed) {
if ("id" in signed) {
event.id = signed.id as string;
}
} else {
await event.sign();
}
// Use direct WebSocket publishing like CommentBox does
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Try to publish to relays directly
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ...standardRelays];
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...$activeOutboxRelays,
...$activeInboxRelays,
];
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
@ -301,7 +346,7 @@ @@ -301,7 +346,7 @@
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (published) {
atLeastOne = true;
// For 30040, set lastPublishedEventId to the index event (last in array)
@ -314,23 +359,23 @@ @@ -314,23 +359,23 @@
}
}
} catch (signError) {
console.error('Error signing/publishing event:', signError);
error = `Failed to sign event: ${signError instanceof Error ? signError.message : 'Unknown error'}`;
console.error("Error signing/publishing event:", signError);
error = `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`;
loading = false;
return;
}
}
loading = false;
if (atLeastOne) {
publishedRelays = relaysPublished;
success = `Published to ${relaysPublished.length} relay(s).`;
} else {
error = 'Failed to publish to any relay.';
error = "Failed to publish to any relay.";
}
} catch (err) {
console.error('Error in handlePublish:', err);
error = `Publishing failed: ${err instanceof Error ? err.message : 'Unknown error'}`;
console.error("Error in handlePublish:", err);
error = `Publishing failed: ${err instanceof Error ? err.message : "Unknown error"}`;
loading = false;
}
}
@ -338,11 +383,15 @@ @@ -338,11 +383,15 @@
/**
* Debug function to analyze a 30040 event and provide guidance.
*/
function debug30040Event(eventData: { content: string; tags: [string, string][]; kind: number }) {
function debug30040Event(eventData: {
content: string;
tags: [string, string][];
kind: number;
}) {
const analysis = analyze30040Event(eventData);
console.log('30040 Event Analysis:', analysis);
console.log("30040 Event Analysis:", analysis);
if (!analysis.valid) {
console.log('Guidance:', get30040FixGuidance());
console.log("Guidance:", get30040FixGuidance());
}
return analysis;
}
@ -354,95 +403,138 @@ @@ -354,95 +403,138 @@
}
</script>
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'>
<h2 class='text-xl font-bold mb-4'>Publish Nostr Event</h2>
<form class='space-y-4' onsubmit={handleSubmit}>
<div
class="w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg"
>
<h2 class="text-xl font-bold mb-4">Publish Nostr Event</h2>
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label class='block font-medium mb-1' for='event-kind'>Kind</label>
<input id='event-kind' type='text' class='input input-bordered w-full' bind:value={kind} required />
<label class="block font-medium mb-1" for="event-kind">Kind</label>
<input
id="event-kind"
type="text"
class="input input-bordered w-full"
bind:value={kind}
required
/>
{#if !isValidKind(kind)}
<div class="text-red-600 text-sm mt-1">
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if kind === 30040}
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded">
<strong>30040 - Publication Index:</strong> {get30040EventDescription()}
<div
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded"
>
<strong>30040 - Publication Index:</strong>
{get30040EventDescription()}
</div>
{/if}
</div>
<div>
<label class='block font-medium mb-1' for='tags-container'>Tags</label>
<div id='tags-container' class='space-y-2'>
<label class="block font-medium mb-1" for="tags-container">Tags</label>
<div id="tags-container" class="space-y-2">
{#each tags as [key, value], i}
<div class='flex gap-2'>
<input type='text' class='input input-bordered flex-1' placeholder='tag' bind:value={tags[i][0]} oninput={e => updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} />
<input type='text' class='input input-bordered flex-1' placeholder='value' bind:value={tags[i][1]} oninput={e => updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} />
<button type='button' class='btn btn-error btn-sm' onclick={() => removeTag(i)} disabled={tags.length === 1}>×</button>
<div class="flex gap-2">
<input
type="text"
class="input input-bordered flex-1"
placeholder="tag"
bind:value={tags[i][0]}
oninput={(e) =>
updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])}
/>
<input
type="text"
class="input input-bordered flex-1"
placeholder="value"
bind:value={tags[i][1]}
oninput={(e) =>
updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-error btn-sm"
onclick={() => removeTag(i)}
disabled={tags.length === 1}</button
>
</div>
{/each}
<div class='flex justify-end'>
<button type='button' class='btn btn-primary btn-sm border border-primary-600 px-3 py-1' onclick={addTag}>Add Tag</button>
<div class="flex justify-end">
<button
type="button"
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1"
onclick={addTag}>Add Tag</button
>
</div>
</div>
</div>
<div>
<label class='block font-medium mb-1' for='event-content'>Content</label>
<label class="block font-medium mb-1" for="event-content">Content</label>
<textarea
id='event-content'
id="event-content"
bind:value={content}
oninput={handleContentInput}
placeholder='Content (start with a header for the title)'
class='textarea textarea-bordered w-full h-40'
placeholder="Content (start with a header for the title)"
class="textarea textarea-bordered w-full h-40"
required
></textarea>
</div>
<div>
<label class='block font-medium mb-1' for='event-title'>Title</label>
<label class="block font-medium mb-1" for="event-title">Title</label>
<input
type='text'
id='event-title'
type="text"
id="event-title"
bind:value={title}
oninput={handleTitleInput}
placeholder='Title (auto-filled from header)'
class='input input-bordered w-full'
placeholder="Title (auto-filled from header)"
class="input input-bordered w-full"
/>
</div>
<div>
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label>
<label class="block font-medium mb-1" for="event-d-tag">d-tag</label>
<input
type='text'
id='event-d-tag'
type="text"
id="event-d-tag"
bind:value={dTag}
oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full'
placeholder="d-tag (auto-generated from title)"
class="input input-bordered w-full"
required={requiresDTag(kind)}
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>
<div class="text-red-600 text-sm mt-1">{dTagError}</div>
{/if}
</div>
<div class='flex justify-end'>
<button type='submit' class='btn btn-primary border border-primary-600 px-4 py-2' disabled={loading}>Publish</button>
<div class="flex justify-end">
<button
type="submit"
class="btn btn-primary border border-primary-600 px-4 py-2"
disabled={loading}>Publish</button
>
</div>
{#if loading}
<span class='ml-2 text-gray-500'>Publishing...</span>
<span class="ml-2 text-gray-500">Publishing...</span>
{/if}
{#if error}
<div class='mt-2 text-red-600'>{error}</div>
<div class="mt-2 text-red-600">{error}</div>
{/if}
{#if success}
<div class='mt-2 text-green-600'>{success}</div>
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div>
<div class="mt-2 text-green-600">{success}</div>
<div class="text-xs text-gray-500">
Relays: {publishedRelays.join(", ")}
</div>
{#if lastPublishedEventId}
<div class='mt-2 text-green-700'>
Event ID: <span class='font-mono'>{lastPublishedEventId}</span>
<Button onclick={viewPublishedEvent} class='text-primary-600 dark:text-primary-500 hover:underline ml-2'>
<div class="mt-2 text-green-700">
Event ID: <span class="font-mono">{lastPublishedEventId}</span>
<Button
onclick={viewPublishedEvent}
class="text-blue-600 dark:text-blue-500 hover:underline ml-2"
>
View your event
</Button>
</div>
{/if}
{/if}
</form>
</div>
</div>

787
src/lib/components/EventSearch.svelte

File diff suppressed because it is too large Load Diff

78
src/lib/components/Login.svelte

@ -1,78 +0,0 @@ @@ -1,78 +0,0 @@
<script lang="ts">
import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
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 npub = $state<string | undefined>(undefined);
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>("");
$effect(() => {
if ($ndkSignedIn) {
$ndkInstance
.getUser({ pubkey: $activePubkey ?? undefined })
?.fetchProfile()
.then((userProfile) => {
profile = userProfile;
});
npub = $ndkInstance.activeUser?.npub;
}
});
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = "";
const user = await loginWithExtension();
if (!user) {
throw new Error("The NIP-07 extension did not return a user.");
}
profile = await user.fetchProfile();
persistLogin(user);
} catch (e) {
console.error(e);
signInFailed = true;
errorMessage =
e instanceof Error ? e.message : "Failed to sign in. Please try again.";
}
}
</script>
<div class="m-4">
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{:else}
<Avatar rounded class="h-6 w-6 cursor-pointer bg-transparent" id="avatar" />
<Popover
class="popover-leather w-fit"
placement="bottom"
triggeredBy="#avatar"
>
<div class="w-full flex flex-col space-y-2">
<Button onclick={handleSignInClick}>Extension Sign-In</Button>
{#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}
</div>
{/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}
>
Bunker Sign-In
</Button> -->
</div>
</Popover>
{/if}
</div>

13
src/lib/components/LoginModal.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from "$lib/ndk";
import { loginWithExtension } from "$lib/stores/userStore";
import { userStore } from "$lib/stores/userStore";
const {
show = false,
@ -14,14 +15,17 @@ @@ -14,14 +15,17 @@
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>("");
let user = $state($userStore);
let modalOpen = $state(show);
userStore.subscribe((val) => (user = val));
$effect(() => {
modalOpen = show;
});
$effect(() => {
if ($ndkSignedIn && show) {
if (user.signedIn && show) {
onLoginSuccess();
onClose();
}
@ -38,10 +42,7 @@ @@ -38,10 +42,7 @@
signInFailed = false;
errorMessage = "";
const user = await loginWithExtension();
if (!user) {
throw new Error("The NIP-07 extension did not return a user.");
}
await loginWithExtension();
} catch (e: unknown) {
console.error(e);
signInFailed = true;

8
src/lib/components/Navigation.svelte

@ -7,9 +7,12 @@ @@ -7,9 +7,12 @@
NavHamburger,
NavBrand,
} from "flowbite-svelte";
import Login from "./Login.svelte";
import Profile from "./util/Profile.svelte";
import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props();
let userState = $derived($userStore);
</script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
@ -19,11 +22,12 @@ @@ -19,11 +22,12 @@
</NavBrand>
</div>
<div class="flex md:order-2">
<Login />
<Profile isNav={true} pubkey={userState.npub || undefined} />
<NavHamburger class="btn-leather" />
</div>
<NavUl class="ul-leather">
<NavLi href="/">Publications</NavLi>
<NavLi href="/new/compose">Compose</NavLi>
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>

59
src/lib/components/NetworkStatus.svelte

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
<script lang="ts">
import { networkCondition, isNetworkChecking, startNetworkStatusMonitoring } from '$lib/stores/networkStore';
import { NetworkCondition } from '$lib/utils/network_detection';
import { onMount } from 'svelte';
function getStatusColor(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return 'text-green-600 dark:text-green-400';
case NetworkCondition.SLOW:
return 'text-yellow-600 dark:text-yellow-400';
case NetworkCondition.OFFLINE:
return 'text-red-600 dark:text-red-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
}
function getStatusIcon(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return '🟢';
case NetworkCondition.SLOW:
return '🟡';
case NetworkCondition.OFFLINE:
return '🔴';
default:
return '⚪';
}
}
function getStatusText(): string {
switch ($networkCondition) {
case NetworkCondition.ONLINE:
return 'Online';
case NetworkCondition.SLOW:
return 'Slow Connection';
case NetworkCondition.OFFLINE:
return 'Offline';
default:
return 'Unknown';
}
}
onMount(() => {
// Start centralized network monitoring
startNetworkStatusMonitoring();
});
</script>
<div class="flex items-center space-x-2 text-xs {getStatusColor()} font-medium">
{#if $isNetworkChecking}
<span class="animate-spin"></span>
<span>Checking...</span>
{:else}
<span class="text-lg">{getStatusIcon()}</span>
<span>{getStatusText()}</span>
{/if}
</div>

1
src/lib/components/Preview.svelte

@ -21,6 +21,7 @@ @@ -21,6 +21,7 @@
} from "$lib/snippets/PublicationSnippets.svelte";
import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { onMount } from "svelte";
// TODO: Fix move between parents.

93
src/lib/components/PublicationHeader.svelte

@ -1,93 +0,0 @@ @@ -1,93 +0,0 @@
<script lang="ts">
import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from "../consts";
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
const { event } = $props<{ event: NDKEvent }>();
const relays = $derived.by(() => {
return $ndkInstance.activeUser?.relayUrls ?? standardRelays;
});
const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
});
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
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,
);
// New: fetch profile display name for authorPubkey
let authorDisplayName = $state<string | undefined>(undefined);
$effect(() => {
if (authorPubkey) {
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
author ||
authorPubkey;
});
} else {
authorDisplayName = undefined;
}
});
</script>
{#if title != null && href != null}
<Card
class="ArticleBox card-leather max-w-md h-48 flex flex-row items-center space-x-2 relative overflow-hidden"
>
{#if image}
<div
class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<Img src={image} class="rounded w-full h-full object-cover" />
</div>
{/if}
<div class="col flex flex-row flex-grow space-x-4">
<div class="flex flex-col flex-grow">
<a href="/{href}" class="flex flex-col space-y-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, authorDisplayName)}
{:else}
{author}
{/if}
</h3>
{#if version != "1"}
<h3
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
version: {version}
</h3>
{/if}
</a>
</div>
<div class="flex flex-col justify-start items-center">
<CardActions {event} />
</div>
</div>
</Card>
{/if}

10
src/lib/components/RelayActions.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import { Button, Modal } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { get } from "svelte/store";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
getConnectedRelays,
getEventRelays,
} from "./RelayDisplay.svelte";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { communityRelays, secondaryRelays } from "$lib/consts";
const { event } = $props<{
event: NDKEvent;
@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(
(r) => r.url,
);
allRelays = [...standardRelays, ...userRelays, ...fallbackRelays].filter(
allRelays = [...$activeInboxRelays, ...$activeOutboxRelays, ...userRelays].filter(
(url, idx, arr) => arr.indexOf(url) === idx,
);
relaySearchResults = Object.fromEntries(
@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk
.fetchEvent({ ids: [event?.id || ""] }, undefined, relaySet)
.withTimeout(3000);
.withTimeout(2000);
relaySearchResults = {
...relaySearchResults,
[relay]: found ? "found" : "notfound",
@ -108,7 +108,7 @@ @@ -108,7 +108,7 @@
size="lg"
>
<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]}
{#each Object.entries( { "Active Inbox Relays": $activeInboxRelays, "Active Outbox Relays": $activeOutboxRelays }, ) as [groupName, groupRelays]}
{#if groupRelays.length > 0}
<div class="flex flex-col gap-2">
<h3

11
src/lib/components/RelayDisplay.svelte

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
<script lang="ts" context="module">
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
import { activeInboxRelays, ndkInstance } from "$lib/ndk";
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays)
// Get relays from event (prefer event.relay or event.relays, fallback to active inbox relays)
export function getEventRelays(event: NDKEvent): string[] {
if (event && (event as any).relay) {
const relay = (event as any).relay;
@ -12,7 +14,8 @@ @@ -12,7 +14,8 @@
typeof r === "string" ? r : r.url,
);
}
return standardRelays;
// Use active inbox relays as fallback
return get(activeInboxRelays);
}
export function getConnectedRelays(): string[] {
@ -24,10 +27,6 @@ @@ -24,10 +27,6 @@
</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;

25
src/lib/components/RelayStatus.svelte

@ -7,11 +7,8 @@ @@ -7,11 +7,8 @@
checkWebSocketSupport,
checkEnvironmentForWebSocketDowngrade,
} from "$lib/ndk";
import { standardRelays, anonymousRelays } from "$lib/consts";
import { onMount } from "svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, outboxRelays } from "$lib/ndk";
import { FeedType } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
interface RelayStatus {
url: string;
@ -24,6 +21,13 @@ @@ -24,6 +21,13 @@
let relayStatuses = $state<RelayStatus[]>([]);
let testing = $state(false);
// Use the new relay management system
let allRelays: string[] = $state([]);
$effect(() => {
allRelays = [...$activeInboxRelays, ...$activeOutboxRelays];
});
async function runRelayTests() {
testing = true;
const ndk = $ndkInstance;
@ -34,16 +38,9 @@ @@ -34,16 +38,9 @@
let relaysToTest: string[] = [];
if ($feedType === FeedType.UserRelays && $ndkSignedIn) {
// Use user's relays (inbox + outbox), deduplicated
const userRelays = new Set([...$inboxRelays, ...$outboxRelays]);
relaysToTest = Array.from(userRelays);
} else {
// Use default relays (standard + anonymous), deduplicated
relaysToTest = Array.from(
new Set([...standardRelays, ...anonymousRelays]),
);
}
// Use active relays from the new relay management system
const userRelays = new Set([...$activeInboxRelays, ...$activeOutboxRelays]);
relaysToTest = Array.from(userRelays);
console.log("[RelayStatus] Relays to test:", relaysToTest);

28
src/lib/components/Toc.svelte

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export let notes: NDKEvent[] = [];
// check if notes is empty
if (notes.length === 0) {
console.debug("notes is empty");
}
</script>
<div class="toc">
<h2>Table of contents</h2>
<ul>
{#each notes as note}
<li>
<a href="#{nip19.noteEncode(note.id)}"
>{note.getMatchingTags("title")[0][1]}</a
>
</li>
{/each}
</ul>
</div>
<style>
.toc h2 {
text-align: center;
}
</style>

180
src/lib/components/ZettelEditor.svelte

@ -0,0 +1,180 @@ @@ -0,0 +1,180 @@
<script lang="ts">
import { Textarea, Button } from "flowbite-svelte";
import { EyeOutline } from "flowbite-svelte-icons";
import {
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import asciidoctor from "asciidoctor";
// Component props
let {
content = "",
placeholder = `== Note Title
:author: {author} // author is optional
:tags: tag1, tag2, tag3 // tags are optional
note content here...
== Note Title 2
:tags: tag1, tag2, tag3
Note content here...
`,
showPreview = false,
onContentChange = (content: string) => {},
onPreviewToggle = (show: boolean) => {},
} = $props<{
content?: string;
placeholder?: string;
showPreview?: boolean;
onContentChange?: (content: string) => void;
onPreviewToggle?: (show: boolean) => void;
}>();
// Initialize AsciiDoctor processor
const asciidoctorProcessor = asciidoctor();
// Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Toggle preview panel
function togglePreview() {
const newShowPreview = !showPreview;
onPreviewToggle(newShowPreview);
}
// Handle content changes
function handleContentChange(event: Event) {
const target = event.target as HTMLTextAreaElement;
onContentChange(target.value);
}
</script>
<div class="flex flex-col space-y-4">
<div class="flex items-center justify-between">
<Button
color="light"
size="sm"
on:click={togglePreview}
class="flex items-center space-x-1"
>
{#if showPreview}
<EyeOutline class="w-4 h-4" />
<span>Hide Preview</span>
{:else}
<EyeOutline class="w-4 h-4" />
<span>Show Preview</span>
{/if}
</Button>
</div>
<div class="flex space-x-4 {showPreview ? 'h-96' : ''}">
<!-- Editor Panel -->
<div class="{showPreview ? 'w-1/2' : 'w-full'} flex flex-col space-y-4">
<div class="flex-1">
<Textarea
bind:value={content}
on:input={handleContentChange}
{placeholder}
class="h-full min-h-64 resize-none"
rows={12}
/>
</div>
</div>
<!-- Preview Panel -->
{#if showPreview}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4">
<h3
class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"
>
AsciiDoc Preview
</h3>
<div
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-h-80 overflow-y-auto"
>
{#if !content.trim()}
<div class="text-gray-500 dark:text-gray-400 text-sm">
Start typing to see the preview...
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{#each parsedSections as section, index}
<div class="mb-6">
<div
class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"
>
{@html asciidoctorProcessor.convert(
`== ${section.title}\n\n${section.content}`,
{
standalone: false,
doctype: "article",
attributes: {
showtitle: true,
sectids: true,
},
},
)}
</div>
{#if index < parsedSections.length - 1}
<!-- Gray area with tag bubbles above event boundary -->
<div class="my-4 relative">
<!-- Gray background area -->
<div
class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0}
{#each section.tags as tag}
<div
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</div>
{/each}
{:else}
<span
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
>
{/if}
</div>
</div>
<!-- Event boundary line -->
<div
class="border-t-2 border-dashed border-blue-400 relative"
>
<div
class="absolute -top-2 left-1/2 transform -translate-x-1/2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs font-medium"
>
Event Boundary
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
<div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"
>
<strong>Event Count:</strong>
{parsedSections.length} event{parsedSections.length !== 1
? "s"
: ""}
<br />
<strong>Note:</strong> Currently only the first event will be published.
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>

44
src/lib/components/cards/BlogHeader.svelte

@ -1,12 +1,14 @@ @@ -1,12 +1,14 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from "svelte/transition";
import { Card, Img } from "flowbite-svelte";
import { Card } from "flowbite-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";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
const {
rootId,
@ -54,23 +56,35 @@ @@ -54,23 +56,35 @@
? 'active'
: ''}"
>
<div class="space-y-4">
<div class="space-y-4 relative">
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
{@render userBadge(authorPubkey, author)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div>
<CardActions {event} />
</div>
{#if image && active}
<div
class="ArticleBoxImage flex col justify-center"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
>
<Img src={image} class="rounded w-full max-h-72 object-cover" />
</div>
{/if}
<div class="flex flex-col flex-grow space-y-4">
<div
class="ArticleBoxImage flex justify-center items-center p-2 h-40 -mt-2"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
>
{#if image}
<LazyImage
src={image}
alt={title || "Publication image"}
eventId={event.id}
className="rounded w-full h-full object-cover"
/>
{:else}
<div
class="rounded w-full h-full"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
{/if}
</div>
<div class="flex flex-col space-y-4">
<button onclick={() => showBlog()} class="text-left">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
</button>
@ -82,9 +96,15 @@ @@ -82,9 +96,15 @@
</div>
{/if}
</div>
{#if active}
<Interactions {rootId} {event} />
{/if}
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</div>
</Card>
{/if}

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

@ -1,11 +1,16 @@ @@ -1,11 +1,16 @@
<script lang="ts">
import { Card, Img, Modal, Button, P } from "flowbite-svelte";
import { Card, 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";
import { lnurlpWellKnownUrl, checkCommunity } from "$lib/utils/search_utility";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import {
lnurlpWellKnownUrl,
checkCommunity,
} from "$lib/utils/search_utility";
// @ts-ignore
import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
@ -41,11 +46,13 @@ @@ -41,11 +46,13 @@
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => {
communityStatus = status;
}).catch(() => {
communityStatus = false;
});
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
});
@ -57,18 +64,22 @@ @@ -57,18 +64,22 @@
{#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
<div class="ArticleBoxImage flex col justify-center">
{#if profile.banner}
<LazyImage
src={profile.banner}
class="rounded w-full max-h-72 object-cover"
alt="Profile banner"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
eventId={event.id}
className="rounded w-full max-h-72 object-cover"
/>
</div>
{/if}
{:else}
<div
class="rounded w-full max-h-72"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
{/if}
</div>
<div class="flex flex-row space-x-4 items-center">
{#if profile.picture}
<img
@ -89,9 +100,18 @@ @@ -89,9 +100,18 @@
event.pubkey,
)}
{#if communityStatus === true}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else if communityStatus === false}
@ -156,12 +176,12 @@ @@ -156,12 +176,12 @@
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
{#if id.link}
<Button
class="text-primary-700 dark:text-primary-200"
<button
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors"
onclick={() => navigateToIdentifier(id.link)}
>
{id.value}
</Button>
</button>
{:else}
{id.value}
{/if}

92
src/lib/components/Publication.svelte → src/lib/components/publications/Publication.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton,
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
@ -15,13 +16,13 @@ @@ -15,13 +16,13 @@
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
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/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from "$lib/parser";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string;
@ -29,16 +30,18 @@ @@ -29,16 +30,18 @@
indexEvent: NDKEvent;
}>();
const publicationTree = getContext("publicationTree") as PublicationTree;
const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading
// TODO: Test load handling.
let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false);
let isDone = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null);
let activeAddress = $state<string | null>(null);
let observer: IntersectionObserver;
@ -82,7 +85,8 @@ @@ -82,7 +85,8 @@
// #endregion
// region Columns visibility
// #region Columns visibility
let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
@ -91,6 +95,10 @@ @@ -91,6 +95,10 @@
return currentBlog !== null && $publicationColumnVisibility.inner;
}
function closeToc() {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
}
@ -119,6 +127,33 @@ @@ -119,6 +127,33 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
// #endregion
/**
* Performs actions on the DOM element for a publication tree leaf when it is mounted.
*
* @param el The DOM element that was mounted.
* @param address The address of the event that was mounted.
*/
function onPublicationSectionMounted(el: HTMLElement, address: string) {
// Update last element ref for the intersection observer.
setLastElementRef(el, leaves.length);
// Michael J - 08 July 2025 - NOTE: Updating the ToC from here somewhat breaks separation of
// concerns, since the TableOfContents component is primarily responsible for working with the
// ToC data structure. However, the Publication component has direct access to the needed DOM
// element already, and I want to avoid complicated callbacks between the two components.
// Update the ToC from the contents of the leaf section.
const entry = toc.getEntry(address);
if (!entry) {
console.warn(`[Publication] No parent found for ${address}`);
return;
}
toc.buildTocFromDocument(el, entry);
}
// #region Lifecycle hooks
onDestroy(() => {
// reset visibility
publicationColumnVisibility.reset();
@ -147,20 +182,42 @@ @@ -147,20 +182,42 @@
},
{ threshold: 0.5 },
);
loadMore(8);
loadMore(12);
return () => {
observer.disconnect();
};
});
// Whenever the publication changes, update rootId
let rootId = $derived($pharosInstance.getRootIndexId());
// #endregion
</script>
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} />
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>
<CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents
{rootAddress}
depth={2}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone) {
loadMore(4);
}
}}
/>
</Sidebar>
{/if}
{/if}
<!-- Default publications -->
@ -179,11 +236,12 @@ @@ -179,11 +236,12 @@
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
{@const address = leaf.tagAddress()}
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
{address}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
{/each}
@ -193,7 +251,7 @@ @@ -193,7 +251,7 @@
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-700 dark:text-gray-300">
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
{/if}
@ -204,9 +262,7 @@ @@ -204,9 +262,7 @@
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1
{isInnerActive() ? 'discreet' : ''}
"
class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
@ -287,7 +343,7 @@ @@ -287,7 +343,7 @@
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-700 dark:text-gray-300">1.1.1970</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to

260
src/lib/components/PublicationFeed.svelte → src/lib/components/publications/PublicationFeed.svelte

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
<script lang="ts">
import { indexKind } from "$lib/consts";
import { ndkInstance } from "$lib/ndk";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/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 { onMount, onDestroy } from "svelte";
import {
getMatchingTags,
NDKRelaySetFromNDK,
@ -13,67 +13,142 @@ @@ -13,67 +13,142 @@
} from "$lib/utils/nostrUtils";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { feedType } from "$lib/stores";
import { isValidNip05Address } from "$lib/utils/search_utility";
let {
relays,
fallbackRelays,
searchQuery = "",
} = $props<{
relays: string[];
fallbackRelays: string[];
const props = $props<{
searchQuery?: string;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>();
// Component state
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>(
{},
);
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>({});
let loading: boolean = $state(true);
let hasInitialized = $state(false);
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null;
// Relay management
let allRelays: string[] = $state([]);
let ndk = $derived($ndkInstance);
// Event management
let allIndexEvents: NDKEvent[] = $state([]);
let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ??
new Date().getTime(),
);
let allIndexEvents: NDKEvent[] = $state([]);
// Initialize relays and fetch events
async function initializeAndFetch() {
if (!ndk) {
console.debug('[PublicationFeed] No NDK instance available');
return;
}
// Get relays from active stores
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays];
console.debug('[PublicationFeed] Available relays:', {
inboxCount: inboxRelays.length,
outboxCount: outboxRelays.length,
totalCount: newRelays.length,
relays: newRelays
});
if (newRelays.length === 0) {
console.debug('[PublicationFeed] No relays available, waiting...');
return;
}
// Update allRelays if different
const currentRelaysString = allRelays.sort().join(',');
const newRelaysString = newRelays.sort().join(',');
if (currentRelaysString !== newRelaysString) {
allRelays = newRelays;
console.debug('[PublicationFeed] Relays updated, fetching events');
await fetchAllIndexEventsFromRelays();
}
}
// Watch for relay store changes
$effect(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
const newRelays = [...inboxRelays, ...outboxRelays];
if (newRelays.length > 0 && !hasInitialized) {
console.debug('[PublicationFeed] Relays available, initializing');
hasInitialized = true;
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
fallbackTimeout = null;
}
setTimeout(() => initializeAndFetch(), 0);
} else if (newRelays.length === 0 && !hasInitialized) {
console.debug('[PublicationFeed] No relays available, setting up fallback');
if (!fallbackTimeout) {
fallbackTimeout = setTimeout(() => {
console.debug('[PublicationFeed] Fallback timeout reached, retrying');
hasInitialized = true;
initializeAndFetch();
}, 3000);
}
}
});
async function fetchAllIndexEventsFromRelays() {
loading = true;
const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter(
(r: string) => !primaryRelays.includes(r),
);
const allRelays = [...primaryRelays, ...fallback];
console.debug('[PublicationFeed] fetchAllIndexEventsFromRelays called with relays:', {
allRelaysCount: allRelays.length,
allRelays: allRelays
});
if (!ndk) {
console.error('[PublicationFeed] No NDK instance available');
loading = false;
return;
}
if (allRelays.length === 0) {
console.debug('[PublicationFeed] No relays available for fetching');
loading = false;
return;
}
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) {
console.log(`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`);
console.log(
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
return;
}
loading = true;
relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
let allEvents: NDKEvent[] = [];
const eventMap = new Map<string, NDKEvent>();
// Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<NDKEvent[]> {
async function fetchFromRelay(relay: string): Promise<void> {
try {
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk
.fetchEvents(
{
kinds: [indexKind],
limit: 1000, // Increased limit to get more events
},
{
groupable: false,
@ -82,36 +157,57 @@ @@ -82,36 +157,57 @@
},
relaySet,
)
.withTimeout(5000);
.withTimeout(5000); // Reduced timeout to 5 seconds for faster response
console.debug(`[PublicationFeed] Raw events from ${relay}:`, eventSet.size);
eventSet = filterValidIndexEvents(eventSet);
console.debug(`[PublicationFeed] Valid events from ${relay}:`, eventSet.size);
relayStatuses = { ...relayStatuses, [relay]: "found" };
return Array.from(eventSet);
// Add new events to the map and update the view immediately
const newEvents: NDKEvent[] = [];
for (const event of eventSet) {
const tagAddress = event.tagAddress();
if (!eventMap.has(tagAddress)) {
eventMap.set(tagAddress, event);
newEvents.push(event);
}
}
if (newEvents.length > 0) {
// Update allIndexEvents with new events
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`);
}
} catch (err) {
console.error(`Error fetching from relay ${relay}:`, err);
console.error(`[PublicationFeed] Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
return [];
}
}
// Fetch from all relays in parallel, do not block on any single relay
const results = await Promise.allSettled(allRelays.map(fetchFromRelay));
for (const result of results) {
if (result.status === "fulfilled") {
allEvents = allEvents.concat(result.value);
}
}
// Deduplicate by tagAddress
const eventMap = new Map(
allEvents.map((event) => [event.tagAddress(), event]),
);
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Fetch from all relays in parallel, return events as they arrive
console.debug(`[PublicationFeed] Starting fetch from ${allRelays.length} relays`);
// Start all relay fetches in parallel
const fetchPromises = allRelays.map(fetchFromRelay);
// Wait for all to complete (but events are shown as they arrive)
await Promise.allSettled(fetchPromises);
console.debug(`[PublicationFeed] All relays completed, final event count:`, allIndexEvents.length);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page
// Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
@ -119,8 +215,8 @@ @@ -119,8 +215,8 @@
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!searchQuery) return events;
const query = searchQuery.toLowerCase();
if (!props.searchQuery) return events;
const query = props.searchQuery.toLowerCase();
console.debug(
"[PublicationFeed] Filtering events with query:",
query,
@ -129,9 +225,11 @@ @@ -129,9 +225,11 @@
);
// Check cache first for publication search
const cachedResult = searchCache.get('publication', query);
const cachedResult = searchCache.get("publication", query);
if (cachedResult) {
console.log(`[PublicationFeed] Using cached results for publication search: ${query}`);
console.log(
`[PublicationFeed] Using cached results for publication search: ${query}`,
);
return cachedResult.events;
}
@ -178,7 +276,7 @@ @@ -178,7 +276,7 @@
}
return matches;
});
// Cache the filtered results
const result = {
events: filtered,
@ -186,11 +284,11 @@ @@ -186,11 +284,11 @@
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'publication',
searchTerm: query
searchType: "publication",
searchTerm: query,
};
searchCache.set('publication', query, result);
searchCache.set("publication", query, result);
console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered;
};
@ -211,15 +309,25 @@ @@ -211,15 +309,25 @@
$effect(() => {
console.debug(
"[PublicationFeed] Search query effect triggered:",
searchQuery,
props.searchQuery,
);
debouncedSearch(searchQuery);
debouncedSearch(props.searchQuery);
});
// Emit event count updates
$effect(() => {
if (props.onEventCountUpdate) {
props.onEventCountUpdate({
displayed: eventsInView.length,
total: allIndexEvents.length
});
}
});
async function loadMorePublications() {
loadingMore = true;
const current = eventsInView.length;
let source = searchQuery.trim()
let source = props.searchQuery.trim()
? filterEventsBySearch(allIndexEvents)
: allIndexEvents;
eventsInView = source.slice(0, current + 30);
@ -228,7 +336,7 @@ @@ -228,7 +336,7 @@
}
function getSkeletonIds(): string[] {
const skeletonHeight = 124; // The height of the skeleton component in pixels.
const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px).
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
@ -243,30 +351,31 @@ @@ -243,30 +351,31 @@
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
}
// Track previous feed type to avoid infinite loops
let previousFeedType = $state($feedType);
// Watch for changes in feed type and relay configuration
$effect(() => {
if (previousFeedType !== $feedType) {
console.log(`[PublicationFeed] Feed type changed from ${previousFeedType} to ${$feedType}`);
previousFeedType = $feedType;
// Clear cache when feed type changes (different relay sets)
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
// Cleanup function for fallback timeout
function cleanup() {
if (fallbackTimeout) {
clearTimeout(fallbackTimeout);
fallbackTimeout = null;
}
}
// Cleanup on component destruction
onDestroy(() => {
cleanup();
});
onMount(async () => {
await fetchAllIndexEventsFromRelays();
console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available
});
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
<div class="flex flex-col space-y-4">
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full"
>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" />
@ -281,7 +390,7 @@ @@ -281,7 +390,7 @@
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class="flex justify-center mt-4 mb-8">
<Button
@ -308,3 +417,4 @@ @@ -308,3 +417,4 @@
>
</div>
{/if}
</div>

90
src/lib/components/publications/PublicationHeader.svelte

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
<script lang="ts">
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays } from "$lib/ndk";
import { Card } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
const { event } = $props<{ event: NDKEvent }>();
function getRelayUrls(): string[] {
return $activeInboxRelays;
}
const relays = $derived.by(() => {
return getRelayUrls();
});
const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
});
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
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,
);
</script>
{#if title != null && href != null}
<Card class="ArticleBox card-leather max-w-md h-48 flex flex-row space-x-2 relative">
<div
class="flex-shrink-0 w-32 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
>
{#if image}
<LazyImage
src={image}
alt={title || "Publication image"}
eventId={event.id}
className="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full rounded"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
{/if}
</div>
<div class="flex flex-col flex-grow space-x-2">
<div class="flex flex-col flex-grow">
<a href="/{href}" class="flex flex-col space-y-2 h-full">
<div class="flex-grow pt-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}
</h3>
</div>
{#if version != "1"}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto">version: {version}</h3>
{/if}
</a>
</div>
</div>
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
</div>
</Card>
{/if}

14
src/lib/components/PublicationSection.svelte → src/lib/components/publications/PublicationSection.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
let {
@ -23,7 +24,7 @@ @@ -23,7 +24,7 @@
ref: (ref: HTMLElement) => void;
} = $props();
const publicationTree: PublicationTree = getContext("publicationTree");
const publicationTree: SveltePublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(
@ -47,9 +48,10 @@ @@ -47,9 +48,10 @@
);
let leafContent: Promise<string | Document> = $derived.by(async () => {
const rawContent = (await leafEvent)?.content ?? "";
const asciidoctorHtml = asciidoctor.convert(rawContent);
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString());
const content = (await leafEvent)?.content ?? "";
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString());
return processed;
});
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
@ -124,7 +126,6 @@ @@ -124,7 +126,6 @@
ref(sectionRef);
});
</script>
<section
@ -135,7 +136,6 @@ @@ -135,7 +136,6 @@
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{@const contentString = leafContent.toString()}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
@ -147,7 +147,7 @@ @@ -147,7 +147,7 @@
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(
contentString,
leafContent.toString(),
publicationType ?? "article",
false,
)}

182
src/lib/components/publications/TableOfContents.svelte

@ -0,0 +1,182 @@ @@ -0,0 +1,182 @@
<script lang="ts">
import {
TableOfContents,
type TocEntry,
} from "$lib/components/publications/table_of_contents.svelte";
import { getContext } from "svelte";
import {
SidebarDropdownWrapper,
SidebarGroup,
SidebarItem,
} from "flowbite-svelte";
import Self from "./TableOfContents.svelte";
import { onMount, onDestroy } from "svelte";
let { depth, onSectionFocused, onLoadMore } = $props<{
rootAddress: string;
depth: number;
onSectionFocused?: (address: string) => void;
onLoadMore?: () => void;
}>();
let toc = getContext("toc") as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => {
const newEntries = [];
for (const [_, entry] of toc.addressMap) {
if (entry.depth !== depth) {
continue;
}
newEntries.push(entry);
}
return newEntries;
});
// Track the currently visible section
let currentVisibleSection = $state<string | null>(null);
let observer: IntersectionObserver;
function setEntryExpanded(address: string, expanded: boolean = false) {
const entry = toc.getEntry(address);
if (!entry) {
return;
}
toc.expandedMap.set(address, expanded);
entry.resolveChildren();
}
function handleSectionClick(address: string) {
// Smooth scroll to the section
const element = document.getElementById(address);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
onSectionFocused?.(address);
// Check if this is the last entry and trigger loading more events
const currentEntries = entries;
const lastEntry = currentEntries[currentEntries.length - 1];
if (lastEntry && lastEntry.address === address) {
console.debug('[TableOfContents] Last entry clicked, triggering load more');
onLoadMore?.();
}
}
// Check if an entry is currently visible
function isEntryVisible(address: string): boolean {
return currentVisibleSection === address;
}
// Set up intersection observer to track visible sections
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
// Find the section that is most visible in the viewport
let maxIntersectionRatio = 0;
let mostVisibleSection: string | null = null;
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > maxIntersectionRatio) {
maxIntersectionRatio = entry.intersectionRatio;
mostVisibleSection = entry.target.id;
}
});
if (mostVisibleSection && mostVisibleSection !== currentVisibleSection) {
currentVisibleSection = mostVisibleSection;
}
},
{
threshold: [0, 0.25, 0.5, 0.75, 1],
rootMargin: "-20% 0px -20% 0px", // Consider section visible when it's in the middle 60% of the viewport
}
);
// Function to observe all section elements
function observeSections() {
const sections = document.querySelectorAll('section[id]');
sections.forEach((section) => {
observer.observe(section);
});
}
// Initial observation
observeSections();
// Set up a mutation observer to watch for new sections being added
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Check if the added node is a section with an id
if (element.tagName === 'SECTION' && element.id) {
observer.observe(element);
}
// Check if the added node contains sections
const sections = element.querySelectorAll?.('section[id]');
if (sections) {
sections.forEach((section) => {
observer.observe(section);
});
}
}
});
});
});
// Start observing the document body for changes
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
</script>
<!-- TODO: Figure out how to style indentations. -->
<!-- TODO: Make group title fonts the same as entry title fonts. -->
<SidebarGroup>
{#each entries as entry, index}
{@const address = entry.address}
{@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)}
{@const isLastEntry = index === entries.length - 1}
{#if isLeaf}
<SidebarItem
label={entry.title}
href={`#${address}`}
spanClass="px-2 text-ellipsis"
class={`${isVisible ? "toc-highlight" : ""} ${isLastEntry ? "pb-4" : ""}`}
onclick={() => handleSectionClick(address)}
/>
{:else}
{@const childDepth = depth + 1}
<SidebarDropdownWrapper
label={entry.title}
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} {isLastEntry ? 'pb-4' : ''}"
bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
>
<Self rootAddress={address} depth={childDepth} {onSectionFocused} {onLoadMore} />
</SidebarDropdownWrapper>
{/if}
{/each}
</SidebarGroup>

111
src/lib/components/publications/svelte_publication_tree.svelte.ts

@ -0,0 +1,111 @@ @@ -0,0 +1,111 @@
import { SvelteSet } from "svelte/reactivity";
import { PublicationTree } from "../../data_structures/publication_tree.ts";
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
export class SveltePublicationTree {
resolvedAddresses: SvelteSet<string> = new SvelteSet();
#publicationTree: PublicationTree;
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) {
this.#publicationTree = new PublicationTree(rootEvent, ndk);
this.#publicationTree.onNodeResolved(this.#handleNodeResolved);
this.#publicationTree.onBookmarkMoved(this.#handleBookmarkMoved);
}
// #region Proxied Public Methods
getChildAddresses(address: string): Promise<Array<string | null>> {
return this.#publicationTree.getChildAddresses(address);
}
getEvent(address: string): Promise<NDKEvent | null> {
return this.#publicationTree.getEvent(address);
}
getHierarchy(address: string): Promise<NDKEvent[]> {
return this.#publicationTree.getHierarchy(address);
}
async getParent(address: string): Promise<NDKEvent | null> {
const hierarchy = await this.getHierarchy(address);
// The last element in the hierarchy is the event with the given address, so the parent is the
// second to last element.
return hierarchy.at(-2) ?? null;
}
setBookmark(address: string) {
this.#publicationTree.setBookmark(address);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved.
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever the bookmark is moved.
* @param observer The observer function.
*/
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
// #endregion
// #region Proxied Async Iterator Methods
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> {
return this;
}
next(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.next();
}
previous(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.previous();
}
// #endregion
// #region Private Methods
/**
* Observer function that is invoked whenever a new node is resolved on the publication tree.
*
* @param address The address of the resolved node.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleNodeResolved = (address: string) => {
this.resolvedAddresses.add(address);
for (const observer of this.#nodeResolvedObservers) {
observer(address);
}
};
/**
* Observer function that is invoked whenever the bookmark is moved on the publication tree.
*
* @param address The address of the new bookmark.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleBookmarkMoved = (address: string) => {
for (const observer of this.#bookmarkMovedObservers) {
observer(address);
}
};
// #endregion
}

297
src/lib/components/publications/table_of_contents.svelte.ts

@ -0,0 +1,297 @@ @@ -0,0 +1,297 @@
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts";
import type { NDKEvent } from "../../utils/nostrUtils.ts";
import { indexKind } from "../../consts.ts";
export interface TocEntry {
address: string;
title: string;
href?: string;
children: TocEntry[];
parent?: TocEntry;
depth: number;
childrenResolved: boolean;
resolveChildren: () => Promise<void>;
}
/**
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree.
*
* @see SveltePublicationTree
*/
export class TableOfContents {
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap();
public expandedMap: SvelteMap<string, boolean> = new SvelteMap();
public leaves: SvelteSet<string> = new SvelteSet();
#root: TocEntry | null = null;
#publicationTree: SveltePublicationTree;
#pagePathname: string;
/**
* Constructs a `TableOfContents` from a `SveltePublicationTree`.
*
* @param rootAddress The address of the root event.
* @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation.
*/
constructor(
rootAddress: string,
publicationTree: SveltePublicationTree,
pagePathname: string,
) {
this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname;
this.#init(rootAddress);
}
// #region Public Methods
/**
* Returns the root entry of the ToC.
*
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized.
*/
getRootEntry(): TocEntry | null {
return this.#root;
}
getEntry(address: string): TocEntry | undefined {
return this.addressMap.get(address);
}
/**
* Builds a table of contents from the DOM subtree rooted at `parentElement`.
*
* @param parentElement The root of the DOM subtree containing the content to be added to the
* ToC.
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated
* by `parentElement`.
*
* This function is intended for use on segments of HTML markup that are not directly derived
* from a structure publication of the kind supported by `PublicationTree`. It may be used to
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or
* from a kind `30023` event with Markdown content.
*/
buildTocFromDocument(parentElement: HTMLElement, parentEntry: TocEntry) {
parentElement
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`)
.forEach((header) => {
// TODO: Correctly update ToC state from DOM.
const title = header.textContent?.trim();
const id = header.id;
// Only create an entry if the header has an ID and a title.
if (id && title) {
const href = `${this.#pagePathname}#${id}`;
// TODO: Check this logic.
const tocEntry: TocEntry = {
address: parentEntry.address,
title,
href,
depth: parentEntry.depth + 1,
children: [],
childrenResolved: true,
resolveChildren: () => Promise.resolve(),
};
parentEntry.children.push(tocEntry);
this.expandedMap.set(tocEntry.address, false);
this.buildTocFromDocument(header, tocEntry);
}
});
}
// #endregion
// #region Iterator Methods
/**
* Iterates over all ToC entries in depth-first order.
*/
*[Symbol.iterator](): IterableIterator<TocEntry> {
function* traverse(entry: TocEntry | null): IterableIterator<TocEntry> {
if (!entry) {
return;
}
yield entry;
if (entry.children) {
for (const child of entry.children) {
yield* traverse(child);
}
}
}
yield* traverse(this.#root);
}
// #endregion
// #region Private Methods
/**
* Initializes the ToC from the associated publication tree.
*
* @param rootAddress The address of the publication's root event.
*
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the
* publication has been fully resolved.
*
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to
* initialize the ToC with all of its first-level children.
*/
async #init(rootAddress: string) {
const rootEvent = await this.#publicationTree.getEvent(rootAddress);
if (!rootEvent) {
throw new Error(`[ToC] Root event ${rootAddress} not found.`);
}
this.#root = await this.#buildTocEntry(rootAddress);
this.addressMap.set(rootAddress, this.#root);
// Handle any other nodes that have already been resolved in parallel.
await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address),
),
);
// Set up an observer to handle progressive resolution of the publication tree.
this.#publicationTree.onNodeResolved((address: string) => {
this.#buildTocEntryFromResolvedNode(address);
});
}
#getTitle(event: NDKEvent | null): string {
if (!event) {
// TODO: What do we want to return in this case?
return "[untitled]";
}
const titleTag = event.getMatchingTags?.("title")?.[0]?.[1];
return titleTag || event.tagAddress() || "[untitled]";
}
async #buildTocEntry(address: string): Promise<TocEntry> {
// Michael J - 07 July 2025 - NOTE: This arrow function is nested so as to use its containing
// scope in its operation. Do not move it to the top level without ensuring it still has access
// to the necessary variables.
const resolver = async () => {
if (entry.childrenResolved) {
return;
}
const event = await this.#publicationTree.getEvent(entry.address);
if (event?.kind !== indexKind) {
// TODO: Build ToC entries from HTML markup in this case.
return;
}
const childAddresses = await this.#publicationTree.getChildAddresses(
entry.address,
);
for (const childAddress of childAddresses) {
if (!childAddress) {
continue;
}
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
// here so that we can determine whether to render an entry as a leaf before it is fully
// resolved.
if (childAddress.split(":")[0] !== indexKind.toString()) {
this.leaves.add(childAddress);
}
// Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the
// publication tree. This is acceptable here, because the tree is always resolved
// top-down. Therefore, by the time we handle a node's resolution, its parent and
// siblings have already been resolved.
const childEntry = await this.#buildTocEntry(childAddress);
childEntry.parent = entry;
childEntry.depth = entry.depth + 1;
entry.children.push(childEntry);
this.addressMap.set(childAddress, childEntry);
}
await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true;
};
const event = await this.#publicationTree.getEvent(address);
if (!event) {
throw new Error(`[ToC] Event ${address} not found.`);
}
const depth = (await this.#publicationTree.getHierarchy(address)).length;
const entry: TocEntry = {
address,
title: this.#getTitle(event),
href: `${this.#pagePathname}#${address}`,
children: [],
depth,
childrenResolved: false,
resolveChildren: resolver,
};
this.expandedMap.set(address, false);
// Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner
// resolver function. The resolver function is called when entries are resolved by expanding
// a ToC entry, and we'll reach the block below when entries are resolved by the publication
// tree.
if (event.kind !== indexKind) {
this.leaves.add(address);
}
return entry;
}
/**
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding
* Nostr index event.
*
* @param entry The ToC entry to reorder.
*
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the
* parent event has. Average size of `n` is small enough to be negligible.
*/
async #matchChildrenToTagOrder(entry: TocEntry) {
const parentEvent = await this.#publicationTree.getEvent(entry.address);
if (parentEvent?.kind === indexKind) {
const tagOrder = parentEvent.getMatchingTags("a").map((tag) => tag[1]);
const addressToOrdinal = new Map<string, number>();
// Build map of addresses to their ordinals from tag order
tagOrder.forEach((address, index) => {
addressToOrdinal.set(address, index);
});
entry.children.sort((a, b) => {
const aOrdinal =
addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal =
addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal;
});
}
}
#buildTocEntryFromResolvedNode(address: string) {
if (this.addressMap.has(address)) {
return;
}
this.#buildTocEntry(address).then((entry) => {
this.addressMap.set(address, entry);
});
}
// #endregion
}

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

@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
indexEvent.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
indexEvent.getMatchingTags("author")[0]?.[1] ?? "unknown",
);
let pubkey: string = $derived(
indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
@ -129,40 +129,37 @@ @@ -129,40 +129,37 @@
outline={true}
onclick={backToMain}
>
<CaretLeftOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Back</span
>
<CaretLeftOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if !isLeaf}
{#if publicationType === "blog"}
<Button
class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog
? 'active'
: ''}"
class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? "active" : ""}`}
outline={true}
onclick={() => toggleColumn("blog")}
>
<BookOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Table of Contents</span
>
<BookOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Table of Contents</span>
</Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button
class="btn-leather !w-auto"
class={`btn-leather !w-auto ${$publicationColumnVisibility.toc ? "active" : ""}`}
outline={true}
onclick={() => toggleColumn("toc")}
>
<BookOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Table of Contents</span
>
<BookOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Table of Contents</span>
</Button>
{/if}
{/if}
</div>
<div class="flex flex-grow text justify-center items-center">
<div class="flex flex-col flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis">
<b class="text-nowrap">{title}</b>
</p>
<p>
<span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span
>
@ -175,9 +172,8 @@ @@ -175,9 +172,8 @@
outline={true}
onclick={backToBlog}
>
<CloseOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Close</span
>
<CloseOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
@ -186,9 +182,8 @@ @@ -186,9 +182,8 @@
outline={true}
onclick={() => toggleColumn("discussion")}
>
<GlobeOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Discussion</span
>
<GlobeOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Discussion</span>
</Button>
{/if}
</div>

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

@ -8,40 +8,52 @@ @@ -8,40 +8,52 @@
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { ndkSignedIn, inboxRelays } from "$lib/ndk";
import { feedType } from "$lib/stores";
import { FeedType } from "$lib/consts";
import { activeInboxRelays } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
const {
event,
title,
author,
originalAuthor,
summary,
image,
version,
source,
type,
language,
publisher,
identifier,
} = $props<{
event: NDKEvent;
title?: string;
author?: string;
originalAuthor?: string;
summary?: string;
image?: string;
version?: string;
source?: string;
type?: string;
language?: string;
publisher?: string;
identifier?: string;
}>();
// Component props
let { event } = $props<{ event: NDKEvent }>();
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// 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);
@ -49,19 +61,16 @@ @@ -49,19 +61,16 @@
/**
* 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
* - Uses active inbox relays from the new relay management system
* - Falls back to active inbox relays for anonymous users (which include community relays)
*/
let activeRelays = $derived(
(() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays;
const relays = user.signedIn ? $activeInboxRelays : $activeInboxRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: $ndkSignedIn,
feedType: $feedType,
isUserFeed,
isSignedIn: user.signedIn,
relayCount: relays.length,
relayUrls: relays,
});
@ -108,10 +117,9 @@ @@ -108,10 +117,9 @@
* Navigates to the event details page
*/
function viewEventDetails() {
const nevent = getIdentifier('nevent');
const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
</script>
<div

115
src/lib/components/util/ContainingIndexes.svelte

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { findContainingIndexEvents } from "$lib/utils/event_search";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
let { event } = $props<{
event: NDKEvent;
}>();
let containingIndexes = $state<NDKEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let lastEventId = $state<string | null>(null);
async function loadContainingIndexes() {
console.log(
"[ContainingIndexes] Loading containing indexes for event:",
event.id,
);
loading = true;
error = null;
try {
containingIndexes = await findContainingIndexEvents(event);
console.log(
"[ContainingIndexes] Found containing indexes:",
containingIndexes.length,
);
} catch (err) {
error =
err instanceof Error
? err.message
: "Failed to load containing indexes";
console.error(
"[ContainingIndexes] Error loading containing indexes:",
err,
);
} finally {
loading = false;
}
}
function navigateToIndex(indexEvent: NDKEvent) {
const dTag = getMatchingTags(indexEvent, "d")[0]?.[1];
if (dTag) {
goto(`/publication?d=${encodeURIComponent(dTag)}`);
} else {
// Fallback to naddr
try {
const naddr = naddrEncode(indexEvent, $activeInboxRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`);
} catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err);
}
}
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
$effect(() => {
// Only reload if the event ID has actually changed
if (event.id !== lastEventId) {
lastEventId = event.id;
loadContainingIndexes();
}
});
</script>
{#if containingIndexes.length > 0 || loading || error}
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Containing Publications
</h4>
{#if loading}
<div class="text-sm text-gray-500 dark:text-gray-400">
Loading containing publications...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400">
{error}
</div>
{:else if containingIndexes.length > 0}
<div class="max-h-32 overflow-y-auto">
{#each containingIndexes.slice(0, 3) as indexEvent}
{@const title =
getMatchingTags(indexEvent, "title")[0]?.[1] || "Untitled"}
<Button
size="xs"
color="alternative"
class="mb-1 mr-1 text-xs"
onclick={() => navigateToIndex(indexEvent)}
>
{title}
</Button>
{/each}
{#if containingIndexes.length > 3}
<span class="text-xs text-gray-500 dark:text-gray-400">
+{containingIndexes.length - 3} more
</span>
{/if}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
No containing publications found
</div>
{/if}
</div>
{/if}

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

@ -4,6 +4,9 @@ @@ -4,6 +4,9 @@
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
// isModal
// - don't show interactions in modal view
@ -18,9 +21,6 @@ @@ -18,9 +21,6 @@
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,
);
@ -40,11 +40,27 @@ @@ -40,11 +40,27 @@
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind);
let authorTag: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "",
);
let pTag: string = $derived(getMatchingTags(event, "p")[0]?.[1] ?? "");
let originalAuthor: string = $derived(
getMatchingTags(event, "p")[0]?.[1] ?? null,
);
function isValidNostrPubkey(str: string): boolean {
return (
/^[a-f0-9]{64}$/i.test(str) ||
(str.startsWith("npub1") && str.length >= 59 && str.length <= 63)
);
}
</script>
<div class="flex flex-col relative mb-2">
{#if !isModal}
<div class="flex flex-row justify-between items-center">
<!-- Index author badge -->
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P
>
@ -54,23 +70,36 @@ @@ -54,23 +70,36 @@
<div
class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"
>
{#if image}
<div class="my-2">
<img
class="w-full md:max-w-48 object-contain rounded"
alt={title}
<div class="my-2">
{#if image}
<LazyImage
src={image}
alt={title}
eventId={event.id}
className="w-full md:max-w-48 object-contain rounded"
/>
</div>
{/if}
{:else}
<div
class="w-full md:max-w-48 h-32 object-contain rounded"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div>
{/if}
</div>
<div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor !== null}
{#if authorTag && pTag && isValidNostrPubkey(pTag)}
{authorTag} {@render userBadge(pTag, "")}
{:else if authorTag}
{authorTag}
{:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, "")}
{:else if originalAuthor !== null}
{@render userBadge(originalAuthor, author)}
{:else}
{author}
unknown
{/if}
</h2>
{#if version !== "1"}
@ -93,7 +122,11 @@ @@ -93,7 +122,11 @@
{#if hashtags.length}
<div class="tags my-2">
{#each hashtags as tag}
<span class="text-sm">#{tag}</span>
<button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer"
>#{tag}</button
>
{/each}
</div>
{/if}
@ -106,7 +139,7 @@ @@ -106,7 +139,7 @@
{:else}
<span>Author:</span>
{/if}
{@render userBadge(event.pubkey, author)}
{@render userBadge(event.pubkey, "")}
</h4>
</div>

90
src/lib/components/util/LazyImage.svelte

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
<script lang="ts">
import { generateDarkPastelColor } from '$lib/utils/image_utils';
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let {
src,
alt,
eventId,
className = 'w-full h-full object-cover',
placeholderClassName = '',
}: {
src: string;
alt: string;
eventId: string;
className?: string;
placeholderClassName?: string;
} = $props();
let imageLoaded = $state(false);
let imageError = $state(false);
let imgElement = $state<HTMLImageElement | null>(null);
const placeholderColor = $derived.by(() => generateDarkPastelColor(eventId));
function loadImage() {
if (!imgElement) return;
imgElement.onload = () => {
// Small delay to ensure smooth transition
setTimeout(() => {
imageLoaded = true;
}, 100);
};
imgElement.onerror = () => {
imageError = true;
};
// Set src after setting up event handlers
imgElement.src = src;
}
function bindImg(element: HTMLImageElement) {
imgElement = element;
// Load image immediately when element is bound
loadImage();
}
</script>
<div class="relative w-full h-full">
<!-- Placeholder -->
<div
class="absolute inset-0 {placeholderClassName}"
style="background-color: {placeholderColor};"
class:hidden={imageLoaded}
>
</div>
<!-- Image -->
<img
bind:this={imgElement}
{src}
{alt}
class="{className} {imageLoaded ? 'opacity-100' : 'opacity-0'}"
style="transition: opacity 0.2s ease-out;"
loading="lazy"
decoding="async"
class:hidden={imageError}
onload={() => {
setTimeout(() => {
imageLoaded = true;
}, 100);
}}
onerror={() => {
imageError = true;
}}
/>
<!-- Error state -->
{#if imageError}
<div
class="absolute inset-0 flex items-center justify-center bg-gray-200 dark:bg-gray-700 {placeholderClassName}"
>
<div class="text-gray-500 dark:text-gray-400 text-xs">
Failed to load
</div>
</div>
{/if}
</div>

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

@ -1,6 +1,14 @@ @@ -1,6 +1,14 @@
<script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logout, ndkInstance } from "$lib/ndk";
import NetworkStatus from "$components/NetworkStatus.svelte";
import {
logoutUser,
userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub
} from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import {
ArrowRightToBracketOutline,
UserOutline,
@ -8,29 +16,331 @@ @@ -8,29 +16,331 @@
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import { activeInboxRelays } from "$lib/ndk";
let { pubkey, isNav = false } = $props();
let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>();
let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image);
// UI state for login functionality
let isLoadingExtension: boolean = $state(false);
let isLoadingAmber: boolean = $state(false);
let result: string | null = $state(null);
let nostrConnectUri: string | undefined = $state(undefined);
let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state();
let resultTimeout: ReturnType<typeof setTimeout> | null = null;
let profileAvatarId = "profile-avatar-btn";
let showAmberFallback = $state(false);
let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null;
let isRefreshingProfile = $state(false);
onMount(() => {
if (localStorage.getItem("alexandria/amber/fallback") === "1") {
console.log("Profile: Found fallback flag on mount, showing modal");
showAmberFallback = true;
}
});
// Use profile data from userStore
let userState = $derived($userStore);
let profile = $derived(userState.profile);
let pfp = $derived(profile?.picture);
let username = $derived(profile?.name);
let tag = $derived(profile?.name);
let npub = $state<string | undefined>(undefined);
let npub = $derived(userState.npub);
// Debug logging
$effect(() => {
console.log("Profile component - userState:", userState);
console.log("Profile component - profile:", profile);
console.log("Profile component - pfp:", pfp);
console.log("Profile component - username:", username);
});
// Handle user state changes with effects
$effect(() => {
const currentUser = userState;
// Check for fallback flag when user state changes to signed in
if (
currentUser.signedIn &&
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"Profile: User signed in and fallback flag found, showing modal",
);
showAmberFallback = true;
}
// Set up periodic check when user is signed in
if (currentUser.signedIn && !fallbackCheckInterval) {
fallbackCheckInterval = setInterval(() => {
if (
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"Profile: Found fallback flag during periodic check, showing modal",
);
showAmberFallback = true;
}
}, 500); // Check every 500ms
} else if (!currentUser.signedIn && fallbackCheckInterval) {
clearInterval(fallbackCheckInterval);
fallbackCheckInterval = null;
}
});
// Auto-refresh profile when user signs in
$effect(() => {
const currentUser = userState;
// If user is signed in and we have an npub but no profile data, refresh it
if (currentUser.signedIn && currentUser.npub && !profile?.name && !isRefreshingProfile) {
console.log("Profile: User signed in but no profile data, refreshing...");
refreshProfile();
}
});
// Debug activeInboxRelays
$effect(() => {
const inboxRelays = get(activeInboxRelays);
console.log("Profile component - activeInboxRelays:", inboxRelays);
});
// Track if we've already refreshed the profile for this session
let hasRefreshedProfile = $state(false);
// Reset the refresh flag when user logs out
$effect(() => {
const user = $ndkInstance.getUser({ pubkey: pubkey ?? undefined });
const currentUser = userState;
if (!currentUser.signedIn) {
hasRefreshedProfile = false;
}
});
// Manual trigger to refresh profile when user signs in (only once)
$effect(() => {
const currentUser = userState;
if (currentUser.signedIn && currentUser.npub && !isRefreshingProfile && !hasRefreshedProfile) {
console.log("Profile: User signed in, triggering profile refresh...");
hasRefreshedProfile = true;
// Add a small delay to ensure relays are ready
setTimeout(() => {
refreshProfile();
}, 1000);
}
});
npub = user.npub;
// Refresh profile when login method changes (e.g., Amber to read-only)
$effect(() => {
const currentUser = userState;
if (currentUser.signedIn && currentUser.npub && currentUser.loginMethod && !isRefreshingProfile) {
console.log("Profile: Login method detected:", currentUser.loginMethod);
// If switching to read-only mode (npub), refresh profile
if (currentUser.loginMethod === "npub" && !hasRefreshedProfile) {
console.log("Profile: Switching to read-only mode, refreshing profile...");
hasRefreshedProfile = true;
setTimeout(() => {
refreshProfile();
}, 500);
}
}
});
user.fetchProfile().then((userProfile) => {
profile = userProfile;
});
// Track login method changes and refresh profile when switching from Amber to npub
let previousLoginMethod = $state<string | null>(null);
$effect(() => {
const currentUser = userState;
if (currentUser.signedIn && currentUser.loginMethod !== previousLoginMethod && !isRefreshingProfile) {
console.log("Profile: Login method changed from", previousLoginMethod, "to", currentUser.loginMethod);
// If switching from Amber to npub (read-only), refresh profile
if (previousLoginMethod === "amber" && currentUser.loginMethod === "npub" && !hasRefreshedProfile) {
console.log("Profile: Switching from Amber to read-only mode, refreshing profile...");
hasRefreshedProfile = true;
setTimeout(() => {
refreshProfile();
}, 1000);
}
previousLoginMethod = currentUser.loginMethod;
}
});
// Function to refresh profile data
async function refreshProfile() {
if (!userState.signedIn || !userState.npub) return;
isRefreshingProfile = true;
try {
console.log("Refreshing profile for npub:", userState.npub);
// Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) {
console.log("Using NDK's built-in profile fetching");
const userProfile = await userState.ndkUser.fetchProfile();
console.log("NDK profile fetch result:", userProfile);
if (userProfile) {
const profileData = {
name: userProfile.name,
displayName: userProfile.displayName,
nip05: userProfile.nip05,
picture: userProfile.image,
about: userProfile.bio,
banner: userProfile.banner,
website: userProfile.website,
lud16: userProfile.lud16,
};
console.log("Converted profile data:", profileData);
// Update the userStore with fresh profile data
userStore.update(currentState => ({
...currentState,
profile: profileData
}));
return;
}
}
// Fallback to getUserMetadata
console.log("Falling back to getUserMetadata");
const freshProfile = await getUserMetadata(userState.npub, true); // Force fresh fetch
console.log("Fresh profile data from getUserMetadata:", freshProfile);
// Update the userStore with fresh profile data
userStore.update(currentState => ({
...currentState,
profile: freshProfile
}));
} catch (error) {
console.error("Failed to refresh profile:", error);
} finally {
isRefreshingProfile = false;
}
}
// Generate QR code
const generateQrCode = async (text: string): Promise<string> => {
try {
const QRCode = await import("qrcode");
return await QRCode.toDataURL(text, {
width: 256,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
} catch (err) {
console.error("Failed to generate QR code:", err);
return "";
}
};
// Copy to clipboard function
const copyToClipboard = async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
result = "✅ URI copied to clipboard!";
} catch (err) {
result = "❌ Failed to copy to clipboard";
}
};
// Helper to show result message near avatar and auto-dismiss
function showResultMessage(msg: string) {
result = msg;
if (resultTimeout) {
clearTimeout(resultTimeout);
}
resultTimeout = setTimeout(() => {
result = null;
}, 4000);
}
// Login handlers
const handleBrowserExtensionLogin = async () => {
isLoadingExtension = true;
isLoadingAmber = false;
try {
await loginWithExtension();
} catch (err: unknown) {
showResultMessage(
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
isLoadingExtension = false;
}
};
const handleAmberLogin = async () => {
isLoadingAmber = true;
isLoadingExtension = false;
try {
const ndk = new NDK();
const relay = "wss://relay.nsec.app";
const localNsec =
localStorage.getItem("amber/nsec") ??
NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: "Alexandria",
perms: "sign_event:1;sign_event:4",
});
if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri ?? undefined;
showQrCode = true;
qrCodeDataUrl =
(await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
const user = await amberSigner.blockUntilReady();
await loginWithAmber(amberSigner, user);
showQrCode = false;
} else {
throw new Error("Failed to generate Nostr Connect URI");
}
} catch (err: unknown) {
showResultMessage(
`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
isLoadingAmber = false;
}
};
const handleReadOnlyLogin = async () => {
const inputNpub = prompt("Enter your npub (public key):");
if (inputNpub) {
try {
await loginWithNpub(inputNpub);
} catch (err: unknown) {
showResultMessage(
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
};
async function handleSignOutClick() {
logout($ndkInstance.activeUser!);
profile = null;
localStorage.removeItem("amber/nsec");
localStorage.removeItem("alexandria/amber/fallback");
logoutUser();
}
function handleViewProfile() {
@ -39,78 +349,282 @@ @@ -39,78 +349,282 @@
}
}
function shortenNpub(long: string | undefined) {
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem("alexandria/amber/fallback");
// Refresh profile when switching to read-only mode
setTimeout(() => {
console.log("Profile: Amber fallback dismissed, refreshing profile for read-only mode...");
refreshProfile();
}, 500);
}
function shortenNpub(long: string | null | undefined) {
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
}
</script>
<div class="relative">
{#if profile}
{#if !userState.signedIn}
<!-- Login button -->
<div class="group">
<Avatar
rounded
class="h-6 w-6 cursor-pointer"
src={pfp}
alt={username}
id="profile-avatar"
/>
{#key username || tag}
<Popover
placement="bottom"
triggeredBy="#profile-avatar"
class="popover-leather w-[180px]"
trigger="hover"
<button
bind:this={loginButtonRef}
id="login-avatar"
class="h-6 w-6 rounded-full bg-gray-300 flex items-center justify-center cursor-pointer hover:bg-gray-400 transition-colors"
>
<UserOutline class="h-4 w-4 text-gray-600" />
</button>
<Popover
placement="bottom"
triggeredBy="#login-avatar"
class="popover-leather w-[200px]"
trigger="click"
>
<div class="flex flex-col space-y-2">
<h3 class="text-lg font-bold mb-2">Login with...</h3>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber}
>
{#if isLoadingExtension}
🔄 Connecting...
{:else}
🌐 Browser extension
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension}
>
{#if isLoadingAmber}
🔄 Connecting...
{:else}
📱 Amber: NostrConnect
{/if}
</button>
<button
class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleReadOnlyLogin}
>
📖 npub (read only)
</button>
<div class="border-t border-gray-200 pt-2 mt-2">
<div class="text-xs text-gray-500 mb-1">Network Status:</div>
<NetworkStatus />
</div>
</div>
</Popover>
{#if result}
<div
class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300"
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col">
{#if username}
<h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{/if}
<ul class="space-y-2 mt-2">
<li>
<CopyToClipboard
displayText={shortenNpub(npub)}
copyText={npub}
/>
</li>
{result}
<button
class="ml-2 text-gray-500 hover:text-gray-700"
onclick={() => (result = null)}>✖</button
>
</div>
{/if}
</div>
{:else}
<!-- User profile -->
<div class="group">
<button
class="h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer"
id={profileAvatarId}
type="button"
aria-label="Open profile menu"
>
{#if !pfp}
<div class="h-6 w-6 rounded-full bg-gray-300 animate-pulse cursor-pointer"></div>
{:else}
<Avatar
rounded
class="h-6 w-6 cursor-pointer"
src={pfp}
alt={username || "User"}
/>
{/if}
</button>
<Popover
placement="bottom"
triggeredBy={`#${profileAvatarId}`}
class="popover-leather w-[220px]"
trigger="click"
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col">
{#if username}
<h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{:else if !pfp}
<h3 class="text-lg font-bold">Loading profile...</h3>
{:else}
<h3 class="text-lg font-bold">Loading...</h3>
{/if}
<ul class="space-y-2 mt-2">
<li>
<CopyToClipboard
displayText={shortenNpub(npub) || "Loading..."}
copyText={npub || ""}
/>
</li>
<li>
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
>
<UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
</button>
</li>
<li class="text-xs text-gray-500">
{#if userState.loginMethod === "extension"}
Logged in with extension
{:else if userState.loginMethod === "amber"}
Logged in with Amber
{:else if userState.loginMethod === "npub"}
Logged in with npub
{:else}
Unknown login method
{/if}
</li>
<li>
<NetworkStatus />
</li>
{#if isNav}
<li>
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
id="sign-out-button"
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick}
>
<UserOutline
<ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
/> Sign out
</button>
</li>
{#if isNav}
<li>
<button
id="sign-out-button"
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick}
>
<ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button>
</li>
{:else}
<!-- li>
<button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
>
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</button>
</li -->
{/if}
</ul>
</div>
{:else}
<!-- li>
<button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
>
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</button>
</li -->
{/if}
</ul>
</div>
</Popover>
{/key}
</div>
</Popover>
</div>
{/if}
</div>
{#if showQrCode && qrCodeDataUrl}
<!-- QR Code Modal -->
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
Scan with Amber
</h2>
<p class="text-sm text-gray-600 mb-4">
Open Amber on your phone and scan this QR code
</p>
<div class="flex justify-center mb-4">
<img
src={qrCodeDataUrl || ""}
alt="Nostr Connect QR Code"
class="border-2 border-gray-300 rounded-lg"
width="256"
height="256"
/>
</div>
<div class="space-y-2">
<label
for="nostr-connect-uri-modal"
class="block text-sm font-medium text-gray-700"
>Or copy the URI manually:</label
>
<div class="flex">
<input
id="nostr-connect-uri-modal"
type="text"
value={nostrConnectUri || ""}
readonly
class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50"
placeholder="nostrconnect://..."
/>
<button
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-r text-sm font-medium transition-colors"
onclick={() => copyToClipboard(nostrConnectUri || "")}
>
📋 Copy
</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-4">
<p>1. Open Amber on your phone</p>
<p>2. Scan the QR code above</p>
<p>3. Approve the connection in Amber</p>
</div>
<button
class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={() => (showQrCode = false)}
>
Close
</button>
</div>
</div>
</div>
{/if}
{#if showAmberFallback}
<div
class="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50"
>
<div
class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg border border-primary-300"
>
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
Amber Session Restored
</h2>
<p class="text-sm text-gray-600 mb-4">
Your Amber wallet session could not be restored automatically, so
you've been switched to read-only mode.<br />
You can still browse and read content, but you'll need to reconnect Amber
to publish or comment.
</p>
<button
class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberReconnect}
>
Reconnect Amber
</button>
<button
class="mt-2 ml-4 bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberFallbackDismiss}
>
Continue in Read-Only Mode
</button>
</div>
</div>
</div>
{/if}

150
src/lib/components/util/TocToggle.svelte

@ -1,150 +0,0 @@ @@ -1,150 +0,0 @@
<script lang="ts">
import {
Heading,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(window.location.hash);
interface TocItem {
label: string;
hash: string;
}
// Get TOC items from parser
let tocItems = $state<TocItem[]>([]);
$effect(() => {
// This will re-run whenever tocUpdate changes
tocUpdate;
const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log("TOC rootId:", rootId, "childIds:", childIds);
const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId);
if (title) {
items.push({
label: title,
hash: `#${nodeId}`,
});
}
const children = $pharosInstance.getChildIndexIds(nodeId);
children.forEach(processNode);
};
childIds.forEach(processNode);
tocItems = items;
});
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
function updateActiveHash() {
activeHash = window.location.hash;
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update((v) => ({
...v,
toc: window.innerWidth >= tocBreakpoint,
}));
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
// Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", updateActiveHash);
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
return () => {
window.removeEventListener("hashchange", updateActiveHash);
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
};
});
</script>
<!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc}
<Sidebar class="sidebar-leather left-0">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>
(This ToC is only for demo purposes, and is not fully-functional.)
</p>
{#each tocItems as item}
<SidebarItem
class="sidebar-item-leather {activeHash === item.hash
? 'bg-primary-200 font-bold'
: ''}"
label={item.label}
href={item.hash}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}

22
src/lib/components/util/ViewPublicationLink.svelte

@ -3,7 +3,8 @@ @@ -3,7 +3,8 @@
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { getEventType } from "$lib/utils/mime";
import { standardRelays } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { goto } from "$app/navigation";
let { event, className = "" } = $props<{
@ -25,7 +26,7 @@ @@ -25,7 +26,7 @@
return null;
}
try {
return naddrEncode(event, standardRelays);
return naddrEncode(event, $activeInboxRelays);
} catch {
return null;
}
@ -40,26 +41,29 @@ @@ -40,26 +41,29 @@
return tag[1]; // Return the addressable event address
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event)
isAddressable: isAddressableEvent(event),
});
if (naddrAddress) {
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress);
console.log(
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
@ -77,4 +81,4 @@ @@ -77,4 +81,4 @@
>
View Publication
</button>
{/if}
{/if}

55
src/lib/consts.ts

@ -1,41 +1,50 @@ @@ -1,41 +1,50 @@
// AI SHOULD NEVER CHANGE THIS FILE
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelay = "wss://profiles.nostr1.com";
export const standardRelays = [
"wss://thecitadel.nostr1.com",
export const communityRelays = [
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com",
// Removed gitcitadel.nostr1.com as it's causing connection issues
//'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu',
//"wss://theforest.gitcitadel.eu"
];
// Non-auth relays for anonymous users
export const anonymousRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
export const searchRelays = [
"wss://profiles.nostr1.com",
"wss://freelay.sovbit.host",
];
export const fallbackRelays = [
"wss://purplepag.es",
"wss://indexer.coracle.social",
"wss://relay.noswhere.com",
"wss://aggr.nostr.land",
"wss://relay.noswhere.com",
"wss://nostr.wine",
];
export const secondaryRelays = [
"wss://theforest.nostr1.com",
//"wss://theforest.gitcitadel.eu"
"wss://thecitadel.nostr1.com",
//"wss://thecitadel.gitcitadel.eu",
"wss://nostr.land",
"wss://nostr.wine",
"wss://nostr.sovbit.host",
"wss://freelay.sovbit.host",
"wss://nostr21.com",
"wss://greensoul.space",
"wss://relay.damus.io",
"wss://relay.nostr.band",
];
export const anonymousRelays = [
"wss://freelay.sovbit.host",
"wss://thecitadel.nostr1.com"
];
export const lowbandwidthRelays = [
"wss://theforest.nostr1.com",
"wss://thecitadel.nostr1.com",
"wss://aggr.nostr.land"
];
export const localRelays: string[] = [
"wss://localhost:8080",
"wss://localhost:4869"
];
export enum FeedType {
StandardRelays = "standard",
CommunityRelays = "standard",
UserRelays = "user",
}

281
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType {
Branch,
@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus { @@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus {
Error,
}
export enum TreeTraversalMode {
Leaves,
All,
}
enum TreeTraversalDirection {
Forward,
Backward,
}
interface PublicationTreeNode {
type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus;
@ -36,6 +45,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -36,6 +45,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* A map of addresses in the tree to their corresponding events.
*/
#events: Map<string, NDKEvent>;
/**
* Simple cache for fetched events to avoid re-fetching.
*/
#eventCache: Map<string, NDKEvent> = new Map();
/**
* An ordered list of the addresses of the leaves of the tree.
@ -52,10 +66,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -52,10 +66,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
#ndk: NDK;
#nodeAddedObservers: Array<(address: string) => void> = [];
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress();
this.#root = {
type: this.#getNodeType(rootEvent),
type: PublicationTreeNodeType.Branch,
status: PublicationTreeNodeStatus.Resolved,
address: rootAddress,
children: [],
@ -142,14 +162,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -142,14 +162,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* Retrieves the addresses of the loaded children, if any, of the node with the given address.
*
* @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes.
*
* Note that this method resolves all children of the node.
*/
async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
`[PublicationTree] Node with address ${address} not found.`,
);
}
@ -169,7 +192,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -169,7 +192,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
let node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
`[PublicationTree] Node with address ${address} not found.`,
);
}
@ -189,7 +212,29 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -189,7 +212,29 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
setBookmark(address: string) {
this.#bookmark = address;
this.#cursor.tryMoveTo(address);
this.#cursor.tryMoveTo(address).then((success) => {
if (success) {
this.#bookmarkMovedObservers.forEach((observer) => observer(address));
}
});
}
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
onNodeAdded(observer: (address: string) => void) {
this.#nodeAddedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved. Nodes are
* added lazily.
*
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
}
// #region Iteration Cursor
@ -206,8 +251,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -206,8 +251,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) {
if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve();
if (!startEvent) {
return false;
}
this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
.get(startEvent.tagAddress())
?.value();
} else {
this.target = await this.#tree.#nodes.get(address)?.value();
@ -222,7 +270,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -222,7 +270,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false;
}
@ -240,7 +290,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -240,7 +290,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false;
}
@ -258,7 +310,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -258,7 +310,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false;
}
@ -287,7 +341,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -287,7 +341,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false;
}
@ -316,7 +372,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -316,7 +372,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
tryMoveToParent(): boolean {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false;
}
@ -338,34 +396,100 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -338,34 +396,100 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return this;
}
// TODO: Add `previous()` method.
/**
* Return the next event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The next event in the tree, or null if the tree is empty.
*/
async next(
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
return this.#yieldEventAtCursor(false);
}
}
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
}
}
async next(): Promise<IteratorResult<NDKEvent | null>> {
/**
* Return the previous event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The previous event in the tree, or null if the tree is empty.
*/
async previous(
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
return this.#yieldEventAtCursor(false);
}
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Backward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Backward);
}
}
async #yieldEventAtCursor(
done: boolean,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
return { done, value: null };
}
const value = (await this.getEvent(this.#cursor.target.address)) ?? null;
return { done, value };
}
/**
* Walks the tree in the given direction, yielding the event at each leaf.
*
* @param direction The direction to walk the tree.
* @returns The event at the leaf, or null if the tree is empty.
*
* Based on Raymond Chen's tree traversal algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
*/
async #walkLeaves(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
do {
if (await this.#cursor.tryMoveToNextSibling()) {
while (await this.#cursor.tryMoveToFirstChild()) {
if (await tryMoveToSibling()) {
while (await tryMoveToChild()) {
continue;
}
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
return this.#yieldEventAtCursor(false);
}
} while (this.#cursor.tryMoveToParent());
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
@ -373,36 +497,43 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -373,36 +497,43 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: true, value: null };
}
async previous(): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
}
/**
* Walks the tree in the given direction, yielding the event at each node.
*
* @param direction The direction to walk the tree.
* @returns The event at the node, or null if the tree is empty.
*
* Based on Raymond Chen's preorder walk algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304
*/
async #preorderWalkAll(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
if (await tryMoveToChild()) {
return this.#yieldEventAtCursor(false);
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do {
if (await this.#cursor.tryMoveToPreviousSibling()) {
while (await this.#cursor.tryMoveToLastChild()) {
continue;
}
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
if (await tryMoveToSibling()) {
return this.#yieldEventAtCursor(false);
}
} while (this.#cursor.tryMoveToParent());
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
return { done: true, value: null };
// If we get to this point, we're at the root node (can't move up any more).
return this.#yieldEventAtCursor(true);
}
// #endregion
@ -431,15 +562,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -431,15 +562,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) {
throw new Error(
`PublicationTree: Node with address ${currentAddress} not found.`,
`[PublicationTree] Node with address ${currentAddress} not found.`,
);
}
currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) {
throw new Error(
`PublicationTree: Event with address ${currentAddress} not found.`,
console.warn(
`[PublicationTree] Event with address ${currentAddress} not found.`,
);
return null;
}
// Stop immediately if the target of the search is found.
@ -462,13 +594,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -462,13 +594,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
// Augment the tree with the children of the current event.
for (const childAddress of currentChildAddresses) {
if (this.#nodes.has(childAddress)) {
continue;
}
await this.#addNode(childAddress, currentNode!);
}
const childPromises = currentChildAddresses
.filter(childAddress => !this.#nodes.has(childAddress))
.map(childAddress => this.#addNode(childAddress, currentNode!));
await Promise.all(childPromises);
// Push the popped address's children onto the stack for the next iteration.
while (currentChildAddresses.length > 0) {
@ -481,18 +611,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -481,18 +611,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#addNode(address: string, parentNode: PublicationTreeNode) {
if (this.#nodes.has(address)) {
console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return;
}
const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
this.#nodeAddedObservers.forEach((observer) => observer(address));
}
/**
@ -508,16 +633,27 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -508,16 +633,27 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
address: string,
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
});
// Check cache first
let event = this.#eventCache.get(address);
if (!event) {
const [kind, pubkey, dTag] = address.split(":");
const fetchedEvent = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
});
// Cache the event if found
if (fetchedEvent) {
this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent;
}
}
if (!event) {
console.debug(
`PublicationTree: Event with address ${address} not found.`,
`[PublicationTree] Event with address ${address} not found.`,
);
return {
@ -543,9 +679,12 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -543,9 +679,12 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [],
};
for (const address of childAddresses) {
this.addEventByAddress(address, event);
}
const childPromises = childAddresses.map(address =>
this.addEventByAddress(address, event)
);
await Promise.all(childPromises);
this.#nodeResolvedObservers.forEach((observer) => observer(address));
return node;
}

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

@ -8,8 +8,9 @@ @@ -8,8 +8,9 @@
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 { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
@ -71,13 +72,13 @@ export function createNetworkNode( @@ -71,13 +72,13 @@ export function createNetworkNode(
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: standardRelays,
relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)],
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
relays: standardRelays,
relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)],
kind: event.kind,
});
} catch (error) {
@ -163,13 +164,24 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -163,13 +164,24 @@ 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 = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
debug("Processing tags for event", {
eventId: event.id,
aTagCount: aTags.length,
tagCount: tags.length,
tagType:
tags.length > 0
? getMatchingTags(event, "a").length > 0
? "a"
: "e"
: "none",
});
aTags.forEach((tag) => {
tags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
@ -284,7 +296,13 @@ export function processIndexEvent( @@ -284,7 +296,13 @@ export function processIndexEvent(
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a")
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(indexEvent, "a");
if (tags.length === 0) {
tags = getMatchingTags(indexEvent, "e");
}
const sequence = tags
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))

408
src/lib/ndk.ts

@ -8,23 +8,39 @@ import NDK, { @@ -8,23 +8,39 @@ import NDK, {
} from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store";
import {
fallbackRelays,
secondaryRelays,
FeedType,
loginStorageKey,
standardRelays,
communityRelays,
anonymousRelays,
searchRelays,
} from "./consts";
import { feedType } from "./stores";
import { userPubkey } from '$lib/stores/authStore.Svelte';
import {
buildCompleteRelaySet,
testRelayConnection,
discoverLocalRelays,
getUserLocalRelays,
getUserBlockedRelays,
getUserOutboxRelays,
deduplicateRelayUrls,
} from "./utils/relay_management";
// Re-export testRelayConnection for components that need it
export { testRelayConnection };
import { startNetworkMonitoring, NetworkCondition } from "./utils/network_detection";
import { userStore } from "./stores/userStore";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore";
export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]);
export const outboxRelays = writable<string[]>([]);
export const ndkSignedIn: Writable<boolean> = writable(false);
export const activePubkey: Writable<string | null> = writable(null);
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
// New relay management stores
export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]);
/**
* Custom authentication policy that handles NIP-42 authentication manually
@ -209,83 +225,7 @@ export function checkWebSocketSupport(): void { @@ -209,83 +225,7 @@ export function checkWebSocketSupport(): void {
}
}
/**
* Tests connection to a relay and returns connection status
* @param relayUrl The relay URL to test
* @param ndk The NDK instance
* @returns Promise that resolves to connection status
*/
export async function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
const relay = new NDKRelay(secureUrl, undefined, new NDK());
let authRequired = false;
let connected = false;
let error: string | undefined;
let actualUrl: string | undefined;
const timeout = setTimeout(() => {
relay.disconnect();
resolve({
connected: false,
requiresAuth: authRequired,
error: "Connection timeout",
actualUrl,
});
}, 5000);
relay.on("connect", () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`);
connected = true;
actualUrl = secureUrl;
clearTimeout(timeout);
relay.disconnect();
resolve({
connected: true,
requiresAuth: authRequired,
error,
actualUrl,
});
});
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
authRequired = true;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
}
});
relay.on("disconnect", () => {
if (!connected) {
error = "Connection failed";
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl,
});
}
});
// Log the actual WebSocket URL being used
console.debug(`[NDK.ts] Attempting connection to: ${secureUrl}`);
relay.connect();
});
}
/**
* Gets the user's pubkey from local storage, if it exists.
@ -409,7 +349,7 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -409,7 +349,7 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect();
}, 10000); // 10 second timeout
}, 5000); // 5 second timeout
// Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) {
@ -435,69 +375,191 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -435,69 +375,191 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
return relay;
}
export function getActiveRelays(ndk: NDK): NDKRelaySet {
// Use all relays currently in the NDK pool
return new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
ndk,
);
/**
* Gets the active relay set for the current user
* @param ndk NDK instance
* @returns Promise that resolves to object with inbox and outbox relay arrays
*/
export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
const user = get(userStore);
console.debug('[NDK.ts] getActiveRelaySet: User state:', { signedIn: user.signedIn, hasNdkUser: !!user.ndkUser, pubkey: user.pubkey });
if (user.signedIn && user.ndkUser) {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:', user.ndkUser.pubkey);
return await buildCompleteRelaySet(ndk, user.ndkUser);
} else {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for anonymous user');
return await buildCompleteRelaySet(ndk, null);
}
}
/**
* Initializes an instance of NDK, and connects it to the logged-in user's preferred relay set
* (if available), or to Alexandria's standard relay set.
* @returns The initialized NDK instance.
* Updates the active relay stores and NDK pool with new relay URLs
* @param ndk NDK instance
*/
export function initNdk(): NDK {
const startingPubkey = getPersistedLogin();
const [startingInboxes, _] =
startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
export async function updateActiveRelayStores(ndk: NDK): Promise<void> {
try {
console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update');
// Get the active relay set from the relay management system
const relaySet = await getActiveRelaySet(ndk);
console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet);
// Update the stores with the new relay configuration
activeInboxRelays.set(relaySet.inboxRelays);
activeOutboxRelays.set(relaySet.outboxRelays);
console.debug('[NDK.ts] updateActiveRelayStores: Updated stores with inbox:', relaySet.inboxRelays.length, 'outbox:', relaySet.outboxRelays.length);
// Add relays to NDK pool (deduplicated)
const allRelayUrls = deduplicateRelayUrls([...relaySet.inboxRelays, ...relaySet.outboxRelays]);
console.debug('[NDK.ts] updateActiveRelayStores: Adding', allRelayUrls.length, 'relays to NDK pool');
for (const url of allRelayUrls) {
try {
const relay = createRelayWithAuth(url, ndk);
ndk.pool?.addRelay(relay);
} catch (error) {
console.debug('[NDK.ts] updateActiveRelayStores: Failed to add relay', url, ':', error);
}
}
console.debug('[NDK.ts] updateActiveRelayStores: Relay store update completed');
} catch (error) {
console.warn('[NDK.ts] updateActiveRelayStores: Error updating relay stores:', error);
}
}
/**
* Logs the current relay configuration to console
*/
export function logCurrentRelayConfiguration(): void {
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
console.log('🔌 Current Relay Configuration:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
}
/**
* Updates relay stores when user state changes
* @param ndk NDK instance
*/
export async function refreshRelayStores(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to user state change');
await updateActiveRelayStores(ndk);
}
/**
* Updates relay stores when network condition changes
* @param ndk NDK instance
*/
export async function refreshRelayStoresOnNetworkChange(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to network condition change');
await updateActiveRelayStores(ndk);
}
/**
* Starts network monitoring for relay optimization
* @param ndk NDK instance
*/
export function startNetworkMonitoringForRelays(ndk: NDK): void {
// Use centralized network monitoring instead of separate monitoring
startNetworkStatusMonitoring();
}
/**
* Creates NDKRelaySet from relay URLs with proper authentication
* @param relayUrls Array of relay URLs
* @param ndk NDK instance
* @returns NDKRelaySet
*/
function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
const relays = relayUrls.map(url =>
new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk)
);
return new NDKRelaySet(new Set(relays), ndk);
}
// Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (
startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays
).map(ensureSecureWebSocket);
/**
* Gets the active relay set as NDKRelaySet for use in queries
* @param ndk NDK instance
* @param useInbox Whether to use inbox relays (true) or outbox relays (false)
* @returns Promise that resolves to NDKRelaySet
*/
export async function getActiveRelaySetAsNDKRelaySet(
ndk: NDK,
useInbox: boolean = true
): Promise<NDKRelaySet> {
const relaySet = await getActiveRelaySet(ndk);
const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays;
return createRelaySetFromUrls(urls, ndk);
}
console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls);
/**
* Initializes an instance of NDK with the new relay management system
* @returns The initialized NDK instance
*/
export function initNdk(): NDK {
console.debug("[NDK.ts] Initializing NDK with new relay management system");
const ndk = new NDK({
autoConnectUserRelays: true,
autoConnectUserRelays: false, // We'll manage relays manually
enableOutboxModel: true,
explicitRelayUrls: secureRelayUrls,
});
// Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
// Connect with better error handling
ndk.connect()
.then(() => {
// Connect with better error handling and reduced retry attempts
let retryCount = 0;
const maxRetries = 1; // Reduce to 1 retry
const attemptConnection = async () => {
try {
await ndk.connect();
console.debug("[NDK.ts] NDK connected successfully");
})
.catch((error) => {
console.error("[NDK.ts] Failed to connect NDK:", error);
// Try to reconnect after a delay
setTimeout(() => {
console.debug("[NDK.ts] Attempting to reconnect...");
ndk.connect().catch((retryError) => {
console.error("[NDK.ts] Reconnection failed:", retryError);
});
}, 5000);
});
// Update relay stores after connection
await updateActiveRelayStores(ndk);
// Start network monitoring for relay optimization
startNetworkMonitoringForRelays(ndk);
} catch (error) {
console.warn("[NDK.ts] Failed to connect NDK:", error);
// Only retry a limited number of times
if (retryCount < maxRetries) {
retryCount++;
console.debug(`[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`);
setTimeout(attemptConnection, 2000); // Reduce timeout to 2 seconds
} else {
console.warn("[NDK.ts] Max retries reached, continuing with limited functionality");
// Still try to update relay stores even if connection failed
try {
await updateActiveRelayStores(ndk);
startNetworkMonitoringForRelays(ndk);
} catch (storeError) {
console.warn("[NDK.ts] Failed to update relay stores:", storeError);
}
}
}
};
attemptConnection();
return ndk;
}
/**
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox
* relays.
* @returns The user's profile, if it is available.
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists.
* Signs in with a NIP-07 browser extension using the new relay management system
* @returns The user's profile, if it is available
* @throws If sign-in fails
*/
export async function loginWithExtension(
pubkey?: string,
@ -515,23 +577,10 @@ export async function loginWithExtension( @@ -515,23 +577,10 @@ export async function loginWithExtension(
activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(
Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes);
// Update relay stores with the new system
await updateActiveRelayStores(ndk);
ndk.signer = signer;
ndk.activeUser = user;
@ -555,58 +604,17 @@ export function logout(user: NDKUser): void { @@ -555,58 +604,17 @@ export function logout(user: NDKUser): void {
activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
// Clear relay stores
activeInboxRelays.set([]);
activeOutboxRelays.set([]);
// Stop network monitoring
stopNetworkStatusMonitoring();
// Re-initialize with anonymous instance
const newNdk = initNdk();
ndkInstance.set(newNdk);
}
/**
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox
* relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(fallbacks, ndk),
);
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = createRelayWithAuth(url, ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
});
} else {
relayList.tags.forEach((tag) => {
switch (tag[0]) {
case "r":
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
case "w":
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
default:
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
}
});
}
return [inboxRelays, outboxRelays];
}

37
src/lib/parser.ts

@ -906,6 +906,13 @@ export default class Pharos { @@ -906,6 +906,13 @@ export default class Pharos {
["#d", nodeId],
...this.extractAndNormalizeWikilinks(content!),
];
// Extract image from content if present
const imageUrl = this.extractImageFromContent(content!);
if (imageUrl) {
event.tags.push(["image", imageUrl]);
}
event.created_at = Date.now();
event.pubkey = pubkey;
@ -1182,6 +1189,36 @@ export default class Pharos { @@ -1182,6 +1189,36 @@ export default class Pharos {
return wikilinks;
}
/**
* Extracts the first image URL from AsciiDoc content.
* @param content The AsciiDoc content to search for images.
* @returns The first image URL found, or null if no images are present.
*/
private extractImageFromContent(content: string): string | null {
// Look for AsciiDoc image syntax: image::url[alt text]
const imageRegex = /image::([^\s\[]+)/g;
let match = imageRegex.exec(content);
if (match) {
return match[1];
}
// Look for AsciiDoc image syntax: image:url[alt text]
const inlineImageRegex = /image:([^\s\[]+)/g;
match = inlineImageRegex.exec(content);
if (match) {
return match[1];
}
// Look for markdown-style image syntax: ![alt](url)
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
match = markdownImageRegex.exec(content);
if (match) {
return match[2];
}
return null;
}
// TODO: Add search-based wikilink resolution.
// #endregion

115
src/lib/services/publisher.ts

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { getMimeTags } from "$lib/utils/mime";
import {
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface PublishResult {
success: boolean;
eventId?: string;
error?: string;
}
export interface PublishOptions {
content: string;
kind?: number;
onSuccess?: (eventId: string) => void;
onError?: (error: string) => void;
}
/**
* Publishes AsciiDoc content as Nostr events
* @param options - Publishing options
* @returns Promise resolving to publish result
*/
export async function publishZettel(
options: PublishOptions,
): Promise<PublishResult> {
const { content, kind = 30041, onSuccess, onError } = options;
if (!content.trim()) {
const error = "Please enter some content";
onError?.(error);
return { success: false, error };
}
// Get the current NDK instance from the store
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
try {
// Parse content into sections
const sections = parseAsciiDocSections(content, 2);
if (sections.length === 0) {
throw new Error("No valid sections found in content");
}
// For now, publish only the first section
const firstSection = sections[0];
const title = firstSection.title;
const cleanContent = firstSection.content;
const sectionTags = firstSection.tags || [];
// Generate d-tag and create event
const dTag = generateDTag(title);
const [mTag, MTag] = getMimeTags(kind);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]];
if (sectionTags) {
tags.push(...sectionTags);
}
// Create and sign NDK event
const ndkEvent = new NDKEvent(ndk);
ndkEvent.kind = kind;
ndkEvent.created_at = Math.floor(Date.now() / 1000);
ndkEvent.tags = tags;
ndkEvent.content = cleanContent;
ndkEvent.pubkey = ndk.activeUser.pubkey;
await ndkEvent.sign();
// Publish to relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) {
throw new Error("No relays available in NDK pool");
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const publishedToRelays = await ndkEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
const result = { success: true, eventId: ndkEvent.id };
onSuccess?.(ndkEvent.id);
return result;
} else {
// Try fallback publishing logic here...
throw new Error("Failed to publish to any relays");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
function generateDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}

87
src/lib/snippets/UserSnippets.svelte

@ -1,31 +1,80 @@ @@ -1,31 +1,80 @@
<script module lang="ts">
import { goto } from "$app/navigation";
import {
createProfileLinkWithVerification,
toNpub,
getUserMetadata,
} from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
// Extend NostrProfile locally to allow display_name for legacy support
type NostrProfileWithLegacy = {
displayName?: string;
display_name?: string;
name?: string;
[key: string]: any;
};
export { userBadge };
</script>
{#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)}
{@const npub = toNpub(identifier) as string}
{@const cleanId = npub.replace(/^nostr:/, "")}
{@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`}
{@const displayTextFinal = displayText || defaultText}
<button
class="npub-badge hover:underline"
onclick={() => goto(`/events?id=${encodeURIComponent(cleanId)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/events?id=${encodeURIComponent(cleanId)}`);
}
}}
>
@{displayTextFinal}
</button>
{@const npub = toNpub(identifier)}
{#if npub}
{#if !displayText || displayText.trim().toLowerCase() === "unknown"}
{#await getUserMetadata(npub) then profile}
{@const p = profile as NostrProfileWithLegacy}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{p.displayName ||
p.display_name ||
p.name ||
npub.slice(0, 8) + "..." + npub.slice(-4)}
</button>
</span>
{:catch}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{npub.slice(0, 8) + "..." + npub.slice(-4)}
</button>
</span>
{/await}
{:else}
{#await createProfileLinkWithVerification(npub as string, displayText)}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
</span>
{:then html}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
{@html html.replace(/([\s\S]*<\/a>)/, "").trim()}
</span>
{:catch}
<span class="inline-flex items-center gap-0.5">
<button
class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${npub}`)}
>
@{displayText}
</button>
</span>
{/await}
{/if}
{:else}
{displayText ?? ""}
{/if}

23
src/lib/stores.ts

@ -1,13 +1,22 @@ @@ -1,13 +1,22 @@
import { readable, writable } from "svelte/store";
import { FeedType } from "./consts";
import { writable } from "svelte/store";
// The old feedType store is no longer needed since we use the new relay management system
// All relay selection is now handled by the activeInboxRelays and activeOutboxRelays stores in ndk.ts
export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let alexandriaKinds = writable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays);
export interface PublicationLayoutVisibility {
toc: boolean;
blog: boolean;
main: boolean;
inner: boolean;
discussion: boolean;
editing: boolean;
}
const defaultVisibility = {
const defaultVisibility: PublicationLayoutVisibility = {
toc: false,
blog: true,
main: true,
@ -17,7 +26,9 @@ const defaultVisibility = { @@ -17,7 +26,9 @@ const defaultVisibility = {
};
function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility });
const { subscribe, set, update } = writable<PublicationLayoutVisibility>({
...defaultVisibility,
});
return {
subscribe,

4
src/lib/stores/authStore.Svelte.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { writable, derived } from 'svelte/store';
import { writable, derived } from "svelte/store";
/**
* Stores the user's public key if logged in, or null otherwise.
@ -8,4 +8,4 @@ export const userPubkey = writable<string | null>(null); @@ -8,4 +8,4 @@ export const userPubkey = writable<string | null>(null);
/**
* Derived store indicating if the user is logged in.
*/
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);

55
src/lib/stores/networkStore.ts

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
import { writable, type Writable } from 'svelte/store';
import { detectNetworkCondition, NetworkCondition, startNetworkMonitoring } from '$lib/utils/network_detection';
// Network status store
export const networkCondition = writable<NetworkCondition>(NetworkCondition.ONLINE);
export const isNetworkChecking = writable<boolean>(false);
// Network monitoring state
let stopNetworkMonitoring: (() => void) | null = null;
/**
* Starts network monitoring if not already running
*/
export function startNetworkStatusMonitoring(): void {
if (stopNetworkMonitoring) {
return; // Already monitoring
}
console.debug('[networkStore.ts] Starting network status monitoring');
stopNetworkMonitoring = startNetworkMonitoring(
(condition: NetworkCondition) => {
console.debug(`[networkStore.ts] Network condition changed to: ${condition}`);
networkCondition.set(condition);
},
60000 // Check every 60 seconds to reduce spam
);
}
/**
* Stops network monitoring
*/
export function stopNetworkStatusMonitoring(): void {
if (stopNetworkMonitoring) {
console.debug('[networkStore.ts] Stopping network status monitoring');
stopNetworkMonitoring();
stopNetworkMonitoring = null;
}
}
/**
* Manually check network status (for immediate updates)
*/
export async function checkNetworkStatus(): Promise<void> {
try {
isNetworkChecking.set(true);
const condition = await detectNetworkCondition();
networkCondition.set(condition);
} catch (error) {
console.warn('[networkStore.ts] Failed to check network status:', error);
networkCondition.set(NetworkCondition.OFFLINE);
} finally {
isNetworkChecking.set(false);
}
}

4
src/lib/stores/relayStore.ts

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

436
src/lib/stores/userStore.ts

@ -0,0 +1,436 @@ @@ -0,0 +1,436 @@
import { writable, get } from "svelte/store";
import type { NostrProfile } from "$lib/utils/nostrUtils";
import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk";
import {
NDKNip07Signer,
NDKRelayAuthPolicies,
NDKRelaySet,
NDKRelay,
} from "@nostr-dev-kit/ndk";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from "$lib/ndk";
import { loginStorageKey } from "$lib/consts";
import { nip19 } from "nostr-tools";
import { userPubkey } from "$lib/stores/authStore.Svelte";
export interface UserState {
pubkey: string | null;
npub: string | null;
profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] };
loginMethod: "extension" | "amber" | "npub" | null;
ndkUser: NDKUser | null;
signer: NDKSigner | null;
signedIn: boolean;
}
export const userStore = writable<UserState>({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
// Helper functions for relay management
function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`;
}
function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem(
getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
);
localStorage.setItem(
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
}
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
);
const outboxes = new Set<string>(
JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
);
return [inboxes, outboxes];
}
async function getUserPreferredRelays(
ndk: any,
user: NDKUser,
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)],
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(fallbacks, ndk),
);
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(
([url, relayType]: [string, any]) => {
const relay = new NDKRelay(
url,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
},
);
} else {
relayList.tags.forEach((tag: string[]) => {
switch (tag[0]) {
case "r":
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
case "w":
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
default:
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break;
}
});
}
return [inboxRelays, outboxRelays];
}
// --- Unified login/logout helpers ---
export const loginMethodStorageKey = "alexandria/login/method";
function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method);
}
function getPersistedLoginMethod(): "extension" | "amber" | "npub" | null {
return (
(localStorage.getItem(loginMethodStorageKey) as
| "extension"
| "amber"
| "npub") ?? null
);
}
function clearLogin() {
localStorage.removeItem(loginStorageKey);
localStorage.removeItem(loginMethodStorageKey);
}
/**
* Login with NIP-07 browser extension
*/
export async function loginWithExtension() {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const user = await signer.user();
const npub = user.npub;
console.log("Login with extension - fetching profile for npub:", npub);
// Try to fetch user metadata, but don't fail if it times out
let profile: NostrProfile | null = null;
try {
console.log("Login with extension - attempting to fetch profile...");
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with extension - using fallback profile:", profile);
}
// Fetch user's preferred relays
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "extension" as const,
ndkUser: user,
signer,
signedIn: true,
};
console.log("Login with extension - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithExtension: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithExtension: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "extension");
}
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const npub = user.npub;
console.log("Login with Amber - fetching profile for npub:", npub);
let profile: NostrProfile | null = null;
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with Amber - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during Amber login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with Amber - using fallback profile:", profile);
}
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "amber" as const,
ndkUser: user,
signer: amberSigner,
signedIn: true,
};
console.log("Login with Amber - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithAmber: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithAmber: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "amber");
}
/**
* Login with npub (read-only)
*/
export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub")) {
try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string;
} catch (e) {
console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e);
throw e;
}
} else {
hexPubkey = pubkeyOrNpub;
}
let npub: string;
try {
npub = nip19.npubEncode(hexPubkey);
} catch (e) {
console.error("Failed to encode npub from hex pubkey:", hexPubkey, e);
throw e;
}
console.log("Login with npub - fetching profile for npub:", npub);
const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null;
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during npub login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with npub - using fallback profile:", profile);
}
ndk.signer = undefined;
ndk.activeUser = user;
const userState = {
pubkey: user.pubkey,
npub,
profile,
relays: { inbox: [], outbox: [] },
loginMethod: "npub" as const,
ndkUser: user,
signer: null,
signedIn: true,
};
console.log("Login with npub - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk);
} catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error);
}
clearLogin();
localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, "npub");
}
/**
* Logout and clear all user state
*/
export function logoutUser() {
console.log("Logging out user...");
const currentUser = get(userStore);
if (currentUser.ndkUser) {
// Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox"));
}
// Clear all possible login states from localStorage
clearLogin();
// Also clear any other potential login keys that might exist
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (
key &&
(key.includes("login") ||
key.includes("nostr") ||
key.includes("user") ||
key.includes("alexandria") ||
key === "pubkey")
) {
keysToRemove.push(key);
}
}
// Specifically target the login storage key
keysToRemove.push("alexandria/login/pubkey");
keysToRemove.push("alexandria/login/method");
keysToRemove.forEach((key) => {
console.log("Removing localStorage key:", key);
localStorage.removeItem(key);
});
// Clear Amber-specific flags
localStorage.removeItem("alexandria/amber/fallback");
// Set a flag to prevent auto-login on next page load
localStorage.setItem("alexandria/logout/flag", "true");
console.log("Cleared all login data from localStorage");
userStore.set({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
userPubkey.set(null);
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = undefined;
ndk.signer = undefined;
}
console.log("Logout complete");
}

116
src/lib/utils/ZettelParser.ts

@ -0,0 +1,116 @@ @@ -0,0 +1,116 @@
import { ndkInstance } from "$lib/ndk";
import { signEvent, getEventHash } from "$lib/utils/nostrUtils";
import { getMimeTags } from "$lib/utils/mime";
import { communityRelays } from "$lib/consts";
import { nip19 } from "nostr-tools";
export interface ZettelSection {
title: string;
content: string;
tags?: string[][];
}
/**
* Splits AsciiDoc content into sections at the specified heading level.
* Each section starts with the heading and includes all lines up to the next heading of the same level.
* @param content The AsciiDoc string.
* @param level The heading level (2 for '==', 3 for '===', etc.).
* @returns Array of section strings, each starting with the heading.
*/
export function splitAsciiDocByHeadingLevel(
content: string,
level: number,
): string[] {
if (level < 1 || level > 6) throw new Error("Heading level must be 1-6");
const heading = "^" + "=".repeat(level) + " ";
const regex = new RegExp(`(?=${heading})`, "gm");
return content
.split(regex)
.map((section) => section.trim())
.filter((section) => section.length > 0);
}
/**
* Parses a single AsciiDoc section string into a ZettelSection object.
* @param section The section string (must start with heading).
*/
export function parseZettelSection(section: string): ZettelSection {
const lines = section.split("\n");
let title = "Untitled";
let contentLines: string[] = [];
let inHeader = true;
let tags: string[][] = [];
tags = extractTags(section);
for (const line of lines) {
const trimmed = line.trim();
if (inHeader && trimmed.startsWith("==")) {
title = trimmed.replace(/^==+/, "").trim();
continue;
} else if (inHeader && trimmed.startsWith(":")) {
continue;
}
inHeader = false;
contentLines.push(line);
}
return {
title,
content: contentLines.join("\n").trim(),
tags,
};
}
/**
* Parses AsciiDoc into an array of ZettelSection objects at the given heading level.
*/
export function parseAsciiDocSections(
content: string,
level: number,
): ZettelSection[] {
return splitAsciiDocByHeadingLevel(content, level).map(parseZettelSection);
}
/**
* Extracts tag names and values from the content.
* :tagname: tagvalue // tags are optional
* @param content The AsciiDoc string.
* @returns Array of tags.
*/
export function extractTags(content: string): string[][] {
const tags: string[][] = [];
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
if (match) {
const tagName = match[1].trim();
const tagValue = match[2].trim();
// Special handling for tags attribute
if (tagName === "tags") {
// Split comma-separated values and create individual "t" tags
const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) {
tags.push(["t", value]);
}
} else {
// Regular attribute becomes a tag
tags.push([tagName, tagValue]);
}
}
}
}
console.log("Extracted tags:", tags);
return tags;
}
// You can add publishing logic here as needed, e.g.,
// export async function publishZettelSection(...) { ... }

119
src/lib/utils/community_checker.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { communityRelay } from '$lib/consts';
import { RELAY_CONSTANTS, SEARCH_LIMITS } from './search_constants';
import { communityRelays } from "$lib/consts";
import { RELAY_CONSTANTS, SEARCH_LIMITS } from "./search_constants";
// Cache for pubkeys with kind 1 events on communityRelay
const communityCache = new Map<string, boolean>();
@ -11,38 +11,56 @@ export async function checkCommunity(pubkey: string): Promise<boolean> { @@ -11,38 +11,56 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
if (communityCache.has(pubkey)) {
return communityCache.get(pubkey)!;
}
try {
const relayUrl = communityRelay;
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK
}
]));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) {
communityCache.set(pubkey, true);
ws.close();
resolve(true);
} else if (data[0] === 'EOSE') {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
// Try each community relay until we find one that works
for (const relayUrl of communityRelays) {
try {
const ws = new WebSocket(relayUrl);
const result = await new Promise<boolean>((resolve) => {
ws.onopen = () => {
ws.send(
JSON.stringify([
"REQ",
RELAY_CONSTANTS.COMMUNITY_REQUEST_ID,
{
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK,
},
]),
);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === "EVENT" && data[2]?.kind === 1) {
communityCache.set(pubkey, true);
ws.close();
resolve(true);
} else if (data[0] === "EOSE") {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
}
};
ws.onerror = () => {
ws.close();
resolve(false);
};
});
if (result) {
return true;
}
};
ws.onerror = () => {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
};
});
} catch {
// Continue to next relay if this one fails
continue;
}
}
// If we get here, no relay found the user
communityCache.set(pubkey, false);
return false;
} catch {
communityCache.set(pubkey, false);
return false;
@ -52,14 +70,37 @@ export async function checkCommunity(pubkey: string): Promise<boolean> { @@ -52,14 +70,37 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
/**
* Check community status for multiple profiles
*/
export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>): Promise<Record<string, boolean>> {
export async function checkCommunityStatus(
profiles: Array<{ pubkey?: string }>,
): Promise<Record<string, boolean>> {
const communityStatus: Record<string, boolean> = {};
for (const profile of profiles) {
if (profile.pubkey) {
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey);
// Run all community checks in parallel with timeout
const checkPromises = profiles.map(async (profile) => {
if (!profile.pubkey) return { pubkey: "", status: false };
try {
const status = await Promise.race([
checkCommunity(profile.pubkey),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 2000); // 2 second timeout per check
}),
]);
return { pubkey: profile.pubkey, status };
} catch (error) {
console.warn("Community status check failed for", profile.pubkey, error);
return { pubkey: profile.pubkey, status: false };
}
});
// Wait for all checks to complete
const results = await Promise.allSettled(checkPromises);
for (const result of results) {
if (result.status === "fulfilled" && result.value.pubkey) {
communityStatus[result.value.pubkey] = result.value.status;
}
}
return communityStatus;
}
}

252
src/lib/utils/event_input_utils.ts

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import type { NDKEvent } from './nostrUtils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import { EVENT_KINDS } from './search_constants';
import type { NDKEvent } from "./nostrUtils";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants";
// =========================
// Validation
@ -12,14 +12,16 @@ import { EVENT_KINDS } from './search_constants'; @@ -12,14 +12,16 @@ import { EVENT_KINDS } from './search_constants';
* Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/
export function requiresDTag(kind: number): boolean {
return kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX;
return (
kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX
);
}
/**
* Returns true if the tags array contains at least one d-tag with a non-empty value.
*/
export function hasDTag(tags: [string, string][]): boolean {
return tags.some(([k, v]) => k === 'd' && v && v.trim() !== '');
return tags.some(([k, v]) => k === "d" && v && v.trim() !== "");
}
/**
@ -33,11 +35,15 @@ function containsAsciiDocHeaders(content: string): boolean { @@ -33,11 +35,15 @@ function containsAsciiDocHeaders(content: string): boolean {
* Validates that content does NOT contain AsciiDoc headers (for kind 30023).
* Returns { valid, reason }.
*/
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } {
export function validateNotAsciidoc(content: string): {
valid: boolean;
reason?: string;
} {
if (containsAsciiDocHeaders(content)) {
return {
valid: false,
reason: 'Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).',
reason:
"Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).",
};
}
return { valid: true };
@ -47,12 +53,21 @@ export function validateNotAsciidoc(content: string): { valid: boolean; reason?: @@ -47,12 +53,21 @@ export function validateNotAsciidoc(content: string): { valid: boolean; reason?:
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header.
* Returns { valid, reason }.
*/
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } {
if (!content.trim().startsWith('=')) {
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' };
export function validateAsciiDoc(content: string): {
valid: boolean;
reason?: string;
} {
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: 'AsciiDoc must start with a document title ("=").',
};
}
if (!/^==\s+/m.test(content)) {
return { valid: false, reason: 'AsciiDoc must contain at least one section header ("==").' };
return {
valid: false,
reason: 'AsciiDoc must contain at least one section header ("==").',
};
}
return { valid: true };
}
@ -61,31 +76,44 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st @@ -61,31 +76,44 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st
* Validates that a 30040 event set will be created correctly.
* Returns { valid, reason }.
*/
export function validate30040EventSet(content: string): { valid: boolean; reason?: string } {
export function validate30040EventSet(content: string): {
valid: boolean;
reason?: string;
} {
// First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) {
return { valid: false, reason: '30040 events must contain at least one section.' };
return {
valid: false,
reason: "30040 events must contain at least one section.",
};
}
// Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) {
return { valid: false, reason: '30040 events must have a document title (line starting with "=").' };
return {
valid: false,
reason:
'30040 events must have a document title (line starting with "=").',
};
}
// Check that the content will result in an empty 30040 event
// The 30040 event should have empty content, with all content split into 30041 events
if (!content.trim().startsWith('=')) {
return { valid: false, reason: '30040 events must start with a document title ("=").' };
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: '30040 events must start with a document title ("=").',
};
}
return { valid: true };
}
@ -99,8 +127,8 @@ export function validate30040EventSet(content: string): { valid: boolean; reason @@ -99,8 +127,8 @@ export function validate30040EventSet(content: string): { valid: boolean; reason
function normalizeDTagValue(header: string): string {
return header
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "");
}
/**
@ -109,8 +137,8 @@ function normalizeDTagValue(header: string): string { @@ -109,8 +137,8 @@ function normalizeDTagValue(header: string): string {
export function titleToDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
}
/**
@ -125,7 +153,7 @@ function extractAsciiDocDocumentHeader(content: string): string | null { @@ -125,7 +153,7 @@ function extractAsciiDocDocumentHeader(content: string): string | null {
* Extracts all section headers (lines starting with '== ').
*/
function extractAsciiDocSectionHeaders(content: string): string[] {
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim());
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map((m) => m[1].trim());
}
/**
@ -142,7 +170,11 @@ function extractMarkdownTopHeader(content: string): string | null { @@ -142,7 +170,11 @@ function extractMarkdownTopHeader(content: string): string | null {
* Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section.
*/
function splitAsciiDocSections(content: string): { sections: string[]; sectionHeaders: string[]; hasPreamble: boolean } {
function splitAsciiDocSections(content: string): {
sections: string[];
sectionHeaders: string[];
hasPreamble: boolean;
} {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
const sectionHeaders: string[] = [];
@ -150,50 +182,50 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe @@ -150,50 +182,50 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
let foundFirstSection = false;
let hasPreamble = false;
let preambleContent: string[] = [];
for (const line of lines) {
// Skip document title lines (= header)
if (/^=\s+/.test(line)) {
continue;
}
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join('\n').trim());
sections.push(current.join("\n").trim());
current = [];
}
// Extract section header for title tag
const headerMatch = line.match(/^==\s+(.+)$/);
if (headerMatch) {
sectionHeaders.push(headerMatch[1].trim());
}
foundFirstSection = true;
} else if (foundFirstSection) {
// Only add lines to current section if we've found the first section
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== '') {
if (line.trim() !== "") {
preambleContent.push(line);
}
}
}
// Add the last section
if (current.length > 0) {
sections.push(current.join('\n').trim());
sections.push(current.join("\n").trim());
}
// Add preamble as first section if it exists
if (preambleContent.length > 0) {
sections.unshift(preambleContent.join('\n').trim());
sectionHeaders.unshift('Preamble');
sections.unshift(preambleContent.join("\n").trim());
sectionHeaders.unshift("Preamble");
hasPreamble = true;
}
return { sections, sectionHeaders, hasPreamble };
}
@ -216,27 +248,28 @@ function getNdk() { @@ -216,27 +248,28 @@ function getNdk() {
export function build30040EventSet(
content: string,
tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number }
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number },
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
console.log('=== build30040EventSet called ===');
console.log('Input content:', content);
console.log('Input tags:', tags);
console.log('Input baseEvent:', baseEvent);
console.log("=== build30040EventSet called ===");
console.log("Input content:", content);
console.log("Input tags:", tags);
console.log("Input baseEvent:", baseEvent);
const ndk = getNdk();
console.log('NDK instance:', ndk);
console.log("NDK instance:", ndk);
const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders;
console.log('Sections:', sections);
console.log('Section headers:', sectionHeaders);
const dTags = sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log('D tags:', dTags);
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders);
const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
@ -244,41 +277,39 @@ export function build30040EventSet( @@ -244,41 +277,39 @@ export function build30040EventSet(
return new NDKEventClass(ndk, {
kind: 30041,
content: section,
tags: [
...tags,
['d', dTag],
['title', header],
],
tags: [...tags, ["d", dTag], ["title", header]],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(dTag => ['a', `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]);
console.log('A tags:', aTags);
const aTags = dTags.map(
(dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string],
);
console.log("A tags:", aTags);
// Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : 'index';
console.log('Index event:', { documentTitle, indexDTag });
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
const indexTags = [
...tags,
['d', indexDTag],
['title', documentTitle || 'Untitled'],
["d", indexDTag],
["title", documentTitle || "Untitled"],
...aTags,
];
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: '',
content: "",
tags: indexTags,
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log('Final index event:', indexEvent);
console.log('=== build30040EventSet completed ===');
console.log("Final index event:", indexEvent);
console.log("=== build30040EventSet completed ===");
return { indexEvent, sectionEvents };
}
@ -287,7 +318,10 @@ export function build30040EventSet( @@ -287,7 +318,10 @@ export function build30040EventSet(
* - 30041, 30818: AsciiDoc document header (first '= ' line)
* - 30023: Markdown topmost '# ' header
*/
export function getTitleTagForEvent(kind: number, content: string): string | null {
export function getTitleTagForEvent(
kind: number,
content: string,
): string | null {
if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content);
}
@ -303,23 +337,27 @@ export function getTitleTagForEvent(kind: number, content: string): string | nul @@ -303,23 +337,27 @@ export function getTitleTagForEvent(kind: number, content: string): string | nul
* - 30041, 30818: Normalized AsciiDoc document header
* - 30040: Uses existing d-tag or generates from content
*/
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null {
if (existingDTag && existingDTag.trim() !== '') {
export function getDTagForEvent(
kind: number,
content: string,
existingDTag?: string,
): string | null {
if (existingDTag && existingDTag.trim() !== "") {
return existingDTag.trim();
}
if (kind === 30023) {
const title = extractMarkdownTopHeader(content);
return title ? normalizeDTagValue(title) : null;
}
if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content);
return title ? normalizeDTagValue(title) : null;
}
return null;
}
}
/**
* Returns a description of what a 30040 event structure should be.
@ -332,52 +370,58 @@ export function get30040EventDescription(): string { @@ -332,52 +370,58 @@ export function get30040EventDescription(): string {
- A tags referencing 30041 content events (one per section)
The content is split into sections, each published as a separate 30041 event.`;
}
}
/**
* Analyzes a 30040 event to determine if it was created correctly.
* Returns { valid, issues } where issues is an array of problems found.
*/
export function analyze30040Event(event: { content: string; tags: [string, string][]; kind: number }): { valid: boolean; issues: string[] } {
export function analyze30040Event(event: {
content: string;
tags: [string, string][];
kind: number;
}): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check if it's actually a 30040 event
if (event.kind !== 30040) {
issues.push('Event is not kind 30040');
issues.push("Event is not kind 30040");
return { valid: false, issues };
}
// Check if content is empty (30040 should be metadata only)
if (event.content && event.content.trim() !== '') {
issues.push('30040 events should have empty content (metadata only)');
issues.push('Content should be split into separate 30041 events');
if (event.content && event.content.trim() !== "") {
issues.push("30040 events should have empty content (metadata only)");
issues.push("Content should be split into separate 30041 events");
}
// Check for required tags
const hasTitle = event.tags.some(([k, v]) => k === 'title' && v);
const hasDTag = event.tags.some(([k, v]) => k === 'd' && v);
const hasATags = event.tags.some(([k, v]) => k === 'a' && v);
const hasTitle = event.tags.some(([k, v]) => k === "title" && v);
const hasDTag = event.tags.some(([k, v]) => k === "d" && v);
const hasATags = event.tags.some(([k, v]) => k === "a" && v);
if (!hasTitle) {
issues.push('Missing title tag');
issues.push("Missing title tag");
}
if (!hasDTag) {
issues.push('Missing d tag');
issues.push("Missing d tag");
}
if (!hasATags) {
issues.push('Missing a tags (should reference 30041 content events)');
issues.push("Missing a tags (should reference 30041 content events)");
}
// Check if a tags have the correct format (kind:pubkey:d-tag)
const aTags = event.tags.filter(([k, v]) => k === 'a' && v);
const aTags = event.tags.filter(([k, v]) => k === "a" && v);
for (const [, value] of aTags) {
if (!value.includes(':')) {
issues.push(`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`);
if (!value.includes(":")) {
issues.push(
`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`,
);
}
}
return { valid: issues.length === 0, issues };
}
}
/**
* Returns guidance on how to fix incorrect 30040 events.
@ -397,4 +441,4 @@ export function get30040FixGuidance(): string { @@ -397,4 +441,4 @@ export function get30040FixGuidance(): string {
- Write your content with document title (= Title) and sections (== Section)
- The system will automatically split it into one 30040 index event and multiple 30041 content events
- The 30040 will have empty content and reference the 30041s via a tags`;
}
}

115
src/lib/utils/event_search.ts

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
import { ndkInstance } from '$lib/ndk';
import { fetchEventWithFallback } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { get } from 'svelte/store';
import { wellKnownUrl, isValidNip05Address } from './search_utils';
import { TIMEOUTS, VALIDATION } from './search_constants';
import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from "$lib/utils/nostrUtils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils";
import { TIMEOUTS, VALIDATION } from "./search_constants";
/**
* Search for a single event by ID or filter
@ -15,7 +15,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -15,7 +15,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
let filterOrId: any = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile)
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(cleanedQuery)) {
if (
new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(cleanedQuery)
) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback(
@ -40,7 +42,10 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -40,7 +42,10 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
return eventResult;
}
} else if (
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery)
new RegExp(
`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`,
"i",
).test(cleanedQuery)
) {
try {
const decoded = nip19.decode(cleanedQuery);
@ -102,7 +107,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -102,7 +107,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
/**
* Search for NIP-05 address
*/
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> {
export async function searchNip05(
nip05Address: string,
): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain");
@ -110,15 +117,15 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null @@ -110,15 +117,15 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null
try {
const [name, domain] = nip05Address.split("@");
const res = await fetch(wellKnownUrl(domain, name));
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] };
@ -130,14 +137,88 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null @@ -130,14 +137,88 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null
if (profileEvent) {
return profileEvent;
} else {
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`);
throw new Error(
`No profile found for ${name}@${domain} (pubkey: ${pubkey})`,
);
}
} else {
throw new Error(`NIP-05 address not found: ${name}@${domain}`);
}
} catch (e) {
console.error(`[Search] Error resolving NIP-05 address ${nip05Address}:`, e);
console.error(
`[Search] Error resolving NIP-05 address ${nip05Address}:`,
e,
);
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`);
}
}
}
/**
* Find containing 30040 index events for a given content event
* @param contentEvent The content event to find containers for (30041, 30818, etc.)
* @returns Array of containing 30040 index events
*/
export async function findContainingIndexEvents(
contentEvent: NDKEvent,
): Promise<NDKEvent[]> {
// Support all content event kinds that can be contained in indexes
const contentEventKinds = [30041, 30818, 30040, 30023];
if (!contentEventKinds.includes(contentEvent.kind)) {
return [];
}
try {
const ndk = get(ndkInstance);
// Search for 30040 events that reference this content event
// We need to search for events that have an 'a' tag or 'e' tag referencing this event
const contentEventId = contentEvent.id;
const contentEventAddress = contentEvent.tagAddress();
// Search for index events that reference this content event
const indexEvents = await ndk.fetchEvents(
{
kinds: [30040],
"#a": [contentEventAddress],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Also search for events with 'e' tags (legacy format)
const indexEventsWithETags = await ndk.fetchEvents(
{
kinds: [30040],
"#e": [contentEventId],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Combine and deduplicate results
const allIndexEvents = new Set([...indexEvents, ...indexEventsWithETags]);
// Filter to only include valid index events
const validIndexEvents = Array.from(allIndexEvents).filter((event) => {
// Check if it's a valid index event (has title, d tag, and either a or e tags)
const hasTitle = event.getMatchingTags("title").length > 0;
const hasDTag = event.getMatchingTags("d").length > 0;
const hasATags = event.getMatchingTags("a").length > 0;
const hasETags = event.getMatchingTags("e").length > 0;
return hasTitle && hasDTag && (hasATags || hasETags);
});
return validIndexEvents;
} catch (error) {
console.error("[Search] Error finding containing index events:", error);
return [];
}
}

31
src/lib/utils/image_utils.ts

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
/**
* Generate a dark-pastel color based on a string (like an event ID)
* @param seed - The string to generate a color from
* @returns A dark-pastel hex color
*/
export function generateDarkPastelColor(seed: string): string {
// Create a simple hash from the seed string
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Use the hash to generate lighter pastel colors
// Keep values in the 120-200 range for better pastel effect
const r = Math.abs(hash) % 80 + 120; // 120-200 range
const g = Math.abs(hash >> 8) % 80 + 120; // 120-200 range
const b = Math.abs(hash >> 16) % 80 + 120; // 120-200 range
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
/**
* Test function to verify color generation
* @param eventId - The event ID to test
* @returns The generated color
*/
export function testColorGeneration(eventId: string): string {
return generateDarkPastelColor(eventId);
}

38
src/lib/utils/indexEventCache.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants";
export interface IndexEventCacheEntry {
events: NDKEvent[];
@ -16,7 +16,7 @@ class IndexEventCache { @@ -16,7 +16,7 @@ class IndexEventCache {
* Generate a cache key based on relay URLs
*/
private generateKey(relayUrls: string[]): string {
return relayUrls.sort().join('|');
return relayUrls.sort().join("|");
}
/**
@ -32,15 +32,17 @@ class IndexEventCache { @@ -32,15 +32,17 @@ class IndexEventCache {
get(relayUrls: string[]): NDKEvent[] | null {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
if (!entry || this.isExpired(entry)) {
if (entry) {
this.cache.delete(key);
}
return null;
}
console.log(`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`);
console.log(
`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`,
);
return entry.events;
}
@ -49,7 +51,7 @@ class IndexEventCache { @@ -49,7 +51,7 @@ class IndexEventCache {
*/
set(relayUrls: string[], events: NDKEvent[]): void {
const key = this.generateKey(relayUrls);
// Implement LRU eviction if cache is full
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
@ -57,14 +59,16 @@ class IndexEventCache { @@ -57,14 +59,16 @@ class IndexEventCache {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
events,
timestamp: Date.now(),
relayUrls: [...relayUrls]
relayUrls: [...relayUrls],
});
console.log(`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`);
console.log(
`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`,
);
}
/**
@ -105,21 +109,25 @@ class IndexEventCache { @@ -105,21 +109,25 @@ class IndexEventCache {
/**
* Get cache statistics
*/
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } {
getStats(): {
size: number;
totalEvents: number;
oldestEntry: number | null;
} {
let totalEvents = 0;
let oldestTimestamp: number | null = null;
for (const entry of this.cache.values()) {
totalEvents += entry.events.length;
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp;
}
}
return {
size: this.cache.size,
totalEvents,
oldestEntry: oldestTimestamp
oldestEntry: oldestTimestamp,
};
}
}
@ -129,4 +137,4 @@ export const indexEventCache = new IndexEventCache(); @@ -129,4 +137,4 @@ export const indexEventCache = new IndexEventCache();
// Clean up expired entries periodically
setInterval(() => {
indexEventCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

10
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -60,7 +60,7 @@ function fixAllMathBlocks(html: string): string { @@ -60,7 +60,7 @@ function fixAllMathBlocks(html: string): string {
return `<span class="math-inline">$${trimmedCode}$</span>`;
}
return match; // Return original if not LaTeX
}
},
);
// Also process code blocks without language class
@ -72,7 +72,7 @@ function fixAllMathBlocks(html: string): string { @@ -72,7 +72,7 @@ function fixAllMathBlocks(html: string): string {
return `<span class="math-inline">$${trimmedCode}$</span>`;
}
return match; // Return original if not LaTeX
}
},
);
return html;
@ -83,7 +83,7 @@ function fixAllMathBlocks(html: string): string { @@ -83,7 +83,7 @@ function fixAllMathBlocks(html: string): string {
*/
function isLaTeXContent(content: string): boolean {
const trimmed = content.trim();
// Check for common LaTeX patterns
const latexPatterns = [
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
@ -138,8 +138,8 @@ function isLaTeXContent(content: string): boolean { @@ -138,8 +138,8 @@ function isLaTeXContent(content: string): boolean {
/\\mathfrak\{/, // Fraktur
/\\mathscr\{/, // Script
];
return latexPatterns.some(pattern => pattern.test(trimmed));
return latexPatterns.some((pattern) => pattern.test(trimmed));
}
/**

39
src/lib/utils/markup/advancedMarkupParser.ts

@ -10,18 +10,19 @@ hljs.configure({ @@ -10,18 +10,19 @@ hljs.configure({
// Escapes HTML characters for safe display
function escapeHtml(text: string): string {
const div = typeof document !== 'undefined' ? document.createElement('div') : null;
const div =
typeof document !== "undefined" ? document.createElement("div") : null;
if (div) {
div.textContent = text;
return div.innerHTML;
}
// Fallback for non-browser environments
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Regular expressions for advanced markup elements
@ -406,7 +407,7 @@ function processDollarMath(content: string): string { @@ -406,7 +407,7 @@ function processDollarMath(content: string): string {
return `<div class="math-block">$$${expr}$$</div>`;
} else {
// Strip all $ or $$ from AsciiMath
const clean = expr.replace(/\$+/g, '').trim();
const clean = expr.replace(/\$+/g, "").trim();
return `<div class="math-block" data-math-type="asciimath">${clean}</div>`;
}
});
@ -415,7 +416,7 @@ function processDollarMath(content: string): string { @@ -415,7 +416,7 @@ function processDollarMath(content: string): string {
if (isLaTeXContent(expr)) {
return `<span class="math-inline">$${expr}$</span>`;
} else {
const clean = expr.replace(/\$+/g, '').trim();
const clean = expr.replace(/\$+/g, "").trim();
return `<span class="math-inline" data-math-type="asciimath">${clean}</span>`;
}
});
@ -447,19 +448,19 @@ function processMathExpressions(content: string): string { @@ -447,19 +448,19 @@ function processMathExpressions(content: string): string {
// Detect LaTeX display math (\\[...\\])
if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\\\[|\\\]$/g, '');
const inner = trimmedCode.replace(/^\\\[|\\\]$/g, "");
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect display math ($$...$$)
if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$\$|\$\$$/g, '');
const inner = trimmedCode.replace(/^\$\$|\$\$$/g, "");
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect inline math ($...$)
if (/^\$[\s\S]*\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$|\$$/g, '');
const inner = trimmedCode.replace(/^\$|\$$/g, "");
return `<span class="math-inline">$${inner}$</span>`;
}
// Default to inline math for any other LaTeX content
@ -511,7 +512,7 @@ function processMathExpressions(content: string): string { @@ -511,7 +512,7 @@ function processMathExpressions(content: string): string {
];
// If it matches code patterns, treat as regular code
if (codePatterns.some(pattern => pattern.test(trimmedCode))) {
if (codePatterns.some((pattern) => pattern.test(trimmedCode))) {
const escapedCode = trimmedCode
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
@ -538,27 +539,27 @@ function processMathExpressions(content: string): string { @@ -538,27 +539,27 @@ function processMathExpressions(content: string): string {
*/
function isLaTeXContent(content: string): boolean {
const trimmed = content.trim();
// Check for simple math expressions first (like AsciiMath)
if (/^\$[^$]+\$$/.test(trimmed)) {
return true;
}
// Check for display math
if (/^\$\$[\s\S]*\$\$$/.test(trimmed)) {
return true;
}
// Check for LaTeX display math
if (/^\\\[[\s\S]*\\\]$/.test(trimmed)) {
return true;
}
// Check for LaTeX environments with double backslashes (like tabular)
if (/\\\\begin\{[^}]+\}/.test(trimmed) || /\\\\end\{[^}]+\}/.test(trimmed)) {
return true;
}
// Check for common LaTeX patterns
const latexPatterns = [
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
@ -684,8 +685,8 @@ function isLaTeXContent(content: string): boolean { @@ -684,8 +685,8 @@ function isLaTeXContent(content: string): boolean {
/\\mathscr\{/, // Script
/\\\\mathscr\{/, // Script with double backslashes
];
return latexPatterns.some(pattern => pattern.test(trimmed));
return latexPatterns.some((pattern) => pattern.test(trimmed));
}
/**

13
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -35,14 +35,11 @@ function replaceWikilinks(html: string): string { @@ -35,14 +35,11 @@ function replaceWikilinks(html: string): string {
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(
/<a id="([^"]+)"><\/a>/g,
(_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
}
);
return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}
/**

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

@ -412,7 +412,11 @@ export async function parseBasicmarkup(text: string): Promise<string> { @@ -412,7 +412,11 @@ export async function parseBasicmarkup(text: string): Promise<string> {
.filter((para) => para.length > 0)
.map((para) => {
// Skip wrapping if para already contains block-level elements or math blocks
if (/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(para)) {
if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(
para,
)
) {
return para;
}
return `<p class="my-4">${para}</p>`;

19
src/lib/utils/mime.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { EVENT_KINDS } from './search_constants';
import { EVENT_KINDS } from "./search_constants";
/**
* Determine the type of Nostr event based on its kind number
@ -12,16 +12,25 @@ export function getEventType( @@ -12,16 +12,25 @@ export function getEventType(
kind: number,
): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first
if (kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind < EVENT_KINDS.ADDRESSABLE.MAX) {
if (
kind >= EVENT_KINDS.ADDRESSABLE.MIN &&
kind < EVENT_KINDS.ADDRESSABLE.MAX
) {
return "addressable";
}
if (kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN && kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX) {
if (
kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN &&
kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX
) {
return "ephemeral";
}
if ((kind >= EVENT_KINDS.REPLACEABLE.MIN && kind < EVENT_KINDS.REPLACEABLE.MAX) ||
EVENT_KINDS.REPLACEABLE.SPECIFIC.includes(kind as 0 | 3)) {
if (
(kind >= EVENT_KINDS.REPLACEABLE.MIN &&
kind < EVENT_KINDS.REPLACEABLE.MAX) ||
EVENT_KINDS.REPLACEABLE.SPECIFIC.includes(kind as 0 | 3)
) {
return "replaceable";
}

189
src/lib/utils/network_detection.ts

@ -0,0 +1,189 @@ @@ -0,0 +1,189 @@
import { deduplicateRelayUrls } from './relay_management';
/**
* Network conditions for relay selection
*/
export enum NetworkCondition {
ONLINE = 'online',
SLOW = 'slow',
OFFLINE = 'offline'
}
/**
* Network connectivity test endpoints
*/
const NETWORK_ENDPOINTS = [
'https://www.google.com/favicon.ico',
'https://httpbin.org/status/200',
'https://api.github.com/zen'
];
/**
* Detects if the network is online using more reliable endpoints
* @returns Promise that resolves to true if online, false otherwise
*/
export async function isNetworkOnline(): Promise<boolean> {
for (const endpoint of NETWORK_ENDPOINTS) {
try {
// Use a simple fetch without HEAD method to avoid CORS issues
const response = await fetch(endpoint, {
method: 'GET',
cache: 'no-cache',
signal: AbortSignal.timeout(3000),
mode: 'no-cors' // Use no-cors mode to avoid CORS issues
});
// With no-cors mode, we can't check response.ok, so we assume success if no error
return true;
} catch (error) {
console.debug(`[network_detection.ts] Failed to reach ${endpoint}:`, error);
continue;
}
}
console.debug('[network_detection.ts] All network endpoints failed');
return false;
}
/**
* Tests network speed by measuring response time
* @returns Promise that resolves to network speed in milliseconds
*/
export async function testNetworkSpeed(): Promise<number> {
const startTime = performance.now();
for (const endpoint of NETWORK_ENDPOINTS) {
try {
await fetch(endpoint, {
method: 'GET',
cache: 'no-cache',
signal: AbortSignal.timeout(5000),
mode: 'no-cors' // Use no-cors mode to avoid CORS issues
});
const endTime = performance.now();
return endTime - startTime;
} catch (error) {
console.debug(`[network_detection.ts] Speed test failed for ${endpoint}:`, error);
continue;
}
}
console.debug('[network_detection.ts] Network speed test failed for all endpoints');
return Infinity; // Very slow if it fails
}
/**
* Determines network condition based on connectivity and speed
* @returns Promise that resolves to NetworkCondition
*/
export async function detectNetworkCondition(): Promise<NetworkCondition> {
const isOnline = await isNetworkOnline();
if (!isOnline) {
console.debug('[network_detection.ts] Network condition: OFFLINE');
return NetworkCondition.OFFLINE;
}
const speed = await testNetworkSpeed();
// Consider network slow if response time > 2000ms
if (speed > 2000) {
console.debug(`[network_detection.ts] Network condition: SLOW (${speed.toFixed(0)}ms)`);
return NetworkCondition.SLOW;
}
console.debug(`[network_detection.ts] Network condition: ONLINE (${speed.toFixed(0)}ms)`);
return NetworkCondition.ONLINE;
}
/**
* Gets the appropriate relay sets based on network condition
* @param networkCondition The detected network condition
* @param discoveredLocalRelays Array of discovered local relay URLs
* @param lowbandwidthRelays Array of low bandwidth relay URLs
* @param fullRelaySet The complete relay set for normal conditions
* @returns Object with inbox and outbox relay arrays
*/
export function getRelaySetForNetworkCondition(
networkCondition: NetworkCondition,
discoveredLocalRelays: string[],
lowbandwidthRelays: string[],
fullRelaySet: { inboxRelays: string[]; outboxRelays: string[] }
): { inboxRelays: string[]; outboxRelays: string[] } {
switch (networkCondition) {
case NetworkCondition.OFFLINE:
// When offline, use local relays if available, otherwise rely on cache
// This will be improved when IndexedDB local relay is implemented
if (discoveredLocalRelays.length > 0) {
console.debug('[network_detection.ts] Using local relays (offline)');
return {
inboxRelays: discoveredLocalRelays,
outboxRelays: discoveredLocalRelays
};
} else {
console.debug('[network_detection.ts] No local relays available, will rely on cache (offline)');
return {
inboxRelays: [],
outboxRelays: []
};
}
case NetworkCondition.SLOW:
// Local relays + low bandwidth relays when slow (deduplicated)
console.debug('[network_detection.ts] Using local + low bandwidth relays (slow network)');
const slowInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]);
const slowOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...lowbandwidthRelays]);
return {
inboxRelays: slowInboxRelays,
outboxRelays: slowOutboxRelays
};
case NetworkCondition.ONLINE:
default:
// Full relay set when online
console.debug('[network_detection.ts] Using full relay set (online)');
return fullRelaySet;
}
}
/**
* Starts periodic network monitoring with reduced frequency to avoid spam
* @param onNetworkChange Callback function called when network condition changes
* @param checkInterval Interval in milliseconds between network checks (default: 60 seconds)
* @returns Function to stop the monitoring
*/
export function startNetworkMonitoring(
onNetworkChange: (condition: NetworkCondition) => void,
checkInterval: number = 60000 // Increased to 60 seconds to reduce spam
): () => void {
let lastCondition: NetworkCondition | null = null;
let intervalId: number | null = null;
const checkNetwork = async () => {
try {
const currentCondition = await detectNetworkCondition();
if (currentCondition !== lastCondition) {
console.debug(`[network_detection.ts] Network condition changed: ${lastCondition} -> ${currentCondition}`);
lastCondition = currentCondition;
onNetworkChange(currentCondition);
}
} catch (error) {
console.warn('[network_detection.ts] Network monitoring error:', error);
}
};
// Initial check
checkNetwork();
// Set up periodic monitoring
intervalId = window.setInterval(checkNetwork, checkInterval);
// Return function to stop monitoring
return () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
}

354
src/lib/utils/nostrEventService.ts

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./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 "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from "./search_constants";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { ndkInstance } from "$lib/ndk";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
export interface RootEventInfo {
rootId: string;
@ -44,16 +44,20 @@ function findTag(tags: string[][], tagName: string): string[] | undefined { @@ -44,16 +44,20 @@ function findTag(tags: string[][], tagName: string): string[] | undefined {
/**
* Helper function to get tag value safely
*/
function getTagValue(tags: string[][], tagName: string, index: number = 1): string {
function getTagValue(
tags: string[][],
tagName: string,
index: number = 1,
): string {
const tag = findTag(tags, tagName);
return tag?.[index] || '';
return tag?.[index] || "";
}
/**
* Helper function to create a tag array
*/
function createTag(name: string, ...values: (string | number)[]): string[] {
return [name, ...values.map(v => String(v))];
return [name, ...values.map((v) => String(v))];
}
/**
@ -72,36 +76,41 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -72,36 +76,41 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
rootPubkey: getPubkeyString(parent.pubkey),
rootRelay: getRelayString(parent.relay),
rootKind: parent.kind || 1,
rootAddress: '',
rootIValue: '',
rootIRelay: '',
rootAddress: "",
rootIValue: "",
rootIRelay: "",
isRootA: false,
isRootI: false,
};
if (!parent.tags) return rootInfo;
const rootE = findTag(parent.tags, 'E');
const rootA = findTag(parent.tags, 'A');
const rootI = findTag(parent.tags, 'I');
const rootE = findTag(parent.tags, "E");
const rootA = findTag(parent.tags, "A");
const rootI = findTag(parent.tags, "I");
rootInfo.isRootA = !!rootA;
rootInfo.isRootI = !!rootI;
if (rootE) {
rootInfo.rootId = rootE[1];
rootInfo.rootRelay = getRelayString(rootE[2]);
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootA) {
rootInfo.rootAddress = rootA[1];
rootInfo.rootRelay = getRelayString(rootA[2]);
rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootPubkey = getPubkeyString(
getTagValue(parent.tags, "P") || rootInfo.rootPubkey,
);
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootI) {
rootInfo.rootIValue = rootI[1];
rootInfo.rootIRelay = getRelayString(rootI[2]);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
}
return rootInfo;
@ -111,9 +120,11 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -111,9 +120,11 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
* Extract parent event information
*/
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
const dTag = getTagValue(parent.tags || [], 'd');
const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : '';
const dTag = getTagValue(parent.tags || [], "d");
const parentAddress = dTag
? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}`
: "";
return {
parentId: parent.id,
parentPubkey: getPubkeyString(parent.pubkey),
@ -126,45 +137,62 @@ export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo { @@ -126,45 +137,62 @@ export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
/**
* Build root scope tags for NIP-22 threading
*/
function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] {
function buildRootScopeTags(
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
): string[][] {
const tags: string[][] = [];
if (rootInfo.rootAddress) {
const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E';
addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay));
const tagType = rootInfo.isRootA ? "A" : rootInfo.isRootI ? "I" : "E";
addTags(
tags,
createTag(
tagType,
rootInfo.rootAddress || rootInfo.rootId,
rootInfo.rootRelay,
),
);
} else if (rootInfo.rootIValue) {
addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay));
addTags(tags, createTag("I", rootInfo.rootIValue, rootInfo.rootIRelay));
} else {
addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay));
addTags(tags, createTag("E", rootInfo.rootId, rootInfo.rootRelay));
}
addTags(tags, createTag('K', rootInfo.rootKind));
addTags(tags, createTag("K", rootInfo.rootKind));
if (rootInfo.rootPubkey && !rootInfo.rootIValue) {
addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay));
addTags(tags, createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay));
}
return tags;
}
/**
* Build parent scope tags for NIP-22 threading
*/
function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] {
function buildParentScopeTags(
parent: NDKEvent,
parentInfo: ParentEventInfo,
rootInfo: RootEventInfo,
): string[][] {
const tags: string[][] = [];
if (parentInfo.parentAddress) {
const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e';
addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay));
const tagType = rootInfo.isRootA ? "a" : rootInfo.isRootI ? "i" : "e";
addTags(
tags,
createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay),
);
}
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
return tags;
}
@ -175,63 +203,65 @@ export function buildReplyTags( @@ -175,63 +203,65 @@ export function buildReplyTags(
parent: NDKEvent,
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
kind: number
kind: number,
): string[][] {
const tags: string[][] = [];
const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentReplaceable =
parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN &&
parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
// Kind 1 replies use simple e/p tags
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay, 'root'),
createTag('p', parentInfo.parentPubkey)
createTag("e", parent.id, parentInfo.parentRelay, "root"),
createTag("p", parentInfo.parentPubkey),
);
// Add address for replaceable events
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
addTags(tags, createTag('a', parentAddress, '', 'root'));
addTags(tags, createTag("a", parentAddress, "", "root"));
}
}
} else {
// Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
if (isReplyToComment) {
// Root scope (uppercase) - use the original article
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
);
// Parent scope (lowercase) - the comment we're replying to
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment - root and parent are the same
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('a', parentAddress, parentInfo.parentRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("a", parentAddress, parentInfo.parentRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
} else {
@ -239,22 +269,22 @@ export function buildReplyTags( @@ -239,22 +269,22 @@ export function buildReplyTags(
if (isReplyToComment) {
addTags(
tags,
createTag('E', rootInfo.rootId, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("E", rootInfo.rootId, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
addTags(
tags,
createTag('E', parent.id, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("E", parent.id, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
}
@ -265,9 +295,9 @@ export function buildReplyTags( @@ -265,9 +295,9 @@ export function buildReplyTags(
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment or regular event
@ -287,23 +317,30 @@ export async function createSignedEvent( @@ -287,23 +317,30 @@ export async function createSignedEvent(
content: string,
pubkey: string,
kind: number,
tags: string[][]
tags: string[][],
): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content);
const eventToSign = {
kind: Number(kind),
created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
created_at: Number(
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR),
),
tags: tags.map((tag) => [
String(tag[0]),
String(tag[1]),
String(tag[2] || ""),
String(tag[3] || ""),
]),
content: String(prefixedContent),
pubkey: pubkey,
};
let sig, id;
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(eventToSign);
sig = signed.sig as string;
id = 'id' in signed ? signed.id as string : getEventHash(eventToSign);
id = "id" in signed ? (signed.id as string) : getEventHash(eventToSign);
} else {
id = getEventHash(eventToSign);
sig = await signEvent(eventToSign);
@ -316,106 +353,91 @@ export async function createSignedEvent( @@ -316,106 +353,91 @@ export async function createSignedEvent(
...eventToSign,
id,
sig,
}
},
};
}
/**
* Publish event to a single relay
*/
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> {
const ws = new WebSocket(relayUrl);
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, TIMEOUTS.GENERAL);
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) {
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
}
/**
* Publish event to relays
* Publishes an event to relays using the new relay management system
* @param event The event to publish (can be NDKEvent or plain event object)
* @param relayUrls Array of relay URLs to publish to
* @returns Promise that resolves to array of successful relay URLs
*/
export async function publishEvent(
signedEvent: any,
useOtherRelays = false,
useFallbackRelays = false,
userRelayPreference = false
): Promise<EventPublishResult> {
// Determine which relays to use
let relays = userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
event: NDKEvent | any,
relayUrls: string[],
): Promise<string[]> {
const successfulRelays: string[] = [];
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK instance not available");
}
// Try to publish to relays
for (const relayUrl of relays) {
try {
await publishToRelay(relayUrl, signedEvent);
return {
success: true,
relay: relayUrl,
eventId: signedEvent.id
};
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
// Create relay set from URLs
const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk);
try {
// If event is a plain object, create an NDKEvent from it
let ndkEvent: NDKEvent;
if (event.publish && typeof event.publish === 'function') {
// It's already an NDKEvent
ndkEvent = event;
} else {
// It's a plain event object, create NDKEvent
ndkEvent = new NDKEvent(ndk, event);
}
// Publish with timeout
await ndkEvent.publish(relaySet).withTimeout(5000);
// For now, assume all relays were successful
// In a more sophisticated implementation, you'd track individual relay responses
successfulRelays.push(...relayUrls);
console.debug("[nostrEventService] Published event successfully:", {
eventId: ndkEvent.id,
relayCount: relayUrls.length,
successfulRelays
});
} catch (error) {
console.error("[nostrEventService] Failed to publish event:", error);
throw new Error(`Failed to publish event: ${error}`);
}
return {
success: false,
error: "Failed to publish to any relays"
};
return successfulRelays;
}
/**
* Navigate to the published event
*/
export function navigateToEvent(eventId: string): void {
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
try {
// Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn("Invalid event ID format:", eventId);
return;
}
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
} catch (error) {
console.error("Failed to encode event ID for navigation:", eventId, error);
}
}
// Helper functions to ensure relay and pubkey are always strings
function getRelayString(relay: any): string {
if (!relay) return '';
if (typeof relay === 'string') return relay;
if (typeof relay.url === 'string') return relay.url;
return '';
if (!relay) return "";
if (typeof relay === "string") return relay;
if (typeof relay.url === "string") return relay.url;
return "";
}
function getPubkeyString(pubkey: any): string {
if (!pubkey) return '';
if (typeof pubkey === 'string') return pubkey;
if (typeof pubkey.hex === 'function') return pubkey.hex();
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey;
return '';
}
if (!pubkey) return "";
if (typeof pubkey === "string") return pubkey;
if (typeof pubkey.hex === "function") return pubkey.hex();
if (typeof pubkey.pubkey === "string") return pubkey.pubkey;
return "";
}

313
src/lib/utils/nostrUtils.ts

@ -4,13 +4,14 @@ import { ndkInstance } from "$lib/ndk"; @@ -4,13 +4,14 @@ import { ndkInstance } from "$lib/ndk";
import { npubCache } from "./npubCache";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
import { communityRelays, secondaryRelays, anonymousRelays } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
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";
import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants';
import { TIMEOUTS, VALIDATION } from "./search_constants";
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>';
@ -54,12 +55,17 @@ function escapeHtml(text: string): string { @@ -54,12 +55,17 @@ function escapeHtml(text: string): string {
*/
export async function getUserMetadata(
identifier: string,
force = false,
): Promise<NostrProfile> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, "");
if (npubCache.has(cleanId)) {
return npubCache.get(cleanId)!;
console.log("getUserMetadata called with identifier:", identifier, "force:", force);
if (!force && npubCache.has(cleanId)) {
const cached = npubCache.get(cleanId)!;
console.log("getUserMetadata returning cached profile:", cached);
return cached;
}
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
@ -67,12 +73,14 @@ export async function getUserMetadata( @@ -67,12 +73,14 @@ export async function getUserMetadata(
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.warn("getUserMetadata: No NDK instance available");
npubCache.set(cleanId, fallback);
return fallback;
}
const decoded = nip19.decode(cleanId);
if (!decoded) {
console.warn("getUserMetadata: Failed to decode identifier:", cleanId);
npubCache.set(cleanId, fallback);
return fallback;
}
@ -84,33 +92,43 @@ export async function getUserMetadata( @@ -84,33 +92,43 @@ export async function getUserMetadata(
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("getUserMetadata: Unsupported identifier type:", decoded.type);
npubCache.set(cleanId, fallback);
return fallback;
}
console.log("getUserMetadata: Fetching profile for pubkey:", pubkey);
const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
console.log("getUserMetadata: Profile event found:", profileEvent);
const profile =
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content)
: null;
console.log("getUserMetadata: Parsed profile:", profile);
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05,
picture: profile?.image,
picture: profile?.picture || profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16,
};
console.log("getUserMetadata: Final metadata:", metadata);
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
console.error("getUserMetadata: Error fetching profile:", e);
npubCache.set(cleanId, fallback);
return fallback;
}
@ -128,6 +146,7 @@ export function createProfileLink( @@ -128,6 +146,7 @@ export function createProfileLink(
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
// Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
}
@ -157,12 +176,28 @@ export async function createProfileLinkWithVerification( @@ -157,12 +176,28 @@ export async function createProfileLinkWithVerification(
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
// Filter out problematic relays
const filterProblematicRelays = (relays: string[]) => {
return relays.filter((relay) => {
if (relay.includes("gitcitadel.nostr1.com")) {
console.info(
`[nostrUtils.ts] Filtering out problematic relay: ${relay}`,
);
return false;
}
return true;
});
};
const allRelays = [
...standardRelays,
...communityRelays,
...userRelays,
...fallbackRelays,
...secondaryRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const filteredRelays = filterProblematicRelays(allRelays);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(filteredRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
@ -267,32 +302,74 @@ export async function processNostrIdentifiers( @@ -267,32 +302,74 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
// Parse the NIP-05 address
const [name, domain] = nip05.split('@');
const [name, domain] = nip05.split("@");
if (!name || !domain) {
console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05);
console.error("[getNpubFromNip05] Invalid NIP-05 format:", nip05);
return null;
}
// Fetch the well-known.json file
// Fetch the well-known.json file with timeout and CORS handling
const url = wellKnownUrl(domain, name);
const response = await fetch(url);
if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
return null;
}
const data = await response.json();
const pubkey = data.names?.[name];
if (!pubkey) {
console.error('[getNpubFromNip05] No pubkey found for name:', name);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
try {
const response = await fetch(url, {
signal: controller.signal,
mode: "cors",
headers: {
Accept: "application/json",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error(
"[getNpubFromNip05] HTTP error:",
response.status,
response.statusText,
);
return null;
}
const data = await response.json();
// Try exact match first
let pubkey = data.names?.[name];
// If not found, try case-insensitive search
if (!pubkey && data.names) {
const names = Object.keys(data.names);
const matchingName = names.find(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (matchingName) {
pubkey = data.names[matchingName];
console.log(
`[getNpubFromNip05] Found case-insensitive match: ${name} -> ${matchingName}`,
);
}
}
if (!pubkey) {
console.error("[getNpubFromNip05] No pubkey found for name:", name);
return null;
}
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === "AbortError") {
console.warn("[getNpubFromNip05] Request timeout for:", url);
} else {
console.warn("[getNpubFromNip05] CORS or network error for:", url);
}
return null;
}
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (error) {
console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null;
@ -362,98 +439,75 @@ export async function fetchEventWithFallback( @@ -362,98 +439,75 @@ export async function fetchEventWithFallback(
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)
: [];
// Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser;
const primaryRelays = isSignedIn ? standardRelays : anonymousRelays;
// Create three relay sets in priority order
const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort)
];
// Use both inbox and outbox relays for better event discovery
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
const allRelays = [...(inboxRelays || []), ...(outboxRelays || [])];
console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
// Check if we have any relays available
if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch");
return null;
}
// Create relay set from all available relays
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
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" &&
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, '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;
}
if (relaySet.relays.size === 0) {
console.warn("fetchEventWithFallback: No relays in relay set for event fetch");
return null;
}
// Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) {
const setName =
index === 0
? isSignedIn
? "standard relays"
: "anonymous relays"
: index === 1
? "user relays"
: "fallback relays";
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
console.log("fetchEventWithFallback: Relay set size:", relaySet.relays.size);
console.log("fetchEventWithFallback: Filter:", filterOrId);
console.log("fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r: any) => r.url));
let found: NDKEvent | null = null;
if (
typeof filterOrId === "string" &&
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, "i").test(filterOrId)
) {
found = 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);
found = results instanceof Set
? (Array.from(results)[0] as NDKEvent)
: null;
}
if (!found) {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets
.map((set, i) => {
const setName =
i === 0
? isSignedIn
? "standard relays"
: "anonymous 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 ");
const relayUrls = Array.from(relaySet.relays).map((r: any) => r.url).join(", ");
console.warn(
`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`,
`fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
);
return null;
}
console.log("fetchEventWithFallback: Found event:", found.id);
// Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) {
console.error("Error in fetchEventWithFallback:", err);
if (err instanceof Error && err.message === 'Timeout') {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r: any) => r.url).join(", ");
console.warn(
`fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
);
} else {
console.error("fetchEventWithFallback: Error in fetchEventWithFallback:", err);
}
return null;
}
}
@ -464,7 +518,7 @@ export async function fetchEventWithFallback( @@ -464,7 +518,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(pubkey)) {
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith("npub1")) return pubkey;
@ -527,76 +581,89 @@ export async function signEvent(event: { @@ -527,76 +581,89 @@ export async function signEvent(event: {
}
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink
*/
export function prefixNostrAddresses(content: string): string {
// Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link
// Must be followed by at least 20 alphanumeric characters to be considered an address
const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
const nostrAddressPattern =
/\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url)
const beforeMatch = content.substring(0, offset);
const afterMatch = content.substring(offset + match.length);
// Check if it's part of a markdown link
const beforeBrackets = beforeMatch.lastIndexOf('[');
const afterParens = afterMatch.indexOf(')');
const beforeBrackets = beforeMatch.lastIndexOf("[");
const afterParens = afterMatch.indexOf(")");
if (beforeBrackets !== -1 && afterParens !== -1) {
const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
const lastOpenBracket = textBeforeBrackets.lastIndexOf('[');
const lastCloseBracket = textBeforeBrackets.lastIndexOf(']');
const lastOpenBracket = textBeforeBrackets.lastIndexOf("[");
const lastCloseBracket = textBeforeBrackets.lastIndexOf("]");
// If we have [text] before this, it might be a markdown link
if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
return match; // Don't prefix if it's part of a markdown link
}
}
// Check if it's part of an HTML link
const beforeHref = beforeMatch.lastIndexOf('href=');
const beforeHref = beforeMatch.lastIndexOf("href=");
if (beforeHref !== -1) {
const afterHref = afterMatch.indexOf('"');
if (afterHref !== -1) {
return match; // Don't prefix if it's part of an HTML link
}
}
// Check if it's already prefixed with "nostr:"
const beforeNostr = beforeMatch.lastIndexOf('nostr:');
const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(' ')) {
if (!textAfterNostr.includes(" ")) {
return match; // Already prefixed
}
}
// Additional check: ensure it's actually a valid Nostr address format
// The part after the prefix should be a valid bech32 string
const addressPart = match.substring(4); // Remove npub, nprofile, etc.
if (addressPart.length < 20) {
return match; // Too short to be a valid address
}
// Check if it looks like a valid bech32 string (alphanumeric, no special chars)
if (!/^[a-zA-Z0-9]+$/.test(addressPart)) {
return match; // Not a valid bech32 format
}
// Additional check: ensure the word before is not a common word that would indicate
// this is just a general reference, not an actual address
const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
if (wordBefore) {
const beforeWord = wordBefore[1].toLowerCase();
const commonWords = ['the', 'a', 'an', 'this', 'that', 'my', 'your', 'his', 'her', 'their', 'our'];
const commonWords = [
"the",
"a",
"an",
"this",
"that",
"my",
"your",
"his",
"her",
"their",
"our",
];
if (commonWords.includes(beforeWord)) {
return match; // Likely just a general reference, not an actual address
}
}
// Prefix with "nostr:"
return `nostr:${match}`;
});

466
src/lib/utils/profile_search.ts

@ -1,233 +1,397 @@ @@ -1,233 +1,397 @@
import { ndkInstance } from '$lib/ndk';
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelay } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
import { checkCommunityStatus } from './community_checker';
import { TIMEOUTS } from './search_constants';
import { ndkInstance } from "$lib/ndk";
import { getUserMetadata, getNpubFromNip05 } from "$lib/utils/nostrUtils";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache";
import { communityRelays, secondaryRelays } from "$lib/consts";
import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types";
import {
fieldMatches,
nip05Matches,
normalizeSearchTerm,
COMMON_DOMAINS,
createProfileFromEvent,
} from "./search_utils";
import { checkCommunityStatus } from "./community_checker";
import { TIMEOUTS } from "./search_constants";
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
export async function searchProfiles(
searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log(
"searchProfiles called with:",
searchTerm,
"normalized:",
normalizedSearchTerm,
);
// Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm);
const cachedResult = searchCache.get("profile", normalizedSearchTerm);
if (cachedResult) {
const profiles = cachedResult.events.map(event => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
}).filter(Boolean) as NostrProfile[];
const communityStatus = await checkCommunityStatus(profiles);
return { profiles, Status: communityStatus };
console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
})
.filter(Boolean) as NostrProfile[];
console.log("Cached profiles found:", profiles.length);
return { profiles, Status: {} };
}
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error('NDK not initialized');
console.error("NDK not initialized");
throw new Error("NDK not initialized");
}
let foundProfiles: NostrProfile[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
console.log("NDK initialized, starting search logic");
// Set a timeout to force completion after profile search timeout
timeoutId = setTimeout(() => {
if (foundProfiles.length === 0) {
// Timeout reached, but no need to log this
}
}, TIMEOUTS.PROFILE_SEARCH);
let foundProfiles: NostrProfile[] = [];
try {
// Check if it's a valid npub/nprofile first
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) {
if (
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile")
) {
try {
const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) {
foundProfiles = [metadata];
}
} catch (error) {
console.error('Error fetching metadata for npub:', error);
console.error("Error fetching metadata for npub:", error);
}
} else if (normalizedSearchTerm.includes('@')) {
// Check if it's a NIP-05 address
} else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try {
const npub = await getNpubFromNip05(normalizedSearchTerm);
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
pubkey: npub,
};
foundProfiles = [profile];
}
} catch (e) {
console.error('[Search] NIP-05 lookup failed:', e);
// If NIP-05 lookup fails, continue with regular search
console.error("[Search] NIP-05 lookup failed:", e);
}
} else {
// Try searching for NIP-05 addresses that match the search term
// Try NIP-05 search first (faster than relay search)
console.log("Starting NIP-05 search for:", normalizedSearchTerm);
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
console.log(
"NIP-05 search completed, found:",
foundProfiles.length,
"profiles",
);
// If no NIP-05 results found, search for profiles across relays
// If no NIP-05 results, try quick relay search
if (foundProfiles.length === 0) {
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk);
console.log("No NIP-05 results, trying quick relay search");
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
console.log(
"Quick relay search completed, found:",
foundProfiles.length,
"profiles",
);
}
}
// Wait for search to complete or timeout
await new Promise<void>((resolve) => {
const checkComplete = () => {
if (timeoutId === null || foundProfiles.length > 0) {
resolve();
} else {
setTimeout(checkComplete, 100);
}
};
checkComplete();
});
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => {
const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || '';
event.pubkey = profile.pubkey || "";
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'profile',
searchTerm: normalizedSearchTerm
searchType: "profile",
searchTerm: normalizedSearchTerm,
};
searchCache.set('profile', normalizedSearchTerm, result);
searchCache.set("profile", normalizedSearchTerm, result);
}
// Check community status for all profiles
const communityStatus = await checkCommunityStatus(foundProfiles);
return { profiles: foundProfiles, Status: communityStatus };
console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
} catch (error) {
console.error('Error searching profiles:', error);
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
async function searchNip05Domains(
searchTerm: string,
ndk: any,
): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups
// Prioritize gitcitadel.com since we know it has profiles
const commonDomains = [
"gitcitadel.com", // Prioritize this domain
"theforest.nostr1.com",
"nostr1.com",
"nostr.land",
"sovbit.host",
"damus.io",
"snort.social",
"iris.to",
"coracle.social",
"nostr.band",
"nostr.wine",
"purplepag.es",
"relay.noswhere.com",
"aggr.nostr.land",
"nostr.sovbit.host",
"freelay.sovbit.host",
"nostr21.com",
"greensoul.space",
"relay.damus.io",
"relay.nostr.band",
];
// Normalize the search term for NIP-05 lookup
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("NIP-05 search: normalized search term:", normalizedSearchTerm);
// Try gitcitadel.com first with extra debugging
const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`;
console.log("NIP-05 search: trying gitcitadel.com first:", gitcitadelAddress);
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${searchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
return [profile];
}
} catch (e) {
// Continue to next domain
}
const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) {
console.log(
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else {
console.log("NIP-05 search: no npub found for gitcitadel.com");
}
} catch (e) {
console.error('[Search] NIP-05 domain search failed:', e);
console.log("NIP-05 search: error for gitcitadel.com:", e);
}
return [];
// If gitcitadel.com didn't work, try other domains
console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
console.log("NIP-05 search: trying address:", nip05Address);
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
return profile;
} else {
console.log("NIP-05 search: no npub found for", nip05Address);
}
} catch (e) {
console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain
}
return null;
});
// Wait for all searches with timeout
const results = await Promise.allSettled(searchPromises);
for (const result of results) {
if (result.status === "fulfilled" && result.value) {
foundProfiles.push(result.value);
}
}
console.log("NIP-05 search: total profiles found:", foundProfiles.length);
return foundProfiles;
}
/**
* Search for profiles across relays
* Quick relay search with short timeout
*/
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
async function quickRelaySearch(
searchTerm: string,
ndk: any,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm);
const foundProfiles: NostrProfile[] = [];
// Prioritize community relays for better search results
const allRelays = Array.from(ndk.pool.relays.values()) as any[];
const prioritizedRelays = new Set([
...allRelays.filter((relay: any) => relay.url === communityRelay),
...allRelays.filter((relay: any) => relay.url !== communityRelay)
]);
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk);
// Subscribe to profile events
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true },
relaySet
);
return new Promise((resolve) => {
sub.on('event', (event: NDKEvent) => {
// Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use all profile relays for better coverage
const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays
console.log("Using all relays for search:", quickRelayUrls);
// Create relay sets for parallel search
const relaySets = quickRelayUrls
.map((url) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || '';
const display_name = profileData.display_name || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const about = profileData.about || '';
// Check if any field matches the search term
const matchesDisplayName = fieldMatches(displayName, searchTerm);
const matchesDisplay_name = fieldMatches(display_name, searchTerm);
const matchesName = fieldMatches(name, searchTerm);
const matchesNip05 = nip05Matches(nip05, searchTerm);
const matchesAbout = fieldMatches(about, searchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) {
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey);
if (existingIndex === -1) {
foundProfiles.push(profile);
}
}
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
// Invalid JSON or other error, skip
console.warn(`Failed to create relay set for ${url}:`, e);
return null;
}
});
})
.filter(Boolean);
// Search all relays in parallel with short timeout
const searchPromises = relaySets.map(async (relaySet, index) => {
if (!relaySet) return [];
return new Promise<NostrProfile[]>((resolve) => {
const foundInRelay: NostrProfile[] = [];
let eventCount = 0;
console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`,
);
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true, relaySet },
);
sub.on('eose', () => {
if (foundProfiles.length > 0) {
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {};
for (const profile of foundProfiles) {
const pubkey = profile.pubkey;
if (pubkey) {
// We don't have created_at from getUserMetadata, so just keep the first one
if (!deduped[pubkey]) {
deduped[pubkey] = { profile, created_at: 0 };
sub.on("event", (event: NDKEvent) => {
eventCount++;
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName =
profileData.displayName || profileData.display_name || "";
const display_name = profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(
displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (
matchesDisplayName ||
matchesDisplay_name ||
matchesName ||
matchesNip05 ||
matchesAbout
) {
console.log(`Found matching profile on relay ${index + 1}:`, {
name: profileData.name,
display_name: profileData.display_name,
nip05: profileData.nip05,
pubkey: event.pubkey,
searchTerm: normalizedSearchTerm,
});
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile in this relay
const existingIndex = foundInRelay.findIndex(
(p) => p.pubkey === event.pubkey,
);
if (existingIndex === -1) {
foundInRelay.push(profile);
}
}
} catch (e) {
// Invalid JSON or other error, skip
}
const dedupedProfiles = Object.values(deduped).map(x => x.profile);
resolve(dedupedProfiles);
} else {
resolve([]);
}
});
sub.on("eose", () => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
resolve(foundInRelay);
});
// Short timeout for quick search
setTimeout(() => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
sub.stop();
resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay
});
});
}
// Wait for all searches to complete
const results = await Promise.allSettled(searchPromises);
// Combine and deduplicate results
const allProfiles: Record<string, NostrProfile> = {};
for (const result of results) {
if (result.status === "fulfilled") {
for (const profile of result.value) {
if (profile.pubkey) {
allProfiles[profile.pubkey] = profile;
}
}
}
}
console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles);
}

64
src/lib/utils/relayDiagnostics.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk';
import { TIMEOUTS } from './search_constants';
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import NDK from "@nostr-dev-kit/ndk";
import { TIMEOUTS } from "./search_constants";
import { get } from "svelte/store";
export interface RelayDiagnostic {
url: string;
@ -15,11 +16,11 @@ export interface RelayDiagnostic { @@ -15,11 +16,11 @@ export interface RelayDiagnostic {
*/
export async function testRelay(url: string): Promise<RelayDiagnostic> {
const startTime = Date.now();
return new Promise((resolve) => {
const ws = new WebSocket(url);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
@ -28,7 +29,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> { @@ -28,7 +29,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
url,
connected: false,
requiresAuth: false,
error: 'Connection timeout',
error: "Connection timeout",
responseTime: Date.now() - startTime,
});
}
@ -56,7 +57,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> { @@ -56,7 +57,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
url,
connected: false,
requiresAuth: false,
error: 'WebSocket error',
error: "WebSocket error",
responseTime: Date.now() - startTime,
});
}
@ -64,7 +65,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> { @@ -64,7 +65,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'NOTICE' && data[1]?.includes('auth-required')) {
if (data[0] === "NOTICE" && data[1]?.includes("auth-required")) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
@ -85,23 +86,24 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> { @@ -85,23 +86,24 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
* Tests all relays and returns diagnostic information
*/
export async function testAllRelays(): Promise<RelayDiagnostic[]> {
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])];
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...');
// Use the new relay management system
const allRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.log("[RelayDiagnostics] Testing", allRelays.length, "relays...");
const results = await Promise.allSettled(
allRelays.map(url => testRelay(url))
allRelays.map((url) => testRelay(url)),
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
if (result.status === "fulfilled") {
return result.value;
} else {
return {
url: allRelays[index],
connected: false,
requiresAuth: false,
error: 'Test failed',
error: "Test failed",
};
}
});
@ -111,31 +113,31 @@ export async function testAllRelays(): Promise<RelayDiagnostic[]> { @@ -111,31 +113,31 @@ export async function testAllRelays(): Promise<RelayDiagnostic[]> {
* Gets working relays from diagnostic results
*/
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] {
return diagnostics
.filter(d => d.connected)
.map(d => d.url);
return diagnostics.filter((d) => d.connected).map((d) => d.url);
}
/**
* Logs relay diagnostic results to console
*/
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void {
console.group('[RelayDiagnostics] Results');
const working = diagnostics.filter(d => d.connected);
const failed = diagnostics.filter(d => !d.connected);
console.group("[RelayDiagnostics] Results");
const working = diagnostics.filter((d) => d.connected);
const failed = diagnostics.filter((d) => !d.connected);
console.log(`✅ Working relays (${working.length}):`);
working.forEach(d => {
console.log(` - ${d.url}${d.requiresAuth ? ' (requires auth)' : ''}${d.responseTime ? ` (${d.responseTime}ms)` : ''}`);
working.forEach((d) => {
console.log(
` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${d.responseTime ? ` (${d.responseTime}ms)` : ""}`,
);
});
if (failed.length > 0) {
console.log(`❌ Failed relays (${failed.length}):`);
failed.forEach(d => {
console.log(` - ${d.url}: ${d.error || 'Unknown error'}`);
failed.forEach((d) => {
console.log(` - ${d.url}: ${d.error || "Unknown error"}`);
});
}
console.groupEnd();
}
}

531
src/lib/utils/relay_management.ts

@ -0,0 +1,531 @@ @@ -0,0 +1,531 @@
import NDK, { NDKRelay, NDKUser } from "@nostr-dev-kit/ndk";
import { communityRelays, searchRelays, secondaryRelays, anonymousRelays, lowbandwidthRelays, localRelays } from "../consts";
import { getRelaySetForNetworkCondition, NetworkCondition } from "./network_detection";
import { networkCondition } from "../stores/networkStore";
import { get } from "svelte/store";
/**
* Normalizes a relay URL to a standard format
* @param url The relay URL to normalize
* @returns The normalized relay URL
*/
export function normalizeRelayUrl(url: string): string {
let normalized = url.toLowerCase().trim();
// Ensure protocol is present
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = 'wss://' + normalized;
}
// Remove trailing slash
normalized = normalized.replace(/\/$/, '');
return normalized;
}
/**
* Normalizes an array of relay URLs
* @param urls Array of relay URLs to normalize
* @returns Array of normalized relay URLs
*/
export function normalizeRelayUrls(urls: string[]): string[] {
return urls.map(normalizeRelayUrl);
}
/**
* Removes duplicates from an array of relay URLs
* @param urls Array of relay URLs
* @returns Array of unique relay URLs
*/
export function deduplicateRelayUrls(urls: string[]): string[] {
const normalized = normalizeRelayUrls(urls);
return [...new Set(normalized)];
}
/**
* Tests connection to a relay and returns connection status
* @param relayUrl The relay URL to test
* @param ndk The NDK instance
* @returns Promise that resolves to connection status
*/
export async function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
// Use the existing NDK instance instead of creating a new one
const relay = new NDKRelay(secureUrl, undefined, ndk);
let authRequired = false;
let connected = false;
let error: string | undefined;
let actualUrl: string | undefined;
const timeout = setTimeout(() => {
relay.disconnect();
resolve({
connected: false,
requiresAuth: authRequired,
error: "Connection timeout",
actualUrl,
});
}, 3000); // Increased timeout to 3 seconds to give relays more time
relay.on("connect", () => {
connected = true;
actualUrl = secureUrl;
clearTimeout(timeout);
relay.disconnect();
resolve({
connected: true,
requiresAuth: authRequired,
error,
actualUrl,
});
});
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
authRequired = true;
}
});
relay.on("disconnect", () => {
if (!connected) {
error = "Connection failed";
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl,
});
}
});
relay.connect();
});
}
/**
* Ensures a relay URL uses secure WebSocket protocol for remote relays
* @param url The relay URL to secure
* @returns The URL with wss:// protocol (except for localhost)
*/
function ensureSecureWebSocket(url: string): string {
// For localhost, always use ws:// (never wss://)
if (url.includes('localhost') || url.includes('127.0.0.1')) {
// Convert any wss://localhost to ws://localhost
return url.replace(/^wss:\/\//, "ws://");
}
// Replace ws:// with wss:// for remote relays
const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) {
console.warn(
`[relay_management.ts] Protocol upgrade for rem ote relay: ${url} -> ${secureUrl}`,
);
}
return secureUrl;
}
/**
* Tests connection to local relays
* @param localRelayUrls Array of local relay URLs to test
* @param ndk NDK instance
* @returns Promise that resolves to array of working local relay URLs
*/
async function testLocalRelays(localRelayUrls: string[], ndk: NDK): Promise<string[]> {
const workingRelays: string[] = [];
if (localRelayUrls.length === 0) {
return workingRelays;
}
console.debug(`[relay_management.ts] Testing ${localRelayUrls.length} local relays...`);
await Promise.all(
localRelayUrls.map(async (url) => {
try {
const result = await testRelayConnection(url, ndk);
if (result.connected) {
workingRelays.push(url);
console.debug(`[relay_management.ts] Local relay connected: ${url}`);
} else {
console.debug(`[relay_management.ts] Local relay failed: ${url} - ${result.error}`);
}
} catch (error) {
// Silently ignore local relay failures - they're optional
console.debug(`[relay_management.ts] Local relay error (ignored): ${url}`);
}
})
);
console.debug(`[relay_management.ts] Found ${workingRelays.length} working local relays`);
return workingRelays;
}
/**
* Discovers local relays by testing common localhost URLs
* @param ndk NDK instance
* @returns Promise that resolves to array of working local relay URLs
*/
export async function discoverLocalRelays(ndk: NDK): Promise<string[]> {
try {
// If no local relays are configured, return empty array
if (localRelays.length === 0) {
console.debug('[relay_management.ts] No local relays configured');
return [];
}
// Convert wss:// URLs from consts to ws:// for local testing
const localRelayUrls = localRelays.map(url =>
url.replace(/^wss:\/\//, 'ws://')
);
const workingRelays = await testLocalRelays(localRelayUrls, ndk);
// If no local relays are working, return empty array
// The network detection logic will provide fallback relays
return workingRelays;
} catch (error) {
// Silently fail and return empty array
return [];
}
}
/**
* Fetches user's local relays from kind 10432 event
* @param ndk NDK instance
* @param user User to fetch local relays for
* @returns Promise that resolves to array of local relay URLs
*/
export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
const localRelayEvent = await ndk.fetchEvent(
{
kinds: [10432 as any],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!localRelayEvent) {
return [];
}
const localRelays: string[] = [];
localRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
localRelays.push(tag[1]);
}
});
return localRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user local relays:', error);
return [];
}
}
/**
* Fetches user's blocked relays from kind 10006 event
* @param ndk NDK instance
* @param user User to fetch blocked relays for
* @returns Promise that resolves to array of blocked relay URLs
*/
export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
const blockedRelayEvent = await ndk.fetchEvent(
{
kinds: [10006],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!blockedRelayEvent) {
return [];
}
const blockedRelays: string[] = [];
blockedRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) {
blockedRelays.push(tag[1]);
}
});
return blockedRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user blocked relays:', error);
return [];
}
}
/**
* Fetches user's outbox relays from NIP-65 relay list
* @param ndk NDK instance
* @param user User to fetch outbox relays for
* @returns Promise that resolves to array of outbox relay URLs
*/
export async function getUserOutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> {
try {
console.debug('[relay_management.ts] Fetching outbox relays for user:', user.pubkey);
const relayList = await ndk.fetchEvent(
{
kinds: [10002],
authors: [user.pubkey],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
}
);
if (!relayList) {
console.debug('[relay_management.ts] No relay list found for user');
return [];
}
console.debug('[relay_management.ts] Found relay list event:', relayList.id);
console.debug('[relay_management.ts] Relay list tags:', relayList.tags);
const outboxRelays: string[] = [];
relayList.tags.forEach((tag) => {
console.debug('[relay_management.ts] Processing tag:', tag);
if (tag[0] === 'w' && tag[1]) {
outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added outbox relay:', tag[1]);
} else if (tag[0] === 'r' && tag[1]) {
// Some relay lists use 'r' for both inbox and outbox
outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added relay (r tag):', tag[1]);
} else {
console.debug('[relay_management.ts] Skipping tag:', tag[0], 'value:', tag[1]);
}
});
console.debug('[relay_management.ts] Final outbox relays:', outboxRelays);
return outboxRelays;
} catch (error) {
console.info('[relay_management.ts] Error fetching user outbox relays:', error);
return [];
}
}
/**
* Gets browser extension's relay configuration by querying the extension directly
* @returns Promise that resolves to array of extension relay URLs
*/
export async function getExtensionRelays(): Promise<string[]> {
try {
// Check if we're in a browser environment with extension support
if (typeof window === 'undefined' || !window.nostr) {
console.debug('[relay_management.ts] No window.nostr available');
return [];
}
console.debug('[relay_management.ts] Extension available, checking for getRelays()');
const extensionRelays: string[] = [];
// Try to get relays from the extension's API
// Different extensions may expose their relay config differently
if (window.nostr.getRelays) {
console.debug('[relay_management.ts] getRelays() method found, calling it...');
try {
const relays = await window.nostr.getRelays();
console.debug('[relay_management.ts] getRelays() returned:', relays);
if (relays && typeof relays === 'object') {
// Convert relay object to array of URLs
const relayUrls = Object.keys(relays);
extensionRelays.push(...relayUrls);
console.debug('[relay_management.ts] Got relays from extension:', relayUrls);
}
} catch (error) {
console.debug('[relay_management.ts] Extension getRelays() failed:', error);
}
} else {
console.debug('[relay_management.ts] getRelays() method not found on window.nostr');
}
// If getRelays() didn't work, try alternative methods
if (extensionRelays.length === 0) {
// Some extensions might expose relays through other methods
// This is a fallback for extensions that don't expose getRelays()
console.debug('[relay_management.ts] Extension does not expose relay configuration');
}
console.debug('[relay_management.ts] Final extension relays:', extensionRelays);
return extensionRelays;
} catch (error) {
console.debug('[relay_management.ts] Error getting extension relays:', error);
return [];
}
}
/**
* Tests a set of relays in batches to avoid overwhelming them
* @param relayUrls Array of relay URLs to test
* @param ndk NDK instance
* @returns Promise that resolves to array of working relay URLs
*/
async function testRelaySet(relayUrls: string[], ndk: NDK): Promise<string[]> {
const workingRelays: string[] = [];
const maxConcurrent = 2; // Reduce to 2 relays at a time to avoid overwhelming them
for (let i = 0; i < relayUrls.length; i += maxConcurrent) {
const batch = relayUrls.slice(i, i + maxConcurrent);
const batchPromises = batch.map(async (url) => {
try {
const result = await testRelayConnection(url, ndk);
return result.connected ? url : null;
} catch (error) {
console.debug(`[relay_management.ts] Failed to test relay ${url}:`, error);
return null;
}
});
const batchResults = await Promise.allSettled(batchPromises);
const batchWorkingRelays = batchResults
.filter((result): result is PromiseFulfilledResult<string | null> => result.status === 'fulfilled')
.map(result => result.value)
.filter((url): url is string => url !== null);
workingRelays.push(...batchWorkingRelays);
}
return workingRelays;
}
/**
* Builds a complete relay set for a user, including local, user-specific, and fallback relays
* @param ndk NDK instance
* @param user NDKUser or null for anonymous access
* @returns Promise that resolves to inbox and outbox relay arrays
*/
export async function buildCompleteRelaySet(
ndk: NDK,
user: NDKUser | null
): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
console.debug('[relay_management.ts] buildCompleteRelaySet: Starting with user:', user?.pubkey || 'null');
// Discover local relays first
const discoveredLocalRelays = await discoverLocalRelays(ndk);
console.debug('[relay_management.ts] buildCompleteRelaySet: Discovered local relays:', discoveredLocalRelays);
// Get user-specific relays if available
let userOutboxRelays: string[] = [];
let userLocalRelays: string[] = [];
let blockedRelays: string[] = [];
let extensionRelays: string[] = [];
if (user) {
console.debug('[relay_management.ts] buildCompleteRelaySet: Fetching user-specific relays for:', user.pubkey);
try {
userOutboxRelays = await getUserOutboxRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User outbox relays:', userOutboxRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching user outbox relays:', error);
}
try {
userLocalRelays = await getUserLocalRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User local relays:', userLocalRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching user local relays:', error);
}
try {
blockedRelays = await getUserBlockedRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User blocked relays:', blockedRelays);
} catch (error) {
// Silently ignore blocked relay fetch errors
}
try {
extensionRelays = await getExtensionRelays();
console.debug('[relay_management.ts] Extension relays gathered:', extensionRelays);
} catch (error) {
console.debug('[relay_management.ts] Error fetching extension relays:', error);
}
} else {
console.debug('[relay_management.ts] buildCompleteRelaySet: No user provided, skipping user-specific relays');
}
// Build initial relay sets and deduplicate
const finalInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userLocalRelays]);
const finalOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userOutboxRelays, ...extensionRelays]);
// Test relays and filter out non-working ones
let testedInboxRelays: string[] = [];
let testedOutboxRelays: string[] = [];
if (finalInboxRelays.length > 0) {
testedInboxRelays = await testRelaySet(finalInboxRelays, ndk);
}
if (finalOutboxRelays.length > 0) {
testedOutboxRelays = await testRelaySet(finalOutboxRelays, ndk);
}
// If no relays passed testing, use remote relays without testing
if (testedInboxRelays.length === 0 && testedOutboxRelays.length === 0) {
const remoteRelays = deduplicateRelayUrls([...secondaryRelays, ...searchRelays]);
return {
inboxRelays: remoteRelays,
outboxRelays: remoteRelays
};
}
// Use tested relays and deduplicate
const inboxRelays = testedInboxRelays.length > 0 ? deduplicateRelayUrls(testedInboxRelays) : deduplicateRelayUrls(secondaryRelays);
const outboxRelays = testedOutboxRelays.length > 0 ? deduplicateRelayUrls(testedOutboxRelays) : deduplicateRelayUrls(secondaryRelays);
// Apply network condition optimization
const currentNetworkCondition = get(networkCondition);
const networkOptimizedRelaySet = getRelaySetForNetworkCondition(
currentNetworkCondition,
discoveredLocalRelays,
lowbandwidthRelays,
{ inboxRelays, outboxRelays }
);
// Filter out blocked relays and deduplicate final sets
const finalRelaySet = {
inboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.inboxRelays.filter(r => !blockedRelays.includes(r))),
outboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.outboxRelays.filter(r => !blockedRelays.includes(r)))
};
// If no relays are working, use anonymous relays as fallback
if (finalRelaySet.inboxRelays.length === 0 && finalRelaySet.outboxRelays.length === 0) {
return {
inboxRelays: deduplicateRelayUrls(anonymousRelays),
outboxRelays: deduplicateRelayUrls(anonymousRelays)
};
}
console.debug('[relay_management.ts] buildCompleteRelaySet: Final relay sets - inbox:', finalRelaySet.inboxRelays.length, 'outbox:', finalRelaySet.outboxRelays.length);
console.debug('[relay_management.ts] buildCompleteRelaySet: Final inbox relays:', finalRelaySet.inboxRelays);
console.debug('[relay_management.ts] buildCompleteRelaySet: Final outbox relays:', finalRelaySet.outboxRelays);
return finalRelaySet;
}

16
src/lib/utils/searchCache.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants";
export interface SearchResult {
events: NDKEvent[];
@ -39,25 +39,29 @@ class SearchCache { @@ -39,25 +39,29 @@ class SearchCache {
get(searchType: string, searchTerm: string): SearchResult | null {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
if (!result || this.isExpired(result)) {
if (result) {
this.cache.delete(key);
}
return null;
}
return result;
}
/**
* Store search results in cache
*/
set(searchType: string, searchTerm: string, result: Omit<SearchResult, 'timestamp'>): void {
set(
searchType: string,
searchTerm: string,
result: Omit<SearchResult, "timestamp">,
): void {
const key = this.generateKey(searchType, searchTerm);
this.cache.set(key, {
...result,
timestamp: Date.now()
timestamp: Date.now(),
});
}
@ -102,4 +106,4 @@ export const searchCache = new SearchCache(); @@ -102,4 +106,4 @@ export const searchCache = new SearchCache();
// Clean up expired entries periodically
setInterval(() => {
searchCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

49
src/lib/utils/search_constants.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/**
* Search and Event Utility Constants
*
*
* This file centralizes all magic numbers used throughout the search and event utilities
* to improve maintainability and reduce code duplication.
*/
@ -9,19 +9,22 @@ @@ -9,19 +9,22 @@
export const TIMEOUTS = {
/** Default timeout for event fetching operations */
EVENT_FETCH: 10000,
/** Timeout for profile search operations */
PROFILE_SEARCH: 15000,
/** Timeout for subscription search operations */
SUBSCRIPTION_SEARCH: 30000,
SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 5000,
/** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000,
/** Timeout for general operations */
GENERAL: 5000,
/** Cache cleanup interval */
CACHE_CLEANUP: 60000,
} as const;
@ -30,7 +33,7 @@ export const TIMEOUTS = { @@ -30,7 +33,7 @@ export const TIMEOUTS = {
export const CACHE_DURATIONS = {
/** Default cache duration for search results */
SEARCH_CACHE: 5 * 60 * 1000, // 5 minutes
/** Cache duration for index events */
INDEX_EVENT_CACHE: 10 * 60 * 1000, // 10 minutes
} as const;
@ -39,13 +42,13 @@ export const CACHE_DURATIONS = { @@ -39,13 +42,13 @@ export const CACHE_DURATIONS = {
export const SEARCH_LIMITS = {
/** Limit for specific profile searches (npub, NIP-05) */
SPECIFIC_PROFILE: 10,
/** Limit for general profile searches */
GENERAL_PROFILE: 500,
/** Limit for community relay checks */
COMMUNITY_CHECK: 1,
/** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100,
} as const;
@ -58,25 +61,25 @@ export const EVENT_KINDS = { @@ -58,25 +61,25 @@ export const EVENT_KINDS = {
MAX: 19999,
SPECIFIC: [0, 3],
},
/** Parameterized replaceable event kinds (20000-29999) */
PARAMETERIZED_REPLACEABLE: {
MIN: 20000,
MAX: 29999,
},
/** Addressable event kinds (30000-39999) */
ADDRESSABLE: {
MIN: 30000,
MAX: 39999,
},
/** Comment event kind */
COMMENT: 1111,
/** Text note event kind */
TEXT_NOTE: 1,
/** Profile metadata event kind */
PROFILE_METADATA: 0,
} as const;
@ -84,8 +87,8 @@ export const EVENT_KINDS = { @@ -84,8 +87,8 @@ export const EVENT_KINDS = {
// Relay-specific constants
export const RELAY_CONSTANTS = {
/** Request ID for community relay checks */
COMMUNITY_REQUEST_ID: 'alexandria-forest',
COMMUNITY_REQUEST_ID: "alexandria-forest",
/** Default relay request kinds for community checks */
COMMUNITY_REQUEST_KINDS: [1],
} as const;
@ -94,7 +97,7 @@ export const RELAY_CONSTANTS = { @@ -94,7 +97,7 @@ export const RELAY_CONSTANTS = {
export const TIME_CONSTANTS = {
/** Unix timestamp conversion factor (seconds to milliseconds) */
UNIX_TIMESTAMP_FACTOR: 1000,
/** Current timestamp in seconds */
CURRENT_TIMESTAMP: Math.floor(Date.now() / 1000),
} as const;
@ -103,7 +106,7 @@ export const TIME_CONSTANTS = { @@ -103,7 +106,7 @@ export const TIME_CONSTANTS = {
export const VALIDATION = {
/** Hex string length for event IDs and pubkeys */
HEX_LENGTH: 64,
/** Minimum length for Nostr identifiers */
MIN_NOSTR_IDENTIFIER_LENGTH: 4,
} as const;
@ -112,10 +115,10 @@ export const VALIDATION = { @@ -112,10 +115,10 @@ export const VALIDATION = {
export const HTTP_STATUS = {
/** OK status code */
OK: 200,
/** Not found status code */
NOT_FOUND: 404,
/** Internal server error status code */
INTERNAL_SERVER_ERROR: 500,
} as const;
} as const;

8
src/lib/utils/search_types.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { NDKEvent } from "@nostr-dev-kit/ndk";
/**
* Extended NostrProfile interface for search results
@ -39,7 +39,7 @@ export interface ProfileSearchResult { @@ -39,7 +39,7 @@ export interface ProfileSearchResult {
/**
* Search subscription type
*/
export type SearchSubscriptionType = 'd' | 't' | 'n';
export type SearchSubscriptionType = "d" | "t" | "n";
/**
* Search filter configuration
@ -53,7 +53,7 @@ export interface SearchFilter { @@ -53,7 +53,7 @@ export interface SearchFilter {
* Second-order search parameters
*/
export interface SecondOrderSearchParams {
searchType: 'n' | 'd';
searchType: "n" | "d";
firstOrderEvents: NDKEvent[];
eventIds?: Set<string>;
addresses?: Set<string>;
@ -66,4 +66,4 @@ export interface SecondOrderSearchParams { @@ -66,4 +66,4 @@ export interface SecondOrderSearchParams {
export interface SearchCallbacks {
onSecondOrderUpdate?: (result: SearchResult) => void;
onSubscriptionCreated?: (sub: any) => void;
}
}

32
src/lib/utils/search_utility.ts

@ -1,25 +1,25 @@ @@ -1,25 +1,25 @@
// Re-export all search functionality from modular files
export * from './search_types';
export * from './search_utils';
export * from './community_checker';
export * from './profile_search';
export * from './event_search';
export * from './subscription_search';
export * from './search_constants';
export * from "./search_types";
export * from "./search_utils";
export * from "./community_checker";
export * from "./profile_search";
export * from "./event_search";
export * from "./subscription_search";
export * from "./search_constants";
// Legacy exports for backward compatibility
export { searchProfiles } from './profile_search';
export { searchBySubscription } from './subscription_search';
export { searchEvent, searchNip05 } from './event_search';
export { checkCommunity } from './community_checker';
export {
wellKnownUrl,
lnurlpWellKnownUrl,
export { searchProfiles } from "./profile_search";
export { searchBySubscription } from "./subscription_search";
export { searchEvent, searchNip05 } from "./event_search";
export { checkCommunity } from "./community_checker";
export {
wellKnownUrl,
lnurlpWellKnownUrl,
isValidNip05Address,
normalizeSearchTerm,
fieldMatches,
nip05Matches,
COMMON_DOMAINS,
isEmojiReaction,
createProfileFromEvent
} from './search_utils';
createProfileFromEvent,
} from "./search_utils";

37
src/lib/utils/search_utils.ts

@ -23,7 +23,7 @@ export function isValidNip05Address(address: string): boolean { @@ -23,7 +23,7 @@ export function isValidNip05Address(address: string): boolean {
* Helper function to normalize search terms
*/
export function normalizeSearchTerm(term: string): string {
return term.toLowerCase().replace(/\s+/g, '');
return term.toLowerCase().replace(/\s+/g, "");
}
/**
@ -32,21 +32,21 @@ export function normalizeSearchTerm(term: string): string { @@ -32,21 +32,21 @@ export function normalizeSearchTerm(term: string): string {
export function fieldMatches(field: string, searchTerm: string): boolean {
if (!field) return false;
const fieldLower = field.toLowerCase();
const fieldNormalized = fieldLower.replace(/\s+/g, '');
const fieldNormalized = fieldLower.replace(/\s+/g, "");
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check exact match
if (fieldLower === searchTermLower) return true;
if (fieldNormalized === normalizedSearchTerm) return true;
// Check if field contains the search term
if (fieldLower.includes(searchTermLower)) return true;
if (fieldNormalized.includes(normalizedSearchTerm)) return true;
// Check individual words (handle spaces in display names)
const words = fieldLower.split(/\s+/);
return words.some(word => word.includes(searchTermLower));
return words.some((word) => word.includes(searchTermLower));
}
/**
@ -57,13 +57,16 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean { @@ -57,13 +57,16 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean {
const nip05Lower = nip05.toLowerCase();
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check if the part before @ contains the search term
const atIndex = nip05Lower.indexOf('@');
const atIndex = nip05Lower.indexOf("@");
if (atIndex !== -1) {
const localPart = nip05Lower.substring(0, atIndex);
const localPartNormalized = localPart.replace(/\s+/g, '');
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm);
const localPartNormalized = localPart.replace(/\s+/g, "");
return (
localPart.includes(searchTermLower) ||
localPartNormalized.includes(normalizedSearchTerm)
);
}
return false;
}
@ -72,11 +75,11 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean { @@ -72,11 +75,11 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean {
* Common domains for NIP-05 lookups
*/
export const COMMON_DOMAINS = [
'gitcitadel.com',
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host'
"gitcitadel.com",
"theforest.nostr1.com",
"nostr1.com",
"nostr.land",
"sovbit.host",
] as const;
/**
@ -99,6 +102,6 @@ export function createProfileFromEvent(event: any, profileData: any): any { @@ -99,6 +102,6 @@ export function createProfileFromEvent(event: any, profileData: any): any {
banner: profileData.banner,
website: profileData.website,
lud16: profileData.lud16,
pubkey: event.pubkey
pubkey: event.pubkey,
};
}
}

704
src/lib/utils/subscription_search.ts

File diff suppressed because it is too large Load Diff

4
src/routes/+layout.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
import { logCurrentRelayConfiguration } from "$lib/ndk";
// Get standard metadata for OpenGraph tags
let title = "Library of Alexandria";
@ -18,6 +19,9 @@ @@ -18,6 +19,9 @@
onMount(() => {
const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`;
// Log relay configuration when layout mounts
logCurrentRelayConfiguration();
});
</script>

116
src/routes/+layout.ts

@ -1,39 +1,115 @@ @@ -1,39 +1,115 @@
import { feedTypeStorageKey } from "$lib/consts";
import { FeedType } from "$lib/consts";
import { getPersistedLogin, initNdk, ndkInstance } from "$lib/ndk";
import {
getPersistedLogin,
initNdk,
loginWithExtension,
ndkInstance,
} from "$lib/ndk";
loginWithAmber,
loginWithNpub,
} from "$lib/stores/userStore";
import { loginMethodStorageKey } from "$lib/stores/userStore";
import Pharos, { pharosInstance } from "$lib/parser";
import { feedType } from "$lib/stores";
import type { LayoutLoad } from "./$types";
import { get } from "svelte/store";
export const ssr = false;
export const load: LayoutLoad = () => {
const initialFeedType =
(localStorage.getItem(feedTypeStorageKey) as FeedType) ??
FeedType.StandardRelays;
feedType.set(initialFeedType);
// Initialize NDK with new relay management system
const ndk = initNdk();
ndkInstance.set(ndk);
try {
// Michael J - 18 Jan 2025 - This will not work server-side, since the NIP-07 extension is only
// available in the browser, and the flags for persistent login are saved in the browser's
// local storage. If SSR is ever enabled, move this code block to run client-side.
const pubkey = getPersistedLogin();
if (pubkey) {
// Michael J - 27 Jan 2025 - We don't await this call; it will run in the background and
// update Svelte stores to propagate data.
loginWithExtension(pubkey);
const loginMethod = localStorage.getItem(loginMethodStorageKey);
const logoutFlag = localStorage.getItem("alexandria/logout/flag");
console.log("Layout load - persisted pubkey:", pubkey);
console.log("Layout load - persisted login method:", loginMethod);
console.log("Layout load - logout flag:", logoutFlag);
console.log("All localStorage keys:", Object.keys(localStorage));
if (pubkey && loginMethod && !logoutFlag) {
if (loginMethod === "extension") {
console.log("Restoring extension login...");
loginWithExtension();
} else if (loginMethod === "amber") {
// Attempt to restore Amber (NIP-46) session from localStorage
const relay = "wss://relay.nsec.app";
const localNsec = localStorage.getItem("amber/nsec");
if (localNsec) {
import("@nostr-dev-kit/ndk").then(
async ({ NDKNip46Signer, default: NDK }) => {
const ndk = get(ndkInstance);
try {
const amberSigner = NDKNip46Signer.nostrconnect(
ndk,
relay,
localNsec,
{
name: "Alexandria",
perms: "sign_event:1;sign_event:4",
},
);
// Try to reconnect (blockUntilReady will resolve if Amber is running and session is valid)
await amberSigner.blockUntilReady();
const user = await amberSigner.user();
await loginWithAmber(amberSigner, user);
console.log("Amber session restored.");
} catch (err) {
// If reconnection fails, automatically fallback to npub-only mode
console.warn(
"Amber session could not be restored. Falling back to npub-only mode.",
);
try {
// Set the flag first, before login
localStorage.setItem("alexandria/amber/fallback", "1");
console.log("Set fallback flag in localStorage");
// Small delay to ensure flag is set
await new Promise((resolve) => setTimeout(resolve, 100));
await loginWithNpub(pubkey);
console.log("Successfully fell back to npub-only mode.");
} catch (fallbackErr) {
console.error(
"Failed to fallback to npub-only mode:",
fallbackErr,
);
}
}
},
);
} else {
// No session data, automatically fallback to npub-only mode
console.log(
"No Amber session data found. Falling back to npub-only mode.",
);
// Set the flag first, before login
localStorage.setItem("alexandria/amber/fallback", "1");
console.log("Set fallback flag in localStorage");
// Small delay to ensure flag is set
setTimeout(async () => {
try {
await loginWithNpub(pubkey);
console.log("Successfully fell back to npub-only mode.");
} catch (fallbackErr) {
console.error(
"Failed to fallback to npub-only mode:",
fallbackErr,
);
}
}, 100);
}
} else if (loginMethod === "npub") {
console.log("Restoring npub login...");
loginWithNpub(pubkey);
}
} else if (logoutFlag) {
console.log("Skipping auto-login due to logout flag");
localStorage.removeItem("alexandria/logout/flag");
}
} catch (e) {
console.warn(
`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`,
`Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
);
}

98
src/routes/+page.svelte

@ -1,94 +1,38 @@ @@ -1,94 +1,38 @@
<script lang="ts">
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";
import { feedType } from "$lib/stores";
$effect(() => {
localStorage.setItem(feedTypeStorageKey, $feedType);
});
$effect(() => {
if (!$ndkSignedIn && $feedType !== FeedType.StandardRelays) {
feedType.set(FeedType.StandardRelays);
}
});
const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) {
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return "";
}
};
import { Alert, Input } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
import { userStore } from "$lib/stores/userStore";
import { activeInboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte";
let searchQuery = $state("");
</script>
let user = $derived($userStore);
let eventCount = $state({ displayed: 0, total: 0 });
<Alert
rounded={false}
id="alert-experimental"
class="border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2"
>
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" />
<span class="font-medium">
Pardon our dust! The publication view is currently using an experimental
loader, and may be unstable.
</span>
</Alert>
function handleEventCountUpdate(counts: { displayed: number; total: number }) {
eventCount = counts;
}
</script>
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
<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)}`}
{#if $ndkSignedIn}
<ChevronDownOutline class="w-6 h-6" />
{/if}
</Button>
<Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
{#if $ndkSignedIn}
<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>
<li>
<Radio
name="follows"
bind:group={$feedType}
value={FeedType.UserRelays}>Your Relays</Radio
>
</li>
</Dropdown>
{/if}
</div>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} />
{#if eventCount.total > 0}
<div class="text-center text-sm text-gray-600 dark:text-gray-400">
Showing {eventCount.displayed} of {eventCount.total} events.
</div>
{/if}
<PublicationFeed
{searchQuery}
onEventCountUpdate={handleEventCountUpdate}
/>
</main>

9
src/routes/about/+page.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from "$app/navigation";
import RelayStatus from "$lib/components/RelayStatus.svelte";
// Get the git tag version from environment variables
@ -35,9 +36,11 @@ @@ -35,9 +36,11 @@
</P>
<P class="mb-3">
Please submit support issues on the <A href="/contact"
>Alexandria contact page</A
> and follow us on <A
Please submit support issues on the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/contact")}>Contact</button
>
page and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"

26
src/routes/contact/+page.svelte

@ -9,8 +9,9 @@ @@ -9,8 +9,9 @@
Input,
Modal,
} from "flowbite-svelte";
import { ndkSignedIn, ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
import { ndkInstance, ndkSignedIn, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { communityRelays } from "$lib/consts";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
// @ts-ignore - Workaround for Svelte component import issue
@ -53,16 +54,21 @@ @@ -53,16 +54,21 @@
content: "",
};
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// Repository event address from the task
const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Hard-coded relays to ensure we have working relays
// Use the new relay management system instead of hardcoded relays
const allRelays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...standardRelays,
...$activeInboxRelays,
...$activeOutboxRelays,
];
// Hard-coded repository owner pubkey and ID from the task
@ -90,7 +96,7 @@ @@ -90,7 +96,7 @@
}
// Check if user is logged in
if (!$ndkSignedIn) {
if (!user.signedIn) {
// Save form data
savedFormData = {
subject,
@ -272,7 +278,7 @@ @@ -272,7 +278,7 @@
// Handle login completion
$effect(() => {
if ($ndkSignedIn && showLoginModal) {
if (user.signedIn && showLoginModal) {
showLoginModal = false;
// Restore saved form data
@ -446,7 +452,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -446,7 +452,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
on:click={toggleSize}
onclick={toggleSize}
>
{isExpanded ? "⌃" : "⌄"}
</Button>
@ -454,7 +460,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -454,7 +460,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" on:click={clearForm}>
<Button type="button" color="alternative" onclick={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
@ -581,8 +587,8 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -581,8 +587,8 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
Would you like to submit the issue?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}>Cancel</Button>
<Button color="primary" on:click={confirmSubmit}>Submit</Button>
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button>
<Button color="primary" onclick={confirmSubmit}>Submit</Button>
</div>
</div>
</Modal>

422
src/routes/events/+page.svelte

@ -8,17 +8,17 @@ @@ -8,17 +8,17 @@
import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte';
import { userPubkey, isLoggedIn } from '$lib/stores/authStore.Svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils';
import { standardRelays } from '$lib/consts';
import { getEventType } from '$lib/utils/mime';
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte';
import { checkCommunity } from '$lib/utils/search_utility';
import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk";
import { getEventType } from "$lib/utils/mime";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility";
let loading = $state(false);
let error = $state<string | null>(null);
@ -42,12 +42,15 @@ @@ -42,12 +42,15 @@
lud16?: string;
nip05?: string;
} | null>(null);
let user = $state($userStore);
let userRelayPreference = $state(false);
let showSidePanel = $state(false);
let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({});
userStore.subscribe((val) => (user = val));
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
showSidePanel = true;
@ -61,7 +64,7 @@ @@ -61,7 +64,7 @@
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
@ -73,7 +76,68 @@ @@ -73,7 +76,68 @@
}
}
function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set(), searchTypeParam?: string, searchTermParam?: string) {
// Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes
$effect(() => {
const url = $page.url.searchParams;
const idParam = url.get("id");
const dParam = url.get("d");
if (idParam) {
searchValue = idParam;
dTagValue = null;
} else if (dParam) {
searchValue = null;
dTagValue = dParam.toLowerCase();
} else {
searchValue = null;
dTagValue = null;
}
});
// Add support for t and n parameters
$effect(() => {
const url = $page.url.searchParams;
const tParam = url.get("t");
const nParam = url.get("n");
if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix
const decodedT = decodeURIComponent(tParam);
searchValue = `t:${decodedT}`;
dTagValue = null;
} else if (nParam) {
// Decode the n parameter and set it as searchValue with n: prefix
const decodedN = decodeURIComponent(nParam);
searchValue = `n:${decodedN}`;
dTagValue = null;
}
});
// Handle side panel visibility based on search type
$effect(() => {
const url = $page.url.searchParams;
const hasIdParam = url.get("id");
const hasDParam = url.get("d");
const hasTParam = url.get("t");
const hasNParam = url.get("n");
// Close side panel for searches that return multiple results
if (hasDParam || hasTParam || hasNParam) {
showSidePanel = false;
event = null;
profile = null;
}
});
function handleSearchResults(
results: NDKEvent[],
secondOrder: NDKEvent[] = [],
tTagEvents: NDKEvent[] = [],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
searchTypeParam?: string,
searchTermParam?: string,
) {
searchResults = results;
secondOrderResults = secondOrder;
tTagResults = tTagEvents;
@ -81,19 +145,28 @@ @@ -81,19 +145,28 @@
originalAddresses = addresses;
searchType = searchTypeParam || null;
searchTerm = searchTermParam || null;
// Track search progress
searchInProgress = loading || (results.length > 0 && secondOrder.length === 0);
searchInProgress =
loading || (results.length > 0 && secondOrder.length === 0);
// Show second-order search message when we have first-order results but no second-order yet
if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'n') {
if (
results.length > 0 &&
secondOrder.length === 0 &&
searchTypeParam === "n"
) {
secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`;
} else if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'd') {
} else if (
results.length > 0 &&
secondOrder.length === 0 &&
searchTypeParam === "d"
) {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
}
// Check community status for all search results
if (results.length > 0) {
checkCommunityStatusForResults(results);
@ -104,7 +177,7 @@ @@ -104,7 +177,7 @@
if (tTagEvents.length > 0) {
checkCommunityStatusForResults(tTagEvents);
}
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
@ -124,7 +197,7 @@ @@ -124,7 +197,7 @@
searchInProgress = false;
secondOrderSearchMessage = null;
communityStatus = {};
goto('/events', { replaceState: true });
goto("/events", { replaceState: true });
}
function closeSidePanel() {
@ -148,7 +221,11 @@ @@ -148,7 +221,11 @@
return getMatchingTags(event, "deferral")[0]?.[1];
}
function getReferenceType(event: NDKEvent, originalEventIds: Set<string>, originalAddresses: Set<string>): string {
function getReferenceType(
event: NDKEvent,
originalEventIds: Set<string>,
originalAddresses: Set<string>,
): string {
// Check if this event has e-tags referencing original events
const eTags = getMatchingTags(event, "e");
for (const tag of eTags) {
@ -156,38 +233,52 @@ @@ -156,38 +233,52 @@
return "Reply/Reference (e-tag)";
}
}
// Check if this event has a-tags referencing original events
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
// Check if this event has a-tags or e-tags referencing original events
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
for (const tag of tags) {
if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)";
}
}
// Check if this event has content references
if (event.content) {
for (const id of originalEventIds) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, 'i');
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, 'i');
if (neventPattern.test(event.content) || notePattern.test(event.content)) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i");
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i");
if (
neventPattern.test(event.content) ||
notePattern.test(event.content)
) {
return "Content Reference";
}
}
for (const address of originalAddresses) {
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, 'i');
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, "i");
if (naddrPattern.test(event.content)) {
return "Content Reference";
}
}
}
return "Reference";
}
function getNeventAddress(event: NDKEvent): string {
return neventEncode(event, standardRelays);
function getNeventUrl(event: NDKEvent): string {
if (event.kind === 0) {
return neventEncode(event, $activeInboxRelays);
}
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function isAddressableEvent(event: NDKEvent): boolean {
@ -199,7 +290,7 @@ @@ -199,7 +290,7 @@
return null;
}
try {
return naddrEncode(event, standardRelays);
return naddrEncode(event, $activeInboxRelays);
} catch {
return null;
}
@ -211,19 +302,20 @@ @@ -211,19 +302,20 @@
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function shortenAddress(addr: string, head = 10, tail = 10): string {
if (!addr || addr.length <= head + tail + 3) return addr;
return addr.slice(0, head) + '…' + addr.slice(-tail);
return addr.slice(0, head) + "…" + addr.slice(-tail);
}
function onLoadingChange(val: boolean) {
loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0);
searchInProgress =
val || (searchResults.length > 0 && secondOrderResults.length === 0);
}
/**
@ -231,121 +323,34 @@ @@ -231,121 +323,34 @@
*/
async function checkCommunityStatusForResults(events: NDKEvent[]) {
const newCommunityStatus: Record<string, boolean> = {};
for (const event of events) {
if (event.pubkey && !communityStatus[event.pubkey]) {
try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) {
console.error('Error checking community status for', event.pubkey, error);
console.error(
"Error checking community status for",
event.pubkey,
error,
);
newCommunityStatus[event.pubkey] = false;
}
} else if (event.pubkey) {
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey];
}
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
function updateSearchFromURL() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
console.log("Events page URL update:", { id, dTag, searchValue });
if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
// Only close side panel if we're clearing the search
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
if (dTag !== dTagValue) {
console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag });
// Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
// For d-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
// Reset state if both id and dTag are absent
if (!id && !dTag) {
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
// Force search when URL changes
function handleUrlChange() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
console.log("Events page URL change:", { id, dTag, currentSearchValue: searchValue, currentDTagValue: dTagValue });
// Handle ID parameter changes
if (id !== searchValue) {
console.log("ID parameter changed:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle d-tag parameter changes
if (dTag !== dTagValue) {
console.log("d-tag parameter changed:", { old: dTagValue, new: dTag });
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
showSidePanel = false;
event = null;
profile = null;
}
// Reset state if both parameters are absent
if (!id && !dTag) {
console.log("Both ID and d-tag parameters absent, resetting state");
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
searchValue = null;
dTagValue = null;
}
}
// Listen for URL changes
$effect(() => {
handleUrlChange();
});
// Log relay configuration when page mounts
onMount(() => {
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
// Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error);
logCurrentRelayConfiguration();
});
</script>
<div class="w-full flex justify-center">
@ -380,11 +385,13 @@ @@ -380,11 +385,13 @@
onEventFound={handleEventFound}
onSearchResults={handleSearchResults}
onClear={handleClear}
onLoadingChange={onLoadingChange}
{onLoadingChange}
/>
{#if secondOrderSearchMessage}
<div class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg">
<div
class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg"
>
{secondOrderSearchMessage}
</div>
{/if}
@ -392,12 +399,14 @@ @@ -392,12 +399,14 @@
{#if searchResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
{#if searchType === 'n'}
{#if searchType === "n"}
Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
{:else if searchType === 't'}
Search Results for t-tag: "{searchTerm}" ({searchResults.length} events)
{:else if searchType === "t"}
Search Results for t-tag: "{searchTerm}" ({searchResults.length}
events)
{:else}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events)
Search Results for d-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
@ -409,15 +418,25 @@ @@ -409,15 +418,25 @@
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>{searchType === 'n' ? 'Profile' : 'Event'} {index + 1}</span
>{searchType === "n" ? "Profile" : "Event"}
{index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -433,7 +452,9 @@ @@ -433,7 +452,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
@ -453,13 +474,17 @@ @@ -453,13 +474,17 @@
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -470,7 +495,9 @@ @@ -470,7 +495,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -478,7 +505,8 @@ @@ -478,7 +505,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -496,13 +524,14 @@ @@ -496,13 +524,14 @@
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events)
</Heading>
{#if (searchType === 'n' || searchType === 'd') && secondOrderResults.length === 100}
{#if (searchType === "n" || searchType === "d") && secondOrderResults.length === 100}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing the 100 newest events. More results may be available.
</P>
{/if}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that reference, reply to, highlight, or quote the original events.
Events that reference, reply to, highlight, or quote the original
events.
</P>
<div class="space-y-4">
{#each secondOrderResults as result, index}
@ -519,9 +548,18 @@ @@ -519,9 +548,18 @@
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -537,12 +575,18 @@ @@ -537,12 +575,18 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
{getReferenceType(result, originalEventIds, originalAddresses)}
{getReferenceType(
result,
originalEventIds,
originalAddresses,
)}
</div>
{#if getSummary(result)}
<div
@ -560,13 +604,17 @@ @@ -560,13 +604,17 @@
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -577,7 +625,9 @@ @@ -577,7 +625,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -585,7 +635,8 @@ @@ -585,7 +635,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -600,7 +651,8 @@ @@ -600,7 +651,8 @@
{#if tTagResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({tTagResults.length} events)
Search Results for t-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({tTagResults.length} events)
</Heading>
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag.
@ -620,9 +672,18 @@ @@ -620,9 +672,18 @@
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -638,7 +699,9 @@ @@ -638,7 +699,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
@ -658,13 +721,17 @@ @@ -658,13 +721,17 @@
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -675,7 +742,9 @@ @@ -675,7 +742,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -683,7 +752,8 @@ @@ -683,7 +752,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -719,8 +789,8 @@ @@ -719,8 +789,8 @@
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
displayText={shortenAddress(getNeventUrl(event))}
copyText={getNeventUrl(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getViewPublicationNaddr(event)}
@ -736,10 +806,10 @@ @@ -736,10 +806,10 @@
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>

117
src/routes/new/compose/+page.svelte

@ -1,29 +1,98 @@ @@ -1,29 +1,98 @@
<script lang="ts">
import Preview from "$lib/components/Preview.svelte";
import { pharosInstance } from "$lib/parser";
import { Heading } from "flowbite-svelte";
let treeNeedsUpdate: boolean = false;
let treeUpdateCount: number = 0;
let someIndexValue = 0;
$: {
if (treeNeedsUpdate) {
treeUpdateCount++;
}
import { Heading, Button, Alert } from "flowbite-svelte";
import { PaperPlaneOutline } from "flowbite-svelte-icons";
import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools";
import { publishZettel } from "$lib/services/publisher";
let content = $state("");
let showPreview = $state(false);
let isPublishing = $state(false);
let publishResult = $state<{
success: boolean;
eventId?: string;
error?: string;
} | null>(null);
// Handle content changes from ZettelEditor
function handleContentChange(newContent: string) {
content = newContent;
}
// Handle preview toggle from ZettelEditor
function handlePreviewToggle(show: boolean) {
showPreview = show;
}
async function handlePublish() {
isPublishing = true;
publishResult = null;
const result = await publishZettel({
content,
onSuccess: (eventId) => {
publishResult = { success: true, eventId };
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
},
onError: (error) => {
publishResult = { success: false, error };
},
});
isPublishing = false;
}
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4">
<Heading tag="h1" class="h-leather mb-2">Compose</Heading>
{#key treeUpdateCount}
<Preview
rootId={$pharosInstance.getRootIndexId()}
allowEditing={true}
bind:needsUpdate={treeNeedsUpdate}
index={someIndexValue}
/>
{/key}
</main>
<svelte:head>
<title>Compose Note - Alexandria</title>
</svelte:head>
<!-- Main container with 75% width and centered -->
<div class="w-3/4 mx-auto">
<div class="flex flex-col space-y-4">
<Heading
tag="h1"
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
Compose Notes
</Heading>
<ZettelEditor
{content}
{showPreview}
onContentChange={handleContentChange}
onPreviewToggle={handlePreviewToggle}
/>
<!-- Publish Button -->
<Button
on:click={handlePublish}
disabled={isPublishing || !content.trim()}
class="w-full"
>
{#if isPublishing}
Publishing...
{:else}
<PaperPlaneOutline class="w-4 h-4 mr-2" />
Publish
{/if}
</Button>
<!-- Status Messages -->
{#if publishResult}
{#if publishResult.success}
<Alert color="green" dismissable>
<span class="font-medium">Success!</span>
Event published successfully. Event ID: {publishResult.eventId}
</Alert>
{:else}
<Alert color="red" dismissable>
<span class="font-medium">Error!</span>
{publishResult.error}
</Alert>
{/if}
{/if}
</div>
</div>

97
src/routes/publication/+page.svelte

@ -1,17 +1,26 @@ @@ -1,17 +1,26 @@
<script lang="ts">
import Publication from "$lib/components/Publication.svelte";
import Publication from "$lib/components/publications/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import { onDestroy, onMount, setContext } from "svelte";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
import { SveltePublicationTree } from "$lib/components/publications/svelte_publication_tree.svelte";
import { TableOfContents } from "$lib/components/publications/table_of_contents.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
const publicationTree = new SveltePublicationTree(data.indexEvent, data.ndk);
const toc = new TableOfContents(
data.indexEvent.tagAddress(),
publicationTree,
page.url.pathname ?? "",
);
setContext("publicationTree", publicationTree);
setContext("toc", toc);
setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags
@ -20,7 +29,9 @@ @@ -20,7 +29,9 @@
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
);
let currentUrl = data.url?.href ?? "";
let currentUrl = $derived(
`${page.url.origin}${page.url.pathname}${page.url.search}`,
);
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
@ -33,6 +44,58 @@ @@ -33,6 +44,58 @@
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
publicationTree.onBookmarkMoved((address) => {
goto(`#${address}`, {
replaceState: true,
});
// TODO: Extract IndexedDB interaction to a service layer.
// Store bookmark in IndexedDB
const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
};
db.onsuccess = () => {
const transaction = db.result.transaction(["bookmarks"], "readwrite");
const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
store.put({ key: bookmarkKey, address });
};
});
onMount(() => {
// TODO: Extract IndexedDB interaction to a service layer.
// Read bookmark from IndexedDB
const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
};
db.onsuccess = () => {
const transaction = db.result.transaction(["bookmarks"], "readonly");
const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
const request = store.get(bookmarkKey);
request.onsuccess = () => {
if (request.result?.address) {
// Set the bookmark in the publication tree
publicationTree.setBookmark(request.result.address);
// Jump to the bookmarked element
goto(`#${request.result.address}`, {
replaceState: true,
});
}
};
};
});
onDestroy(() => data.parser.reset());
</script>
@ -56,22 +119,16 @@ @@ -56,22 +119,16 @@
<meta name="twitter:image" content={image} />
</svelte:head>
{#key data}
<ArticleNav
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
<main class="publication {data.publicationType}">
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
{/key}
<main class="publication {data.publicationType}">
{#await data.waitable}
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>
{/await}
</main>

11
src/routes/publication/+page.ts

@ -2,7 +2,7 @@ import { error } from "@sveltejs/kit"; @@ -2,7 +2,7 @@ import { error } from "@sveltejs/kit";
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 { getActiveRelaySetAsNDKRelaySet } from "$lib/ndk";
import { getMatchingTags } from "$lib/utils/nostrUtils";
/**
@ -68,10 +68,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -68,10 +68,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
*/
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try {
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); // true for inbox relays
const event = await ndk.fetchEvent(
{ "#d": [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk),
relaySet,
);
if (!event) {
@ -83,6 +84,7 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { @@ -83,6 +84,7 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
}
}
// TODO: Use path params instead of query params.
export const load: Load = async ({
url,
parent,
@ -92,7 +94,7 @@ export const load: Load = async ({ @@ -92,7 +94,7 @@ export const load: Load = async ({
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk, parser } = await parent();
const { ndk } = await parent();
if (!id && !dTag) {
throw error(400, "No publication root event ID or d tag provided.");
@ -104,12 +106,9 @@ export const load: Load = async ({ @@ -104,12 +106,9 @@ export const load: Load = async ({
: await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {
waitable: fetchPromise,
publicationType,
indexEvent,
url,
};
};

38
src/routes/start/+page.svelte

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from "$app/navigation";
// Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development";
@ -15,10 +16,13 @@ @@ -15,10 +16,13 @@
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user
can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A
Alexandria opens up to the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("./")}>landing page</button
>, where the user can: login (top-right), select whether to only view the
publications hosted on the <A
href="https://thecitadel.nostr1.com/"
target="_blank">thecitadel document relay</A
> or add in their own relays, and scroll/search the publications.
</P>
@ -86,9 +90,9 @@ @@ -86,9 +90,9 @@
</P>
<P class="mb-3">
An example of a book is <A
An example of a book is <a
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</A
>Jane Eyre</a
>
</P>
@ -122,9 +126,9 @@ @@ -122,9 +126,9 @@
</P>
<P class="mb-3">
An example of a research paper is <A
An example of a research paper is <a
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</A
>Less Partnering, Less Children, or Both?</a
>
</P>
@ -140,11 +144,11 @@ @@ -140,11 +144,11 @@
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A
>, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</A
Our own team uses Alexandria to document the app, to display our <a
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</a
>, as well as to store copies of our most interesting
<a href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</a
>.
</P>
@ -162,9 +166,11 @@ @@ -162,9 +166,11 @@
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected,
evolving content.
one about the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/publication?d=sybil")}>Sybil utility</button
> use the same Asciidoc format as other publications but are specifically designed
for interconnected, evolving content.
</P>
<P class="mb-3">

13
src/routes/visualize/+page.svelte

@ -66,10 +66,17 @@ @@ -66,10 +66,17 @@
// Step 3: Extract content event IDs from index events
const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => {
const aTags = event.getMatchingTags("a");
debug(`Event ${event.id} has ${aTags.length} a-tags`);
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = event.getMatchingTags("a");
if (tags.length === 0) {
tags = event.getMatchingTags("e");
}
aTags.forEach((tag) => {
debug(
`Event ${event.id} has ${tags.length} tags (${tags.length > 0 ? (event.getMatchingTags("a").length > 0 ? "a" : "e") : "none"})`,
);
tags.forEach((tag) => {
const eventId = tag[3];
if (eventId) {
contentEventIds.add(eventId);

53
src/styles/asciidoc.css

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/* AsciiDoc Content Styling */
/* These styles are for rendered AsciiDoc content in previews and publications */
.asciidoc-content h1,
.asciidoc-content h2,
.asciidoc-content h3,
.asciidoc-content h4,
.asciidoc-content h5,
.asciidoc-content h6 {
font-weight: bold;
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.25;
color: inherit;
}
.asciidoc-content h1 {
font-size: 1.875rem;
}
.asciidoc-content h2 {
font-size: 1.5rem;
}
.asciidoc-content h3 {
font-size: 1.25rem;
}
.asciidoc-content h4 {
font-size: 1.125rem;
}
.asciidoc-content h5 {
font-size: 1rem;
}
.asciidoc-content h6 {
font-size: 0.875rem;
}
.asciidoc-content p {
margin-bottom: 1em;
}
/* Dark mode support */
.dark .asciidoc-content h1,
.dark .asciidoc-content h2,
.dark .asciidoc-content h3,
.dark .asciidoc-content h4,
.dark .asciidoc-content h5,
.dark .asciidoc-content h6 {
color: inherit;
}

22
test_data/LaTeXtestfile.json

File diff suppressed because one or more lines are too long

21
test_data/LaTeXtestfile.md

@ -20,24 +20,30 @@ Something complex, in display mode: `$$P \left( A=2 \, \middle| \, \dfrac{A^2}{B @@ -20,24 +20,30 @@ Something complex, in display mode: `$$P \left( A=2 \, \middle| \, \dfrac{A^2}{B
Another example of `$$\prod_{i=1}^{n} x_i - 1$$` inline formulas.
Function example:
Function example:
`$$
f(x)=
\begin{cases}
1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\
1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\
0 & \quad \text{otherwise}
\end{cases}
$$`
$$
`
And a matrix:
`$$
M =
`
$$
M =
\begin{bmatrix}
\frac{5}{6} & \frac{1}{6} & 0 \\[0.3em]
\frac{5}{6} & 0 & \frac{1}{6} \\[0.3em]
0 & \frac{5}{6} & \frac{1}{6}
\end{bmatrix}
$$`
$$
`
LaTeX ypesetting won't be rendered. Use NostrMarkup delimeter tables for this sort of thing.
@ -61,7 +67,7 @@ We also recognize common LaTeX statements: @@ -61,7 +67,7 @@ We also recognize common LaTeX statements:
`\sqrt{x^2+1}`
Greek letters are a snap: `$\Psi$`, `$\psi$`, `$\Phi$`, `$\phi$`.
Greek letters are a snap: `$\Psi$`, `$\psi$`, `$\Phi$`, `$\phi$`.
Equations within text are easy--- A well known Maxwell thermodynamic relation is `$\left.{\partial T \over \partial P}\right|_{s} = \left.{\partial v \over \partial s}\right|_{P}$`.
@ -133,3 +139,4 @@ This document should demonstrate that: @@ -133,3 +139,4 @@ This document should demonstrate that:
3. Regular code blocks remain unchanged
4. Mixed content is handled correctly
5. Edge cases are handled gracefully
$$

56
tests/unit/latexRendering.test.ts

@ -9,53 +9,75 @@ describe("LaTeX and AsciiMath Rendering in Inline Code Blocks", () => { @@ -9,53 +9,75 @@ describe("LaTeX and AsciiMath Rendering in Inline Code Blocks", () => {
// Extract the markdown content field from the JSON event
const content = JSON.parse(raw).content;
it('renders LaTeX inline and display math correctly', async () => {
it("renders LaTeX inline and display math correctly", async () => {
const html = await parseAdvancedmarkup(content);
// Test basic LaTeX examples from the test document
expect(html).toMatch(/<span class="math-inline">\$\\sqrt\{x\}\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$\\sqrt\{x\}\$\$<\/div>/);
expect(html).toMatch(/<span class="math-inline">\$\\mathbb\{N\} = \\{ a \\in \\mathbb\{Z\} : a > 0 \\}\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$P \\left\( A=2 \\, \\middle\| \\, \\dfrac\{A\^2\}\{B\}>4 \\right\)\$\$<\/div>/);
expect(html).toMatch(
/<span class="math-inline">\$\\mathbb\{N\} = \\{ a \\in \\mathbb\{Z\} : a > 0 \\}\$<\/span>/,
);
expect(html).toMatch(
/<div class="math-block">\$\$P \\left\( A=2 \\, \\middle\| \\, \\dfrac\{A\^2\}\{B\}>4 \\right\)\$\$<\/div>/,
);
});
it('renders AsciiMath inline and display math correctly', async () => {
it("renders AsciiMath inline and display math correctly", async () => {
const html = await parseAdvancedmarkup(content);
// Test AsciiMath examples
expect(html).toMatch(/<span class="math-inline">\$E=mc\^2\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$sum_\(k=1\)\^n k = 1\+2\+ cdots \+n=\(n\(n\+1\)\)\/2\$\$<\/div>/);
expect(html).toMatch(/<div class="math-block">\$\$int_0\^1 x\^2 dx\$\$<\/div>/);
expect(html).toMatch(
/<div class="math-block">\$\$sum_\(k=1\)\^n k = 1\+2\+ cdots \+n=\(n\(n\+1\)\)\/2\$\$<\/div>/,
);
expect(html).toMatch(
/<div class="math-block">\$\$int_0\^1 x\^2 dx\$\$<\/div>/,
);
});
it('renders LaTeX array and matrix environments as math', async () => {
it("renders LaTeX array and matrix environments as math", async () => {
const html = await parseAdvancedmarkup(content);
// Test array and matrix environments
expect(html).toMatch(/<div class="math-block">\$\$[\s\S]*\\begin\{array\}\{ccccc\}[\s\S]*\\end\{array\}[\s\S]*\$\$<\/div>/);
expect(html).toMatch(/<div class="math-block">\$\$[\s\S]*\\begin\{bmatrix\}[\s\S]*\\end\{bmatrix\}[\s\S]*\$\$<\/div>/);
expect(html).toMatch(
/<div class="math-block">\$\$[\s\S]*\\begin\{array\}\{ccccc\}[\s\S]*\\end\{array\}[\s\S]*\$\$<\/div>/,
);
expect(html).toMatch(
/<div class="math-block">\$\$[\s\S]*\\begin\{bmatrix\}[\s\S]*\\end\{bmatrix\}[\s\S]*\$\$<\/div>/,
);
});
it('handles unsupported LaTeX environments gracefully', async () => {
it("handles unsupported LaTeX environments gracefully", async () => {
const html = await parseAdvancedmarkup(content);
// Should show a message and plaintext for tabular
expect(html).toMatch(/<div class="unrendered-latex">/);
expect(html).toMatch(/Unrendered, as it is LaTeX typesetting, not a formula:/);
expect(html).toMatch(
/Unrendered, as it is LaTeX typesetting, not a formula:/,
);
expect(html).toMatch(/\\\\begin\{tabular\}/);
});
it('renders mixed LaTeX and AsciiMath correctly', async () => {
it("renders mixed LaTeX and AsciiMath correctly", async () => {
const html = await parseAdvancedmarkup(content);
// Test mixed content
expect(html).toMatch(/<span class="math-inline">\$\\frac\{1\}\{2\}\$<\/span>/);
expect(html).toMatch(
/<span class="math-inline">\$\\frac\{1\}\{2\}\$<\/span>/,
);
expect(html).toMatch(/<span class="math-inline">\$1\/2\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$\\sum_\{i=1\}\^n x_i\$\$<\/div>/);
expect(html).toMatch(/<div class="math-block">\$\$sum_\(i=1\)\^n x_i\$\$<\/div>/);
expect(html).toMatch(
/<div class="math-block">\$\$\\sum_\{i=1\}\^n x_i\$\$<\/div>/,
);
expect(html).toMatch(
/<div class="math-block">\$\$sum_\(i=1\)\^n x_i\$\$<\/div>/,
);
});
it('handles edge cases and regular code blocks', async () => {
it("handles edge cases and regular code blocks", async () => {
const html = await parseAdvancedmarkup(content);
// Test regular code blocks (should remain as code, not math)
expect(html).toMatch(/<code[^>]*>\$19\.99<\/code>/);
expect(html).toMatch(/<code[^>]*>echo &quot;Price: \$100&quot;<\/code>/);
expect(html).toMatch(/<code[^>]*>const price = \\`\$\$\{amount\}\\`<\/code>/);
expect(html).toMatch(
/<code[^>]*>const price = \\`\$\$\{amount\}\\`<\/code>/,
);
expect(html).toMatch(/<code[^>]*>color: \$primary-color<\/code>/);
});
});

Loading…
Cancel
Save