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. 251
      src/lib/components/CommentBox.svelte
  8. 310
      src/lib/components/EventDetails.svelte
  9. 312
      src/lib/components/EventInput.svelte
  10. 725
      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. 23
      src/lib/components/RelayStatus.svelte
  20. 28
      src/lib/components/Toc.svelte
  21. 180
      src/lib/components/ZettelEditor.svelte
  22. 34
      src/lib/components/cards/BlogHeader.svelte
  23. 52
      src/lib/components/cards/ProfileHeader.svelte
  24. 92
      src/lib/components/publications/Publication.svelte
  25. 248
      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. 55
      src/lib/components/util/Details.svelte
  35. 90
      src/lib/components/util/LazyImage.svelte
  36. 558
      src/lib/components/util/Profile.svelte
  37. 150
      src/lib/components/util/TocToggle.svelte
  38. 12
      src/lib/components/util/ViewPublicationLink.svelte
  39. 55
      src/lib/consts.ts
  40. 269
      src/lib/data_structures/publication_tree.ts
  41. 34
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  42. 396
      src/lib/ndk.ts
  43. 37
      src/lib/parser.ts
  44. 115
      src/lib/services/publisher.ts
  45. 81
      src/lib/snippets/UserSnippets.svelte
  46. 23
      src/lib/stores.ts
  47. 2
      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. 73
      src/lib/utils/community_checker.ts
  53. 184
      src/lib/utils/event_input_utils.ts
  54. 105
      src/lib/utils/event_search.ts
  55. 31
      src/lib/utils/image_utils.ts
  56. 22
      src/lib/utils/indexEventCache.ts
  57. 6
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  58. 27
      src/lib/utils/markup/advancedMarkupParser.ts
  59. 7
      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. 312
      src/lib/utils/nostrEventService.ts
  64. 243
      src/lib/utils/nostrUtils.ts
  65. 400
      src/lib/utils/profile_search.ts
  66. 44
      src/lib/utils/relayDiagnostics.ts
  67. 531
      src/lib/utils/relay_management.ts
  68. 10
      src/lib/utils/searchCache.ts
  69. 7
      src/lib/utils/search_constants.ts
  70. 6
      src/lib/utils/search_types.ts
  71. 26
      src/lib/utils/search_utility.ts
  72. 27
      src/lib/utils/search_utils.ts
  73. 576
      src/lib/utils/subscription_search.ts
  74. 4
      src/routes/+layout.svelte
  75. 116
      src/routes/+layout.ts
  76. 96
      src/routes/+page.svelte
  77. 9
      src/routes/about/+page.svelte
  78. 26
      src/routes/contact/+page.svelte
  79. 394
      src/routes/events/+page.svelte
  80. 109
      src/routes/new/compose/+page.svelte
  81. 79
      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. 20
      test_data/LaTeXtestfile.json
  87. 13
      test_data/LaTeXtestfile.md
  88. 56
      tests/unit/latexRendering.test.ts

3
.vscode/settings.json vendored

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

48
src/app.css

@ -3,6 +3,7 @@
@import "./styles/publications.css"; @import "./styles/publications.css";
@import "./styles/visualize.css"; @import "./styles/visualize.css";
@import "./styles/events.css"; @import "./styles/events.css";
@import "./styles/asciidoc.css";
/* Custom styles */ /* Custom styles */
@layer base { @layer base {
@ -154,24 +155,14 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @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 { div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800; @apply bg-primary-100 dark:bg-primary-800;
} }
div.skeleton-leather {
@apply h-48;
}
div.textarea-leather { div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
@ -259,7 +250,7 @@
} }
.ArticleBox.grid.active .ArticleBoxImage { .ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72; @apply max-h-40;
} }
.tags span { .tags span {
@ -297,6 +288,8 @@
/* Rendered publication content */ /* Rendered publication content */
.publication-leather { .publication-leather {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;
scroll-margin-top: 150px;
scroll-behavior: smooth;
h1, h1,
h2, h2,
@ -450,6 +443,21 @@
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; 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 { .description-textarea {
min-height: 100% !important; min-height: 100% !important;
} }
@ -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 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; @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;
}
} }

251
src/lib/components/CommentBox.svelte

@ -4,9 +4,12 @@
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility"; 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 type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
extractRootEventInfo, extractRootEventInfo,
@ -16,14 +19,9 @@
publishEvent, publishEvent,
navigateToEvent, navigateToEvent,
} from "$lib/utils/nostrEventService"; } from "$lib/utils/nostrEventService";
import { get } from 'svelte/store'; import { tick } from "svelte";
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 { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
@ -36,17 +34,17 @@
let success = $state<{ relay: string; eventId: string } | null>(null); let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let showOtherRelays = $state(false); let showOtherRelays = $state(false);
let showFallbackRelays = $state(false); let showSecondaryRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null); let userProfile = $state<NostrProfile | null>(null);
// Add state for modals and search // Add state for modals and search
let showMentionModal = $state(false); let showMentionModal = $state(false);
let showWikilinkModal = $state(false); let showWikilinkModal = $state(false);
let mentionSearch = $state(''); let mentionSearch = $state("");
let mentionResults = $state<NostrProfile[]>([]); let mentionResults = $state<NostrProfile[]>([]);
let mentionLoading = $state(false); let mentionLoading = $state(false);
let wikilinkTarget = $state(''); let wikilinkTarget = $state("");
let wikilinkLabel = $state(''); let wikilinkLabel = $state("");
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null; let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let mentionSearchInput: HTMLInputElement | undefined; let mentionSearchInput: HTMLInputElement | undefined;
@ -54,7 +52,7 @@
$effect(() => { $effect(() => {
if (showMentionModal) { if (showMentionModal) {
// Reset search when modal opens // Reset search when modal opens
mentionSearch = ''; mentionSearch = "";
mentionResults = []; mentionResults = [];
mentionLoading = false; mentionLoading = false;
// Focus the search input after a brief delay to ensure modal is rendered // Focus the search input after a brief delay to ensure modal is rendered
@ -63,23 +61,23 @@
}, 100); }, 100);
} else { } else {
// Reset search when modal closes // Reset search when modal closes
mentionSearch = ''; mentionSearch = "";
mentionResults = []; mentionResults = [];
mentionLoading = false; mentionLoading = false;
} }
}); });
$effect(() => { $effect(() => {
const trimmedPubkey = $activePubkey?.trim(); const trimmedPubkey = $userPubkey?.trim();
const npub = toNpub(trimmedPubkey); const npub = toNpub(trimmedPubkey);
if (npub) { if (npub) {
// Call an async function, but don't make the effect itself async // Call an async function, but don't make the effect itself async
getUserMetadata(npub).then(metadata => { getUserMetadata(npub).then((metadata) => {
userProfile = metadata; userProfile = metadata;
}); });
} else if (trimmedPubkey) { } else if (trimmedPubkey) {
userProfile = null; 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 { } else {
userProfile = null; userProfile = null;
error = null; error = null;
@ -89,10 +87,9 @@
$effect(() => { $effect(() => {
if (!success) return; if (!success) return;
content = ''; content = "";
preview = ''; preview = "";
} });
);
// Markup buttons // Markup buttons
const markupButtons = [ const markupButtons = [
@ -105,8 +102,20 @@
{ label: "List", action: () => insertMarkup("* ", "") }, { label: "List", action: () => insertMarkup("* ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") }, { label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") }, { 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) { function insertMarkup(prefix: string, suffix: string) {
@ -142,7 +151,7 @@
preview = ""; preview = "";
error = null; error = null;
showOtherRelays = false; showOtherRelays = false;
showFallbackRelays = false; showSecondaryRelays = false;
} }
function removeFormatting() { function removeFormatting() {
@ -161,22 +170,24 @@
async function handleSubmit( async function handleSubmit(
useOtherRelays = false, useOtherRelays = false,
useFallbackRelays = false, useSecondaryRelays = false,
) { ) {
isSubmitting = true; isSubmitting = true;
error = null; error = null;
success = null; success = null;
try { try {
const pk = $activePubkey || ''; const pk = $userPubkey || "";
const npub = toNpub(pk); const npub = toNpub(pk);
if (!npub) { 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) { 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; const parent = props.event;
@ -191,34 +202,37 @@
const tags = buildReplyTags(parent, rootInfo, parentInfo, kind); const tags = buildReplyTags(parent, rootInfo, parentInfo, kind);
// Create and sign the event // Create and sign the event
const { event: signedEvent } = await createSignedEvent(content, pk, kind, tags); const { event: signedEvent } = await createSignedEvent(
content,
// Publish the event pk,
const result = await publishEvent( kind,
signedEvent, tags,
useOtherRelays,
useFallbackRelays,
props.userRelayPreference
); );
if (result.success) { // Publish the event using the new relay system
success = { relay: result.relay!, eventId: result.eventId! }; let relays = $activeOutboxRelays;
// Navigate to the published event
navigateToEvent(result.eventId!); if (useOtherRelays && !useSecondaryRelays) {
} else { relays = [...$activeOutboxRelays, ...$activeInboxRelays];
if (!useOtherRelays && !useFallbackRelays) { } else if (useSecondaryRelays) {
showOtherRelays = true; // For secondary relays, use a subset of outbox relays
error = "Failed to publish to primary relays. Would you like to try the other relays?"; relays = $activeOutboxRelays.slice(0, 3); // Use first 3 outbox 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.";
}
} }
} catch (e: unknown) {
console.error('Error publishing comment:', e); const successfulRelays = await publishEvent(signedEvent, relays);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
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 { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -226,8 +240,8 @@
// Add a helper to shorten npub // Add a helper to shorten npub
function shortenNpub(npub: string | undefined) { function shortenNpub(npub: string | undefined) {
if (!npub) return ''; if (!npub) return "";
return npub.slice(0, 8) + '…' + npub.slice(-4); return npub.slice(0, 8) + "…" + npub.slice(-4);
} }
async function insertAtCursor(text: string) { async function insertAtCursor(text: string) {
@ -263,26 +277,49 @@
return; return;
} }
console.log("Starting search for:", mentionSearch.trim());
// Set loading state // Set loading state
mentionLoading = true; mentionLoading = true;
isSearching = true; isSearching = true;
try { try {
console.log("Search promise created, waiting for result...");
const result = await searchProfiles(mentionSearch.trim()); 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; mentionResults = result.profiles;
communityStatus = result.Status; communityStatus = result.Status;
console.log(
"State updated - mentionResults length:",
mentionResults.length,
);
console.log(
"State updated - communityStatus keys:",
Object.keys(communityStatus),
);
} catch (error) { } catch (error) {
console.error('Error searching mentions:', error); console.error("Error searching mentions:", error);
mentionResults = []; mentionResults = [];
communityStatus = {}; communityStatus = {};
} finally { } finally {
mentionLoading = false; mentionLoading = false;
isSearching = false; isSearching = false;
console.log(
"Search finished - loading:",
mentionLoading,
"searching:",
isSearching,
);
} }
} }
function selectMention(profile: NostrProfile) { function selectMention(profile: NostrProfile) {
let mention = ''; let mention = "";
if (profile.pubkey) { if (profile.pubkey) {
try { try {
const npub = toNpub(profile.pubkey); const npub = toNpub(profile.pubkey);
@ -293,22 +330,22 @@
mention = `nostr:${profile.pubkey}`; mention = `nostr:${profile.pubkey}`;
} }
} catch (e) { } catch (e) {
console.error('Error in toNpub:', e); console.error("Error in toNpub:", e);
// Fallback to pubkey if conversion fails // Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`; mention = `nostr:${profile.pubkey}`;
} }
} else { } 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}`; mention = `@${profile.displayName || profile.name}`;
} }
insertAtCursor(mention); insertAtCursor(mention);
showMentionModal = false; showMentionModal = false;
mentionSearch = ''; mentionSearch = "";
mentionResults = []; mentionResults = [];
} }
function insertWikilink() { function insertWikilink() {
let markup = ''; let markup = "";
if (wikilinkLabel.trim()) { if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`; markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
} else { } else {
@ -316,8 +353,8 @@
} }
insertAtCursor(markup); insertAtCursor(markup);
showWikilinkModal = false; showWikilinkModal = false;
wikilinkTarget = ''; wikilinkTarget = "";
wikilinkLabel = ''; wikilinkLabel = "";
} }
function handleViewComment() { function handleViewComment() {
@ -356,7 +393,7 @@
bind:value={mentionSearch} bind:value={mentionSearch}
bind:this={mentionSearchInput} bind:this={mentionSearchInput}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) { if (e.key === "Enter" && mentionSearch.trim() && !isSearching) {
searchMentions(); searchMentions();
} }
}} }}
@ -383,23 +420,47 @@
{#if mentionLoading} {#if mentionLoading}
<div class="text-center py-4">Searching...</div> <div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0} {: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"> <ul class="space-y-1 p-2">
{#each mentionResults as profile} {#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]} {#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"> <div
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
<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"/> 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> </svg>
</div> </div>
{:else} {:else}
<div class="flex-shrink-0 w-6 h-6"></div> <div class="flex-shrink-0 w-6 h-6"></div>
{/if} {/if}
{#if profile.picture} {#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} {: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} {/if}
<div class="flex flex-col text-left min-w-0 flex-1"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate"> <span class="font-semibold truncate">
@ -407,11 +468,24 @@
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <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} {profile.nip05}
</span> </span>
{/if} {/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> </div>
</button> </button>
{/each} {/each}
@ -420,7 +494,9 @@
{:else if mentionSearch.trim()} {:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div> <div class="text-center py-4 text-gray-500">No results found</div>
{:else} {: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} {/if}
</div> </div>
</Modal> </Modal>
@ -447,8 +523,15 @@
class="mb-4" class="mb-4"
/> />
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button> <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="alternative"
on:click={() => {
showWikilinkModal = false;
}}>Cancel</Button
>
</div> </div>
</Modal> </Modal>
@ -462,7 +545,9 @@
class="w-full" class="w-full"
/> />
</div> </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} {@html preview}
</div> </div>
</div> </div>
@ -475,7 +560,7 @@
>Try Other Relays</Button >Try Other Relays</Button
> >
{/if} {/if}
{#if showFallbackRelays} {#if showSecondaryRelays}
<Button <Button
size="xs" size="xs"
class="mt-2" class="mt-2"
@ -515,16 +600,16 @@
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || {userProfile.displayName ||
userProfile.name || userProfile.name ||
nip19.npubEncode($activePubkey || '').slice(0, 8) + "..."} nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$activePubkey} disabled={isSubmitting || !content.trim() || !$userPubkey}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !$activePubkey} {#if !$userPubkey}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -534,7 +619,7 @@
</Button> </Button>
</div> </div>
{#if !$activePubkey} {#if !$userPubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your
current account. current account.

310
src/lib/components/EventDetails.svelte

@ -4,13 +4,16 @@
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; 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 type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte"; import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; 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 { const {
event, event,
@ -52,7 +55,10 @@
} }
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag // 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 (= ) // First try to find a document header (= )
const docMatch = event.content.match(/^=\s+(.+)$/m); const docMatch = event.content.match(/^=\s+(.+)$/m);
if (docMatch) { if (docMatch) {
@ -82,59 +88,218 @@
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; 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[]): { function getTagButtonInfo(tag: string[]): {
text: string; text: string;
gotoValue?: string; gotoValue?: string;
} { } {
if (tag[0] === "a" && tag.length > 1) { if (tag[0] === "a" && tag.length > 1) {
// Parse the a-tag: kind:pubkey:d
const parts = tag[1].split(":"); const parts = tag[1].split(":");
if (parts.length >= 3) { if (parts.length >= 3) {
const [kind, pubkey, d] = parts; const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try { try {
const naddr = naddrEncode( const mockEvent = {
{ kind: +kind,
kind: parseInt(kind),
pubkey, pubkey,
tags: [["d", d]], tags: [["d", d]],
content: "", content: "",
id: "", id: "",
sig: "", sig: "",
} as any, } as any;
standardRelays, const naddr = naddrEncode(mockEvent, $activeInboxRelays);
); return {
console.log("Converted a-tag to naddr:", tag[1], "->", naddr); text: `a:${tag[1]}`,
return { text: `a:${tag[1]}`, gotoValue: naddr }; gotoValue: naddr,
};
} catch (error) { } catch (error) {
console.error("Error encoding a-tag to naddr:", error); console.warn("Failed to encode naddr for a tag:", tag[1], error);
return { text: `a:${tag[1]}`, gotoValue: tag[1] }; return { text: `a:${tag[1]}` };
} }
} else {
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
} }
return { text: `a:${tag[1]}`, gotoValue: tag[1] }; } else {
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
} }
if (tag[0] === "e" && tag.length > 1) { } else if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode( // Validate that event ID is a valid hex string
{ if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1], id: tag[1],
kind: 1, kind: 1,
content: "", content: "",
tags: [], tags: [],
pubkey: "", pubkey: "",
sig: "", sig: "",
} as any, } as any;
standardRelays, const nevent = neventEncode(mockEvent, $activeInboxRelays);
); return {
return { text: `e:${tag[1]}`, gotoValue: nevent }; text: `e:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` };
} }
return { text: "" }; } else {
console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` };
}
} 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: `${tag[0]}:${tag[1]}` };
} }
function navigateToEvent(gotoValue: string) { function getNeventUrl(event: NDKEvent): string {
console.log("Navigating to event:", gotoValue); return neventEncode(event, $activeInboxRelays);
// Add a small delay to ensure the current search state is cleared }
setTimeout(() => {
goto(`/events?id=${encodeURIComponent(gotoValue)}`); function getNaddrUrl(event: NDKEvent): string {
}, 10); return naddrEncode(event, $activeInboxRelays);
}
function getNprofileUrl(pubkey: string): string {
return nprofileEncode(pubkey, $activeInboxRelays);
} }
$effect(() => { $effect(() => {
@ -176,14 +341,14 @@
// nprofile // nprofile
ids.push({ ids.push({
label: "nprofile", label: "nprofile",
value: nprofileEncode(event.pubkey, standardRelays), value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}`, link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
}); });
// nevent // nevent
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: neventEncode(event, standardRelays), value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`, link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
}); });
// hex pubkey // hex pubkey
ids.push({ label: "pubkey", value: event.pubkey }); ids.push({ label: "pubkey", value: event.pubkey });
@ -191,12 +356,12 @@
// nevent // nevent
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: neventEncode(event, standardRelays), value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`, link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
}); });
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, standardRelays); const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id // hex id
@ -211,6 +376,21 @@
const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase(); const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue); 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> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
@ -222,16 +402,16 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-600 dark:text-gray-400"
Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
authorDisplayName, profile?.display_name || event.pubkey,
)} )}</span
</span> >
{:else} {:else}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-600 dark:text-gray-400"
Author: {authorDisplayName} >Author: {profile?.display_name || event.pubkey}</span
</span> >
{/if} {/if}
</div> </div>
@ -255,15 +435,19 @@
<span class="text-gray-700 dark:text-gray-300">Tags:</span> <span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag} {#each getEventHashtags(event) as tag}
<span <button
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium" onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
>#{tag}</span 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} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
<!-- Content --> <!-- Content -->
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
{#if event.kind !== 0} {#if event.kind !== 0}
@ -298,8 +482,36 @@
{@const tagInfo = getTagButtonInfo(tag)} {@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue} {#if tagInfo.text && tagInfo.gotoValue}
<button <button
onclick={() => onclick={() => {
navigateToEvent(tagInfo.gotoValue!)} // 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" 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} {tagInfo.text}

312
src/lib/components/EventInput.svelte

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

725
src/lib/components/EventSearch.svelte

@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { Input, Button } from "flowbite-svelte"; import { Input, Button } from "flowbite-svelte";
import { Spinner } from "flowbite-svelte"; import { Spinner } from "flowbite-svelte";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import RelayDisplay from "./RelayDisplay.svelte"; import {
import { searchEvent, searchBySubscription, searchNip05 } from "$lib/utils/search_utility"; searchEvent,
import { neventEncode, naddrEncode } from "$lib/utils"; searchBySubscription,
import { standardRelays } from "$lib/consts"; searchNip05,
} from "$lib/utils/search_utility";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import type { SearchResult } from '$lib/utils/search_types';
import { userStore } from "$lib/stores/userStore";
import { get } from "svelte/store";
// Props definition // Props definition
let { let {
@ -32,7 +40,7 @@
eventIds: Set<string>, eventIds: Set<string>,
addresses: Set<string>, addresses: Set<string>,
searchType?: string, searchType?: string,
searchTerm?: string searchTerm?: string,
) => void; ) => void;
event: NDKEvent | null; event: NDKEvent | null;
onClear?: () => void; onClear?: () => void;
@ -42,7 +50,6 @@
// Component state // Component state
let searchQuery = $state(""); let searchQuery = $state("");
let localError = $state<string | null>(null); let localError = $state<string | null>(null);
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>({});
let foundEvent = $state<NDKEvent | null>(null); let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false); let searching = $state(false);
let searchCompleted = $state(false); let searchCompleted = $state(false);
@ -55,57 +62,306 @@
let currentAbortController: AbortController | null = null; let currentAbortController: AbortController | null = null;
// Derived values // Derived values
let hasActiveSearch = $derived(searching || (Object.values(relayStatuses).some(s => s === "pending") && !foundEvent)); let hasActiveSearch = $derived(searching && !foundEvent);
let showError = $derived(localError || error); let showError = $derived(localError || error);
let showSuccess = $derived(searchCompleted && searchResultCount !== null); let showSuccess = $derived(searchCompleted && searchResultCount !== null);
// Track last processed values to prevent loops // Track last processed values to prevent loops
let lastProcessedSearchValue = $state<string | null>(null); let lastProcessedSearchValue = $state<string | null>(null);
let lastProcessedDTagValue = $state<string | null>(null); let lastProcessedDTagValue = $state<string | null>(null);
let isProcessingSearch = $state(false);
let currentProcessingSearchValue = $state<string | null>(null);
let lastSearchValue = $state<string | null>(null);
let isWaitingForSearchResult = $state(false);
let isUserEditing = $state(false);
// Simple effect to handle searchValue changes // Move search handler functions above all $effect runes
$effect(() => { async function handleNip05Search(query: string) {
if (searchValue && !searching && !isResetting && searchValue !== lastProcessedSearchValue) { try {
console.log("EventSearch: Processing searchValue:", searchValue); const foundEvent = await searchNip05(query);
// Check if we already have this event displayed
if (foundEvent) { if (foundEvent) {
const currentEventId = foundEvent.id; handleFoundEvent(foundEvent);
let currentNaddr = null; updateSearchState(false, true, 1, "nip05");
let currentNevent = null; } else {
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try { try {
currentNevent = neventEncode(foundEvent, standardRelays); activeSub.stop();
} catch (e) { } catch (e) {
console.warn("Could not encode nevent for current event:", e); console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, "nip05");
}
} catch (error) {
localError =
error instanceof Error ? error.message : "NIP-05 lookup failed";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
lastSearchValue = null;
}
} }
async function handleEventSearch(query: string) {
try { try {
currentNaddr = naddrEncode(foundEvent, standardRelays); const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) { } catch (e) {
console.warn("Could not encode naddr for current event:", e); console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, "event");
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
}
}
async function handleSearchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false;
isUserEditing = false; // Reset user editing flag when search starts
const query = (
queryOverride !== undefined ? queryOverride || "" : searchQuery || ""
).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, "d");
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("t", searchTerm);
return;
}
} }
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("n", searchTerm);
return;
}
}
if (query.includes("@")) {
await handleNip05Search(query);
return;
}
if (clearInput) {
navigateToSearch(query, "id");
// Don't clear searchQuery here - let the effect handle it
}
await handleEventSearch(query);
}
// Keep searchQuery in sync with searchValue and dTagValue props
$effect(() => {
// Only sync if we're not currently searching, resetting, or if the user is editing
if (searching || isResetting || isUserEditing) {
return;
}
if (dTagValue) {
// If dTagValue is set, show it as "d:tag" in the search bar
searchQuery = `d:${dTagValue}`;
} else if (searchValue) {
// searchValue should already be in the correct format (t:, n:, d:, etc.)
searchQuery = searchValue;
} else if (!searchQuery) {
// Only clear if searchQuery is empty to avoid clearing user input
searchQuery = "";
}
});
// Debounced effect to handle searchValue changes
$effect(() => {
if (
!searchValue ||
searching ||
isResetting ||
isProcessingSearch ||
isWaitingForSearchResult
) {
return;
}
// Check if we've already processed this searchValue
if (searchValue === lastProcessedSearchValue) {
return;
}
// If we already have the event for this searchValue, do nothing
if (foundEvent) {
const currentEventId = foundEvent.id;
let currentNaddr = null;
let currentNevent = null;
let currentNpub = null;
try {
currentNevent = neventEncode(foundEvent, $activeInboxRelays);
} catch {}
try {
currentNaddr = getMatchingTags(foundEvent, "d")[0]?.[1]
? naddrEncode(foundEvent, $activeInboxRelays)
: null;
} catch {}
try {
currentNpub = foundEvent.kind === 0 ? toNpub(foundEvent.pubkey) : null;
} catch {}
// If the search value matches any of our current event identifiers, skip the search // Debug log for comparison
if (searchValue === currentEventId || searchValue === currentNaddr || searchValue === currentNevent) { console.log(
console.log("EventSearch: Search value matches current event, skipping search"); "[EventSearch effect] searchValue:",
searchValue,
"foundEvent.id:",
currentEventId,
"foundEvent.pubkey:",
foundEvent.pubkey,
"toNpub(pubkey):",
currentNpub,
"foundEvent.kind:",
foundEvent.kind,
"currentNaddr:",
currentNaddr,
"currentNevent:",
currentNevent,
);
// Also check if searchValue is an nprofile and matches the current event's pubkey
let currentNprofile = null;
if (
searchValue &&
searchValue.startsWith("nprofile1") &&
foundEvent.kind === 0
) {
try {
currentNprofile = nprofileEncode(foundEvent.pubkey, $activeInboxRelays);
} catch {}
}
if (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
) {
// Already displaying the event for this searchValue
lastProcessedSearchValue = searchValue; lastProcessedSearchValue = searchValue;
return; return;
} }
} }
// Otherwise, trigger a search for the new value
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
isProcessingSearch = true;
isWaitingForSearchResult = true;
lastProcessedSearchValue = searchValue; lastProcessedSearchValue = searchValue;
// Always search when searchValue changes, regardless of foundEvent if (searchValue) {
handleSearchEvent(false, searchValue); handleSearchEvent(false, searchValue);
} }
}, 300);
});
// Add debouncing to prevent rapid successive searches
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Cleanup function to clear timeout when component is destroyed
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
}); });
// Simple effect to handle dTagValue changes // Simple effect to handle dTagValue changes
$effect(() => { $effect(() => {
if (dTagValue && !searching && !isResetting && dTagValue !== lastProcessedDTagValue) { if (
dTagValue &&
!searching &&
!isResetting &&
dTagValue !== lastProcessedDTagValue
) {
console.log("EventSearch: Processing dTagValue:", dTagValue); console.log("EventSearch: Processing dTagValue:", dTagValue);
lastProcessedDTagValue = dTagValue; lastProcessedDTagValue = dTagValue;
handleSearchBySubscription('d', dTagValue);
// Add a small delay to prevent rapid successive calls
setTimeout(() => {
if (!searching && !isResetting) {
handleSearchBySubscription("d", dTagValue);
}
}, 100);
} }
}); });
@ -117,7 +373,12 @@
}); });
// Search utility functions // Search utility functions
function updateSearchState(isSearching: boolean, completed: boolean = false, count: number | null = null, type: string | null = null) { function updateSearchState(
isSearching: boolean,
completed: boolean = false,
count: number | null = null,
type: string | null = null,
) {
searching = isSearching; searching = isSearching;
searchCompleted = completed; searchCompleted = completed;
searchResultCount = count; searchResultCount = count;
@ -130,10 +391,12 @@
function resetSearchState() { function resetSearchState() {
isResetting = true; isResetting = true;
foundEvent = null; foundEvent = null;
relayStatuses = {};
localError = null; localError = null;
lastProcessedSearchValue = null; lastProcessedSearchValue = null;
lastProcessedDTagValue = null; lastProcessedDTagValue = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
// Cancel ongoing search // Cancel ongoing search
@ -147,7 +410,7 @@
try { try {
activeSub.stop(); activeSub.stop();
} catch (e) { } catch (e) {
console.warn('Error stopping subscription:', e); console.warn("Error stopping subscription:", e);
} }
activeSub = null; activeSub = null;
} }
@ -155,6 +418,12 @@
// Clear search results // Clear search results
onSearchResults([], [], [], new Set(), new Set()); onSearchResults([], [], [], new Set(), new Set());
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
// Reset the flag after a short delay to allow effects to settle // Reset the flag after a short delay to allow effects to settle
setTimeout(() => { setTimeout(() => {
isResetting = false; isResetting = false;
@ -163,14 +432,14 @@
function handleFoundEvent(event: NDKEvent) { function handleFoundEvent(event: NDKEvent) {
foundEvent = event; foundEvent = event;
relayStatuses = {}; // Clear relay statuses when event is found localError = null; // Clear local error when event is found
// Stop any ongoing subscription // Stop any ongoing subscription
if (activeSub) { if (activeSub) {
try { try {
activeSub.stop(); activeSub.stop();
} catch (e) { } catch (e) {
console.warn('Error stopping subscription:', e); console.warn("Error stopping subscription:", e);
} }
activeSub = null; activeSub = null;
} }
@ -185,7 +454,18 @@
searching = false; searching = false;
searchCompleted = true; searchCompleted = true;
searchResultCount = 1; searchResultCount = 1;
searchResultType = 'event'; searchResultType = "event";
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
lastSearchValue = searchValue;
}
// Reset processing flag
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
onEventFound(event); onEventFound(event);
} }
@ -200,21 +480,66 @@
} }
// Search handlers // Search handlers
async function handleSearchBySubscription(searchType: 'd' | 't' | 'n', searchTerm: string) { async function handleSearchBySubscription(
console.log("EventSearch: Starting subscription search:", { searchType, searchTerm }); searchType: "d" | "t" | "n",
searchTerm: string,
) {
console.log("EventSearch: Starting subscription search:", {
searchType,
searchTerm,
});
isResetting = false; // Allow effects to run for new searches isResetting = false; // Allow effects to run for new searches
localError = null; localError = null;
updateSearchState(true); updateSearchState(true);
// Wait for relays to be available (with timeout)
let retryCount = 0;
const maxRetries = 20; // Wait up to 10 seconds (20 * 500ms) for user login to complete
while ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0 && retryCount < maxRetries) {
console.debug(`EventSearch: Waiting for relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500)); // Wait 500ms
retryCount++;
}
// Additional wait for user-specific relays if user is logged in
const currentUser = get(userStore);
if (currentUser.signedIn && currentUser.pubkey) {
console.debug(`EventSearch: User is logged in (${currentUser.pubkey}), waiting for user-specific relays...`);
retryCount = 0;
while ($activeOutboxRelays.length <= 9 && retryCount < maxRetries) {
// If we still have the default relay count (9), wait for user-specific relays
console.debug(`EventSearch: Waiting for user-specific relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500));
retryCount++;
}
}
// Check if we have any relays available
if ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0) {
console.warn("EventSearch: No relays available after waiting, failing search");
localError = "No relays available. Please check your connection and try again.";
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
searching = false;
return;
}
console.log("EventSearch: Relays available, proceeding with search:", {
inboxCount: $activeInboxRelays.length,
outboxCount: $activeOutboxRelays.length
});
try {
// Cancel existing search // Cancel existing search
if (currentAbortController) { if (currentAbortController) {
currentAbortController.abort(); currentAbortController.abort();
} }
currentAbortController = new AbortController(); currentAbortController = new AbortController();
// Add a timeout to prevent hanging searches
try { const searchPromise = searchBySubscription(
const result = await searchBySubscription(
searchType, searchType,
searchTerm, searchTerm,
{ {
@ -227,7 +552,7 @@
updatedResult.eventIds, updatedResult.eventIds,
updatedResult.addresses, updatedResult.addresses,
updatedResult.searchType, updatedResult.searchType,
updatedResult.searchTerm updatedResult.searchTerm,
); );
}, },
onSubscriptionCreated: (sub) => { onSubscriptionCreated: (sub) => {
@ -236,11 +561,19 @@
activeSub.stop(); activeSub.stop();
} }
activeSub = sub; activeSub = sub;
}
}, },
currentAbortController.signal },
currentAbortController.signal,
); );
// Add a 30-second timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Search timeout: No results received within 30 seconds"));
}, 30000);
});
const result = await Promise.race([searchPromise, timeoutPromise]) as any;
console.log("EventSearch: Search completed:", result); console.log("EventSearch: Search completed:", result);
onSearchResults( onSearchResults(
result.events, result.events,
@ -249,247 +582,117 @@
result.eventIds, result.eventIds,
result.addresses, result.addresses,
result.searchType, result.searchType,
result.searchTerm result.searchTerm,
); );
const totalCount =
const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length; result.events.length +
relayStatuses = {}; // Clear relay statuses when search completes result.secondOrder.length +
result.tTagEvents.length;
localError = null; // Clear local error when search completes
// Stop any ongoing subscription // Stop any ongoing subscription
if (activeSub) { if (activeSub) {
try { try {
activeSub.stop(); activeSub.stop();
} catch (e) { } catch (e) {
console.warn('Error stopping subscription:', e); console.warn("Error stopping subscription:", e);
} }
activeSub = null; activeSub = null;
} }
// Abort any ongoing fetch // Abort any ongoing fetch
if (currentAbortController) { if (currentAbortController) {
currentAbortController.abort(); currentAbortController.abort();
currentAbortController = null; currentAbortController = null;
} }
updateSearchState(false, true, totalCount, searchType); updateSearchState(false, true, totalCount, searchType);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
}
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'Search cancelled') { if (error instanceof Error && error.message === "Search cancelled") {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return; return;
} }
console.error("EventSearch: Search failed:", error); console.error("EventSearch: Search failed:", error);
localError = error instanceof Error ? error.message : 'Search failed'; localError = error instanceof Error ? error.message : "Search failed";
// Provide more specific error messages for different failure types // Provide more specific error messages for different failure types
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('timeout') || error.message.includes('connection')) { if (
localError = 'Search timed out. The relays may be temporarily unavailable. Please try again.'; error.message.includes("timeout") ||
} else if (error.message.includes('NDK not initialized')) { error.message.includes("connection")
localError = 'Nostr client not initialized. Please refresh the page and try again.'; ) {
localError =
"Search timed out. The relays may be temporarily unavailable. Please try again.";
} else if (error.message.includes("NDK not initialized")) {
localError =
"Nostr client not initialized. Please refresh the page and try again.";
} else { } else {
localError = `Search failed: ${error.message}`; localError = `Search failed: ${error.message}`;
} }
} }
localError = null; // Clear local error when search fails
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
}
}
async function handleNip05Search(query: string) {
try {
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'nip05');
} else {
relayStatuses = {}; // Clear relay statuses when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, 'nip05');
}
} catch (error) {
localError = error instanceof Error ? error.message : 'NIP-05 lookup failed';
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
}
}
async function handleEventSearch(query: string) {
try {
const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
relayStatuses = {}; // Clear relay statuses when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'event');
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription // Stop any ongoing subscription
if (activeSub) { if (activeSub) {
try { try {
activeSub.stop(); activeSub.stop();
} catch (e) { } catch (e) {
console.warn('Error stopping subscription:', e); console.warn("Error stopping subscription:", e);
} }
activeSub = null; activeSub = null;
} }
// Abort any ongoing fetch // Abort any ongoing fetch
if (currentAbortController) { if (currentAbortController) {
currentAbortController.abort(); currentAbortController.abort();
currentAbortController = null; currentAbortController = null;
} }
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
} isProcessingSearch = false;
} currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
async function handleSearchEvent(clearInput: boolean = true, queryOverride?: string) { // Update last processed search value to prevent re-processing even on error
// Prevent multiple simultaneous searches if (searchValue) {
if (searching) { lastProcessedSearchValue = searchValue;
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false; // Allow effects to run for new searches
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
// Handle different search types
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, 'd');
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('t', searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('n', searchTerm);
return;
}
} }
if (query.includes('@')) {
await handleNip05Search(query);
return;
} }
// Handle regular event search
if (clearInput) {
navigateToSearch(query, 'id');
searchQuery = "";
}
await handleEventSearch(query);
} }
function handleClear() { function handleClear() {
isResetting = true; isResetting = true;
searchQuery = ''; searchQuery = "";
isUserEditing = false; // Reset user editing flag
resetSearchState(); resetSearchState();
// Clear URL parameters to reset the page
goto("", {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// Ensure all search state is cleared // Ensure all search state is cleared
searching = false; searching = false;
searchCompleted = false; searchCompleted = false;
searchResultCount = null; searchResultCount = null;
searchResultType = null; searchResultType = null;
foundEvent = null; foundEvent = null;
relayStatuses = {};
localError = null; localError = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
if (onClear) { if (onClear) {
onClear(); onClear();
@ -506,14 +709,30 @@
return "Search completed. No results found."; return "Search completed. No results found.";
} }
const typeLabel = searchResultType === 'n' ? 'profile' : const typeLabel =
searchResultType === 'nip05' ? 'NIP-05 address' : 'event'; searchResultType === "n"
const countLabel = searchResultType === 'n' ? 'profiles' : 'events'; ? "profile"
: searchResultType === "nip05"
? "NIP-05 address"
: "event";
const countLabel = searchResultType === "n" ? "profiles" : "events";
return searchResultCount === 1 return searchResultCount === 1
? `Search completed. Found 1 ${typeLabel}.` ? `Search completed. Found 1 ${typeLabel}.`
: `Search completed. Found ${searchResultCount} ${countLabel}.`; : `Search completed. Found ${searchResultCount} ${countLabel}.`;
} }
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function getNprofileUrl(pubkey: string): string {
return nprofileEncode(pubkey, $activeInboxRelays);
}
</script> </script>
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
@ -523,7 +742,10 @@
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..." placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..."
class="flex-grow" class="flex-grow"
onkeydown={(e: KeyboardEvent) => e.key === "Enter" && handleSearchEvent(true)} onkeydown={(e: KeyboardEvent) =>
e.key === "Enter" && handleSearchEvent(true)}
oninput={() => (isUserEditing = true)}
onblur={() => (isUserEditing = false)}
/> />
<Button onclick={() => handleSearchEvent(true)} disabled={loading}> <Button onclick={() => handleSearchEvent(true)} disabled={loading}>
{#if searching} {#if searching}
@ -543,42 +765,21 @@
<!-- Error Display --> <!-- Error Display -->
{#if showError} {#if showError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <div
{localError || error} class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
{#if searchQuery.trim()} role="alert"
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
> >
Njump {localError || error}
</a>.
</div>
{/if}
</div> </div>
{/if} {/if}
<!-- Success Display --> <!-- Success Display -->
{#if showSuccess} {#if showSuccess}
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert"> <div
class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg"
role="alert"
>
{getResultMessage()} {getResultMessage()}
</div> </div>
{/if} {/if}
<!-- Relay Status Display -->
<div class="mt-4">
<div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]}
<RelayDisplay {relay} showStatus={true} {status} />
{/each}
</div>
{#if !foundEvent && hasActiveSearch}
<div class="text-gray-700 dark:text-gray-300 mt-2">
Searching relays...
</div>
{/if}
</div>
</div> </div>

78
src/lib/components/Login.svelte

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

8
src/lib/components/Navigation.svelte

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

59
src/lib/components/NetworkStatus.svelte

@ -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 @@
} from "$lib/snippets/PublicationSnippets.svelte"; } from "$lib/snippets/PublicationSnippets.svelte";
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { onMount } from "svelte";
// TODO: Fix move between parents. // TODO: Fix move between parents.

93
src/lib/components/PublicationHeader.svelte

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

11
src/lib/components/RelayDisplay.svelte

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

23
src/lib/components/RelayStatus.svelte

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

28
src/lib/components/Toc.svelte

@ -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 @@
<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>

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

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

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

@ -1,11 +1,16 @@
<script lang="ts"> <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 { onMount } from "svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts"; import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
import QrCode from "$components/util/QrCode.svelte"; import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.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 // @ts-ignore
import { bech32 } from "https://esm.sh/bech32"; import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
@ -41,9 +46,11 @@
$effect(() => { $effect(() => {
if (event?.pubkey) { if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => { checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status; communityStatus = status;
}).catch(() => { })
.catch(() => {
communityStatus = false; communityStatus = false;
}); });
} }
@ -57,18 +64,22 @@
{#if profile} {#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl"> <Card class="ArticleBox card-leather w-full max-w-2xl">
<div class="space-y-4"> <div class="space-y-4">
{#if profile.banner}
<div class="ArticleBoxImage flex col justify-center"> <div class="ArticleBoxImage flex col justify-center">
<Img {#if profile.banner}
<LazyImage
src={profile.banner} src={profile.banner}
class="rounded w-full max-h-72 object-cover"
alt="Profile banner" alt="Profile banner"
onerror={(e) => { eventId={event.id}
(e.target as HTMLImageElement).style.display = "none"; className="rounded w-full max-h-72 object-cover"
}}
/> />
{:else}
<div
class="rounded w-full max-h-72"
style="background-color: {generateDarkPastelColor(event.id)};"
>
</div> </div>
{/if} {/if}
</div>
<div class="flex flex-row space-x-4 items-center"> <div class="flex flex-row space-x-4 items-center">
{#if profile.picture} {#if profile.picture}
<img <img
@ -89,9 +100,18 @@
event.pubkey, event.pubkey,
)} )}
{#if communityStatus === true} {#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"> <div
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
<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"/> 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> </svg>
</div> </div>
{:else if communityStatus === false} {:else if communityStatus === false}
@ -156,12 +176,12 @@
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> <dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all"> <dd class="break-all">
{#if id.link} {#if id.link}
<Button <button
class="text-primary-700 dark:text-primary-200" 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)} onclick={() => navigateToIdentifier(id.link)}
> >
{id.value} {id.value}
</Button> </button>
{:else} {:else}
{id.value} {id.value}
{/if} {/if}

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

@ -7,6 +7,7 @@
SidebarGroup, SidebarGroup,
SidebarWrapper, SidebarWrapper,
Heading, Heading,
CloseButton,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { import {
@ -15,13 +16,13 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte"; import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte"; import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import { pharosInstance } from "$lib/parser"; import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string; rootAddress: string;
@ -29,16 +30,18 @@
indexEvent: NDKEvent; indexEvent: NDKEvent;
}>(); }>();
const publicationTree = getContext("publicationTree") as PublicationTree; const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading // #region Loading
// TODO: Test load handling.
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false); let isLoading = $state<boolean>(false);
let isDone = $state<boolean>(false); let isDone = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null); let lastElementRef = $state<HTMLElement | null>(null);
let activeAddress = $state<string | null>(null);
let observer: IntersectionObserver; let observer: IntersectionObserver;
@ -82,7 +85,8 @@
// #endregion // #endregion
// region Columns visibility // #region Columns visibility
let currentBlog: null | string = $state(null); let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null); let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041); const isLeaf = $derived(indexEvent.kind === 30041);
@ -91,6 +95,10 @@
return currentBlog !== null && $publicationColumnVisibility.inner; return currentBlog !== null && $publicationColumnVisibility.inner;
} }
function closeToc() {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
function closeDiscussion() { function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false })); publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
} }
@ -119,6 +127,33 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140; 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(() => { onDestroy(() => {
// reset visibility // reset visibility
publicationColumnVisibility.reset(); publicationColumnVisibility.reset();
@ -147,20 +182,42 @@
}, },
{ threshold: 0.5 }, { threshold: 0.5 },
); );
loadMore(8); loadMore(12);
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}); });
// Whenever the publication changes, update rootId // #endregion
let rootId = $derived($pharosInstance.getRootIndexId());
</script> </script>
<!-- Table of contents --> <!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf} {#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} {/if}
<!-- Default publications --> <!-- Default publications -->
@ -179,11 +236,12 @@
Error loading content. One or more events could not be loaded. Error loading content. One or more events could not be loaded.
</Alert> </Alert>
{:else} {:else}
{@const address = leaf.tagAddress()}
<PublicationSection <PublicationSection
{rootAddress} {rootAddress}
{leaves} {leaves}
address={leaf.tagAddress()} {address}
ref={(el) => setLastElementRef(el, i)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{/if} {/if}
{/each} {/each}
@ -193,7 +251,7 @@
{:else if !isDone} {:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button> <Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else} {: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. You've reached the end of the publication.
</p> </p>
{/if} {/if}
@ -204,9 +262,7 @@
<!-- Blog list --> <!-- Blog list -->
{#if $publicationColumnVisibility.blog} {#if $publicationColumnVisibility.blog}
<div <div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
{isInnerActive() ? 'discreet' : ''}
"
> >
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
@ -287,7 +343,7 @@
<Card class="ArticleBox card-leather w-full grid max-w-xl"> <Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2"> <div class="flex flex-col my-2">
<span>Unknown</span> <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>
<div class="flex flex-col flex-grow space-y-4"> <div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to This is a very intelligent comment placeholder that applies to

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

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from "$lib/utils"; import { filterValidIndexEvents, debounce } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte"; import ArticleHeader from "./PublicationHeader.svelte";
import { onMount } from "svelte"; import { onMount, onDestroy } from "svelte";
import { import {
getMatchingTags, getMatchingTags,
NDKRelaySetFromNDK, NDKRelaySetFromNDK,
@ -13,47 +13,118 @@
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { searchCache } from "$lib/utils/searchCache"; import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache"; import { indexEventCache } from "$lib/utils/indexEventCache";
import { feedType } from "$lib/stores";
import { isValidNip05Address } from "$lib/utils/search_utility"; import { isValidNip05Address } from "$lib/utils/search_utility";
let { const props = $props<{
relays,
fallbackRelays,
searchQuery = "",
} = $props<{
relays: string[];
fallbackRelays: string[];
searchQuery?: string; searchQuery?: string;
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>(); }>();
// Component state
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false); let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>( let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>({});
{},
);
let loading: boolean = $state(true); 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( let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? eventsInView?.at(eventsInView.length - 1)?.created_at ??
new Date().getTime(), 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() { async function fetchAllIndexEventsFromRelays() {
loading = true; console.debug('[PublicationFeed] fetchAllIndexEventsFromRelays called with relays:', {
const ndk = $ndkInstance; allRelaysCount: allRelays.length,
const primaryRelays: string[] = relays; allRelays: allRelays
const fallback: string[] = fallbackRelays.filter( });
(r: string) => !primaryRelays.includes(r),
); if (!ndk) {
const allRelays = [...primaryRelays, ...fallback]; 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 // Check cache first
const cachedEvents = indexEventCache.get(allRelays); const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) { if (cachedEvents) {
console.log(`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`); console.log(
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
);
allIndexEvents = cachedEvents; allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= 30;
@ -61,19 +132,23 @@
return; return;
} }
loading = true;
relayStatuses = Object.fromEntries( relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]), allRelays.map((r: string) => [r, "pending"]),
); );
let allEvents: NDKEvent[] = []; let allEvents: NDKEvent[] = [];
const eventMap = new Map<string, NDKEvent>();
// Helper to fetch from a single relay with timeout // Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<NDKEvent[]> { async function fetchFromRelay(relay: string): Promise<void> {
try { try {
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk let eventSet = await ndk
.fetchEvents( .fetchEvents(
{ {
kinds: [indexKind], kinds: [indexKind],
limit: 1000, // Increased limit to get more events
}, },
{ {
groupable: false, groupable: false,
@ -82,36 +157,57 @@
}, },
relaySet, 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); eventSet = filterValidIndexEvents(eventSet);
console.debug(`[PublicationFeed] Valid events from ${relay}:`, eventSet.size);
relayStatuses = { ...relayStatuses, [relay]: "found" }; relayStatuses = { ...relayStatuses, [relay]: "found" };
return Array.from(eventSet);
} catch (err) {
console.error(`Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
return [];
}
}
// Fetch from all relays in parallel, do not block on any single relay // Add new events to the map and update the view immediately
const results = await Promise.allSettled(allRelays.map(fetchFromRelay)); const newEvents: NDKEvent[] = [];
for (const result of results) { for (const event of eventSet) {
if (result.status === "fulfilled") { const tagAddress = event.tagAddress();
allEvents = allEvents.concat(result.value); if (!eventMap.has(tagAddress)) {
eventMap.set(tagAddress, event);
newEvents.push(event);
} }
} }
// Deduplicate by tagAddress
const eventMap = new Map( if (newEvents.length > 0) {
allEvents.map((event) => [event.tagAddress(), event]), // Update allIndexEvents with new events
);
allIndexEvents = Array.from(eventMap.values()); allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending // Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); 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(`[PublicationFeed] Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
}
}
// 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 // Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents); indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page // Final update to ensure we have the latest view
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= 30;
loading = false; loading = false;
@ -119,8 +215,8 @@
// Function to filter events based on search query // Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => { const filterEventsBySearch = (events: NDKEvent[]) => {
if (!searchQuery) return events; if (!props.searchQuery) return events;
const query = searchQuery.toLowerCase(); const query = props.searchQuery.toLowerCase();
console.debug( console.debug(
"[PublicationFeed] Filtering events with query:", "[PublicationFeed] Filtering events with query:",
query, query,
@ -129,9 +225,11 @@
); );
// Check cache first for publication search // Check cache first for publication search
const cachedResult = searchCache.get('publication', query); const cachedResult = searchCache.get("publication", query);
if (cachedResult) { 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; return cachedResult.events;
} }
@ -186,10 +284,10 @@
tTagEvents: [], tTagEvents: [],
eventIds: new Set<string>(), eventIds: new Set<string>(),
addresses: new Set<string>(), addresses: new Set<string>(),
searchType: 'publication', searchType: "publication",
searchTerm: query searchTerm: query,
}; };
searchCache.set('publication', query, result); searchCache.set("publication", query, result);
console.debug("[PublicationFeed] Events after filtering:", filtered.length); console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered; return filtered;
@ -211,15 +309,25 @@
$effect(() => { $effect(() => {
console.debug( console.debug(
"[PublicationFeed] Search query effect triggered:", "[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() { async function loadMorePublications() {
loadingMore = true; loadingMore = true;
const current = eventsInView.length; const current = eventsInView.length;
let source = searchQuery.trim() let source = props.searchQuery.trim()
? filterEventsBySearch(allIndexEvents) ? filterEventsBySearch(allIndexEvents)
: allIndexEvents; : allIndexEvents;
eventsInView = source.slice(0, current + 30); eventsInView = source.slice(0, current + 30);
@ -228,7 +336,7 @@
} }
function getSkeletonIds(): string[] { 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 skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = []; const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) { for (let i = 0; i < skeletonCount; i++) {
@ -243,30 +351,31 @@
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`; return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
} }
// Track previous feed type to avoid infinite loops // Cleanup function for fallback timeout
let previousFeedType = $state($feedType); function cleanup() {
if (fallbackTimeout) {
// Watch for changes in feed type and relay configuration clearTimeout(fallbackTimeout);
$effect(() => { fallbackTimeout = null;
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 on component destruction
onDestroy(() => {
cleanup();
}); });
onMount(async () => { onMount(async () => {
await fetchAllIndexEventsFromRelays(); console.debug('[PublicationFeed] onMount called');
// The effect will handle fetching when relays become available
}); });
</script> </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} {#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id} {#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" /> <Skeleton divClass="skeleton-leather w-full" size="lg" />
@ -308,3 +417,4 @@
> >
</div> </div>
{/if} {/if}
</div>

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

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

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

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

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

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

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

@ -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}

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

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

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

@ -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>

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

@ -1,6 +1,14 @@
<script lang="ts"> <script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; 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 { import {
ArrowRightToBracketOutline, ArrowRightToBracketOutline,
UserOutline, UserOutline,
@ -8,29 +16,331 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { goto } from "$app/navigation"; 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<{ pubkey?: string, isNav?: boolean }>();
// 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);
let { pubkey, isNav = false } = $props(); onMount(() => {
if (localStorage.getItem("alexandria/amber/fallback") === "1") {
console.log("Profile: Found fallback flag on mount, showing modal");
showAmberFallback = true;
}
});
let profile = $state<NDKUserProfile | null>(null); // Use profile data from userStore
let pfp = $derived(profile?.image); let userState = $derived($userStore);
let profile = $derived(userState.profile);
let pfp = $derived(profile?.picture);
let username = $derived(profile?.name); let username = $derived(profile?.name);
let tag = $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 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);
}
});
// 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);
}
}
});
// Track login method changes and refresh profile when switching from Amber to npub
let previousLoginMethod = $state<string | null>(null);
$effect(() => { $effect(() => {
const user = $ndkInstance.getUser({ pubkey: pubkey ?? undefined }); const currentUser = userState;
npub = user.npub; 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);
}
user.fetchProfile().then((userProfile) => { previousLoginMethod = currentUser.loginMethod;
profile = userProfile; }
}); });
// 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() { async function handleSignOutClick() {
logout($ndkInstance.activeUser!); localStorage.removeItem("amber/nsec");
profile = null; localStorage.removeItem("alexandria/amber/fallback");
logoutUser();
} }
function handleViewProfile() { function handleViewProfile() {
@ -39,40 +349,135 @@
} }
} }
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 ""; if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4); return long.slice(0, 8) + "…" + long.slice(-4);
} }
</script> </script>
<div class="relative"> <div class="relative">
{#if profile} {#if !userState.signedIn}
<!-- Login button -->
<div class="group"> <div class="group">
<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"
>
{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 <Avatar
rounded rounded
class="h-6 w-6 cursor-pointer" class="h-6 w-6 cursor-pointer"
src={pfp} src={pfp}
alt={username} alt={username || "User"}
id="profile-avatar"
/> />
{#key username || tag} {/if}
</button>
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy="#profile-avatar" triggeredBy={`#${profileAvatarId}`}
class="popover-leather w-[180px]" class="popover-leather w-[220px]"
trigger="hover" trigger="click"
> >
<div class="flex flex-row justify-between space-x-4"> <div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col"> <div class="flex flex-col">
{#if username} {#if username}
<h3 class="text-lg font-bold">{username}</h3> <h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if} {#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} {/if}
<ul class="space-y-2 mt-2"> <ul class="space-y-2 mt-2">
<li> <li>
<CopyToClipboard <CopyToClipboard
displayText={shortenNpub(npub)} displayText={shortenNpub(npub) || "Loading..."}
copyText={npub} copyText={npub || ""}
/> />
</li> </li>
<li> <li>
@ -85,6 +490,21 @@
/><span class="underline">View profile</span> /><span class="underline">View profile</span>
</button> </button>
</li> </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} {#if isNav}
<li> <li>
<button <button
@ -110,7 +530,101 @@
</div> </div>
</div> </div>
</Popover> </Popover>
{/key}
</div> </div>
{/if} {/if}
</div> </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 @@
<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}

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

@ -3,7 +3,8 @@
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils"; import { naddrEncode } from "$lib/utils";
import { getEventType } from "$lib/utils/mime"; 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"; import { goto } from "$app/navigation";
let { event, className = "" } = $props<{ let { event, className = "" } = $props<{
@ -25,7 +26,7 @@
return null; return null;
} }
try { try {
return naddrEncode(event, standardRelays); return naddrEncode(event, $activeInboxRelays);
} catch { } catch {
return null; return null;
} }
@ -56,10 +57,13 @@
console.log("ViewPublicationLink: navigateToPublication called", { console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind, eventKind: event.kind,
naddrAddress, naddrAddress,
isAddressable: isAddressableEvent(event) isAddressable: isAddressableEvent(event),
}); });
if (naddrAddress) { if (naddrAddress) {
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress); console.log(
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`); goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else { } else {
console.log("ViewPublicationLink: No naddr address found for event"); console.log("ViewPublicationLink: No naddr address found for event");

55
src/lib/consts.ts

@ -1,41 +1,50 @@
// AI SHOULD NEVER CHANGE THIS FILE
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [30041, 30818]; export const zettelKinds = [30041, 30818];
export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelay = "wss://profiles.nostr1.com"; export const communityRelays = [
export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",
"wss://profiles.nostr1.com", //"wss://theforest.gitcitadel.eu"
// Removed gitcitadel.nostr1.com as it's causing connection issues
//'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu',
]; ];
// Non-auth relays for anonymous users export const searchRelays = [
export const anonymousRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com", "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://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.land",
"wss://nostr.wine", "wss://nostr.wine",
"wss://nostr.sovbit.host", "wss://nostr.sovbit.host",
"wss://freelay.sovbit.host",
"wss://nostr21.com", "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 { export enum FeedType {
StandardRelays = "standard", CommunityRelays = "standard",
UserRelays = "user", UserRelays = "user",
} }

269
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,6 @@
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts"; import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus {
Error, Error,
} }
export enum TreeTraversalMode {
Leaves,
All,
}
enum TreeTraversalDirection {
Forward,
Backward,
}
interface PublicationTreeNode { interface PublicationTreeNode {
type: PublicationTreeNodeType; type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus; status: PublicationTreeNodeStatus;
@ -37,6 +46,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
#events: Map<string, NDKEvent>; #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. * An ordered list of the addresses of the leaves of the tree.
*/ */
@ -52,10 +66,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
#ndk: NDK; #ndk: NDK;
#nodeAddedObservers: Array<(address: string) => void> = [];
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) { constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress(); const rootAddress = rootEvent.tagAddress();
this.#root = { this.#root = {
type: this.#getNodeType(rootEvent), type: PublicationTreeNodeType.Branch,
status: PublicationTreeNodeStatus.Resolved, status: PublicationTreeNodeStatus.Resolved,
address: rootAddress, address: rootAddress,
children: [], children: [],
@ -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. * Retrieves the addresses of the loaded children, if any, of the node with the given address.
*
* @param address The address of the parent node. * @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes. * @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>> { async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value(); const node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error( 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> {
let node = await this.#nodes.get(address)?.value(); let node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error( 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> {
*/ */
setBookmark(address: string) { setBookmark(address: string) {
this.#bookmark = address; 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 // #region Iteration Cursor
@ -206,8 +251,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) { async tryMoveTo(address?: string) {
if (!address) { if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve(); const startEvent = await this.#tree.#depthFirstRetrieve();
if (!startEvent) {
return false;
}
this.target = await this.#tree.#nodes this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress()) .get(startEvent.tagAddress())
?.value(); ?.value();
} else { } else {
this.target = await this.#tree.#nodes.get(address)?.value(); this.target = await this.#tree.#nodes.get(address)?.value();
@ -222,7 +270,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> { async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) { 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; return false;
} }
@ -240,7 +290,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToLastChild(): Promise<boolean> { async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) { 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; return false;
} }
@ -258,7 +310,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToNextSibling(): Promise<boolean> { async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) { 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; return false;
} }
@ -287,7 +341,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> { async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) { 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; return false;
} }
@ -316,7 +372,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
tryMoveToParent(): boolean { tryMoveToParent(): boolean {
if (!this.target) { 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; return false;
} }
@ -338,34 +396,100 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return this; 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);
}
}
async next(): Promise<IteratorResult<NDKEvent | null>> { switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
}
}
/**
* 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 (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) { if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address); return this.#yieldEventAtCursor(false);
return { done: false, value: event }; }
}
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 };
} }
// Based on Raymond Chen's tree traversal algorithm example. /**
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 * 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 { do {
if (await this.#cursor.tryMoveToNextSibling()) { if (await tryMoveToSibling()) {
while (await this.#cursor.tryMoveToFirstChild()) { while (await tryMoveToChild()) {
continue; continue;
} }
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) { if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null }; return { done: false, value: null };
} }
const event = await this.getEvent(this.#cursor.target!.address); return this.#yieldEventAtCursor(false);
return { done: false, value: event };
} }
} while (this.#cursor.tryMoveToParent()); } 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: false, value: null };
} }
@ -373,36 +497,43 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: true, value: null }; return { done: true, value: null };
} }
async previous(): Promise<IteratorResult<NDKEvent | null>> { /**
if (!this.#cursor.target) { * Walks the tree in the given direction, yielding the event at each node.
if (await this.#cursor.tryMoveTo(this.#bookmark)) { *
const event = await this.getEvent(this.#cursor.target!.address); * @param direction The direction to walk the tree.
return { done: false, value: event }; * @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 { do {
if (await this.#cursor.tryMoveToPreviousSibling()) { if (await tryMoveToSibling()) {
while (await this.#cursor.tryMoveToLastChild()) { return this.#yieldEventAtCursor(false);
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 };
} }
} while (this.#cursor.tryMoveToParent()); } 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: 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 // #endregion
@ -431,15 +562,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
currentNode = await this.#nodes.get(currentAddress!)?.value(); currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) { if (!currentNode) {
throw new Error( throw new Error(
`PublicationTree: Node with address ${currentAddress} not found.`, `[PublicationTree] Node with address ${currentAddress} not found.`,
); );
} }
currentEvent = this.#events.get(currentAddress!); currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) { if (!currentEvent) {
throw new Error( console.warn(
`PublicationTree: Event with address ${currentAddress} not found.`, `[PublicationTree] Event with address ${currentAddress} not found.`,
); );
return null;
} }
// Stop immediately if the target of the search is found. // Stop immediately if the target of the search is found.
@ -462,13 +594,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
// Augment the tree with the children of the current event. // Augment the tree with the children of the current event.
for (const childAddress of currentChildAddresses) { const childPromises = currentChildAddresses
if (this.#nodes.has(childAddress)) { .filter(childAddress => !this.#nodes.has(childAddress))
continue; .map(childAddress => this.#addNode(childAddress, currentNode!));
}
await this.#addNode(childAddress, currentNode!); await Promise.all(childPromises);
}
// Push the popped address's children onto the stack for the next iteration. // Push the popped address's children onto the stack for the next iteration.
while (currentChildAddresses.length > 0) { while (currentChildAddresses.length > 0) {
@ -481,18 +611,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#addNode(address: string, parentNode: PublicationTreeNode) { #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>(() => const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode), this.#resolveNode(address, parentNode),
); );
parentNode.children!.push(lazyNode); parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode); this.#nodes.set(address, lazyNode);
this.#nodeAddedObservers.forEach((observer) => observer(address));
} }
/** /**
@ -508,16 +633,27 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
address: string, address: string,
parentNode: PublicationTreeNode, parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> { ): Promise<PublicationTreeNode> {
// Check cache first
let event = this.#eventCache.get(address);
if (!event) {
const [kind, pubkey, dTag] = address.split(":"); const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({ const fetchedEvent = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
"#d": [dTag], "#d": [dTag],
}); });
// Cache the event if found
if (fetchedEvent) {
this.#eventCache.set(address, fetchedEvent);
event = fetchedEvent;
}
}
if (!event) { if (!event) {
console.debug( console.debug(
`PublicationTree: Event with address ${address} not found.`, `[PublicationTree] Event with address ${address} not found.`,
); );
return { return {
@ -543,9 +679,12 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
children: [], children: [],
}; };
for (const address of childAddresses) { const childPromises = childAddresses.map(address =>
this.addEventByAddress(address, event); this.addEventByAddress(address, event)
} );
await Promise.all(childPromises);
this.#nodeResolvedObservers.forEach((observer) => observer(address));
return node; return node;
} }

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

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

396
src/lib/ndk.ts

@ -8,23 +8,39 @@ import NDK, {
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { import {
fallbackRelays, secondaryRelays,
FeedType, FeedType,
loginStorageKey, loginStorageKey,
standardRelays, communityRelays,
anonymousRelays, anonymousRelays,
searchRelays,
} from "./consts"; } from "./consts";
import { feedType } from "./stores"; import {
import { userPubkey } from '$lib/stores/authStore.Svelte'; 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 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); // New relay management stores
export const activeInboxRelays = writable<string[]>([]);
export const activePubkey: Writable<string | null> = writable(null); export const activeOutboxRelays = writable<string[]>([]);
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
/** /**
* Custom authentication policy that handles NIP-42 authentication manually * Custom authentication policy that handles NIP-42 authentication manually
@ -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. * Gets the user's pubkey from local storage, if it exists.
@ -409,7 +349,7 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`); console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect(); relay.disconnect();
}, 10000); // 10 second timeout }, 5000); // 5 second timeout
// Set up custom authentication handling only if user is signed in // Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) { if (ndk.signer && ndk.activeUser) {
@ -435,69 +375,191 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
return relay; 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);
}
}
/**
* Updates the active relay stores and NDK pool with new relay URLs
* @param ndk NDK instance
*/
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);
} }
/** /**
* Initializes an instance of NDK, and connects it to the logged-in user's preferred relay set * Gets the active relay set as NDKRelaySet for use in queries
* (if available), or to Alexandria's standard relay set. * @param ndk NDK instance
* @returns The initialized NDK instance. * @param useInbox Whether to use inbox relays (true) or outbox relays (false)
* @returns Promise that resolves to NDKRelaySet
*/ */
export function initNdk(): NDK { export async function getActiveRelaySetAsNDKRelaySet(
const startingPubkey = getPersistedLogin(); ndk: NDK,
const [startingInboxes, _] = useInbox: boolean = true
startingPubkey != null ): Promise<NDKRelaySet> {
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) const relaySet = await getActiveRelaySet(ndk);
: [null, null]; const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays;
// Ensure all relay URLs use secure WebSocket protocol return createRelaySetFromUrls(urls, ndk);
const secureRelayUrls = ( }
startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays
).map(ensureSecureWebSocket);
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({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: false, // We'll manage relays manually
enableOutboxModel: true, enableOutboxModel: true,
explicitRelayUrls: secureRelayUrls,
}); });
// Set up custom authentication policy // Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
// Connect with better error handling // Connect with better error handling and reduced retry attempts
ndk.connect() let retryCount = 0;
.then(() => { const maxRetries = 1; // Reduce to 1 retry
const attemptConnection = async () => {
try {
await ndk.connect();
console.debug("[NDK.ts] NDK connected successfully"); console.debug("[NDK.ts] NDK connected successfully");
}) // Update relay stores after connection
.catch((error) => { await updateActiveRelayStores(ndk);
console.error("[NDK.ts] Failed to connect NDK:", error); // Start network monitoring for relay optimization
// Try to reconnect after a delay startNetworkMonitoringForRelays(ndk);
setTimeout(() => { } catch (error) {
console.debug("[NDK.ts] Attempting to reconnect..."); console.warn("[NDK.ts] Failed to connect NDK:", error);
ndk.connect().catch((retryError) => {
console.error("[NDK.ts] Reconnection failed:", retryError); // Only retry a limited number of times
}); if (retryCount < maxRetries) {
}, 5000); 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; return ndk;
} }
/** /**
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox * Signs in with a NIP-07 browser extension using the new relay management system
* relays. * @returns The user's profile, if it is available
* @returns The user's profile, if it is available. * @throws If sign-in fails
* @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.
*/ */
export async function loginWithExtension( export async function loginWithExtension(
pubkey?: string, pubkey?: string,
@ -515,23 +577,10 @@ export async function loginWithExtension(
activePubkey.set(signerUser.pubkey); activePubkey.set(signerUser.pubkey);
userPubkey.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 user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set( // Update relay stores with the new system
Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url), await updateActiveRelayStores(ndk);
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes);
ndk.signer = signer; ndk.signer = signer;
ndk.activeUser = user; ndk.activeUser = user;
@ -555,58 +604,17 @@ export function logout(user: NDKUser): void {
activePubkey.set(null); activePubkey.set(null);
userPubkey.set(null); userPubkey.set(null);
ndkSignedIn.set(false); ndkSignedIn.set(false);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
}
/** // Clear relay stores
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox activeInboxRelays.set([]);
* relay sets. activeOutboxRelays.set([]);
* @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>(); // Stop network monitoring
const outboxRelays = new Set<NDKRelay>(); stopNetworkStatusMonitoring();
if (relayList == null) { // Re-initialize with anonymous instance
const relayMap = await window.nostr?.getRelays?.(); const newNdk = initNdk();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => { ndkInstance.set(newNdk);
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 {
["#d", nodeId], ["#d", nodeId],
...this.extractAndNormalizeWikilinks(content!), ...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.created_at = Date.now();
event.pubkey = pubkey; event.pubkey = pubkey;
@ -1182,6 +1189,36 @@ export default class Pharos {
return wikilinks; 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. // TODO: Add search-based wikilink resolution.
// #endregion // #endregion

115
src/lib/services/publisher.ts

@ -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, "-");
}

81
src/lib/snippets/UserSnippets.svelte

@ -1,31 +1,80 @@
<script module lang="ts"> <script module lang="ts">
import { goto } from "$app/navigation";
import { import {
createProfileLinkWithVerification,
toNpub, toNpub,
getUserMetadata,
} from "$lib/utils/nostrUtils"; } 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 }; export { userBadge };
</script> </script>
{#snippet userBadge(identifier: string, displayText: string | undefined)} {#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)} {@const npub = toNpub(identifier)}
{@const npub = toNpub(identifier) as string} {#if npub}
{@const cleanId = npub.replace(/^nostr:/, "")} {#if !displayText || displayText.trim().toLowerCase() === "unknown"}
{@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`} {#await getUserMetadata(npub) then profile}
{@const displayTextFinal = displayText || defaultText} {@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 <button
class="npub-badge hover:underline" class="npub-badge bg-transparent border-none p-0 underline cursor-pointer"
onclick={() => goto(`/events?id=${encodeURIComponent(cleanId)}`)} onclick={() => goto(`/events?id=${npub}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/events?id=${encodeURIComponent(cleanId)}`);
}
}}
> >
@{displayTextFinal} @{displayText}
</button> </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} {:else}
{displayText ?? ""} {displayText ?? ""}
{/if} {/if}

23
src/lib/stores.ts

@ -1,13 +1,22 @@
import { readable, writable } from "svelte/store"; import { writable } from "svelte/store";
import { FeedType } from "./consts";
// 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 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, toc: false,
blog: true, blog: true,
main: true, main: true,
@ -17,7 +26,9 @@ const defaultVisibility = {
}; };
function createVisibilityStore() { function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility }); const { subscribe, set, update } = writable<PublicationLayoutVisibility>({
...defaultVisibility,
});
return { return {
subscribe, subscribe,

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

@ -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. * Stores the user's public key if logged in, or null otherwise.

55
src/lib/stores/networkStore.ts

@ -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 @@
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 @@
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 @@
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(...) { ... }

73
src/lib/utils/community_checker.ts

@ -1,5 +1,5 @@
import { communityRelay } from '$lib/consts'; import { communityRelays } from "$lib/consts";
import { RELAY_CONSTANTS, SEARCH_LIMITS } from './search_constants'; import { RELAY_CONSTANTS, SEARCH_LIMITS } from "./search_constants";
// Cache for pubkeys with kind 1 events on communityRelay // Cache for pubkeys with kind 1 events on communityRelay
const communityCache = new Map<string, boolean>(); const communityCache = new Map<string, boolean>();
@ -13,36 +13,54 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
} }
try { try {
const relayUrl = communityRelay; // Try each community relay until we find one that works
for (const relayUrl of communityRelays) {
try {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => { const result = await new Promise<boolean>((resolve) => {
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify([ ws.send(
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, { JSON.stringify([
"REQ",
RELAY_CONSTANTS.COMMUNITY_REQUEST_ID,
{
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS, kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey], authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK limit: SEARCH_LIMITS.COMMUNITY_CHECK,
} },
])); ]),
);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) { if (data[0] === "EVENT" && data[2]?.kind === 1) {
communityCache.set(pubkey, true); communityCache.set(pubkey, true);
ws.close(); ws.close();
resolve(true); resolve(true);
} else if (data[0] === 'EOSE') { } else if (data[0] === "EOSE") {
communityCache.set(pubkey, false); communityCache.set(pubkey, false);
ws.close(); ws.close();
resolve(false); resolve(false);
} }
}; };
ws.onerror = () => { ws.onerror = () => {
communityCache.set(pubkey, false);
ws.close(); ws.close();
resolve(false); resolve(false);
}; };
}); });
if (result) {
return true;
}
} 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 { } catch {
communityCache.set(pubkey, false); communityCache.set(pubkey, false);
return false; return false;
@ -52,12 +70,35 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
/** /**
* Check community status for multiple profiles * 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> = {}; const communityStatus: Record<string, boolean> = {};
for (const profile of profiles) { // Run all community checks in parallel with timeout
if (profile.pubkey) { const checkPromises = profiles.map(async (profile) => {
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey); 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;
} }
} }

184
src/lib/utils/event_input_utils.ts

@ -1,8 +1,8 @@
import type { NDKEvent } from './nostrUtils'; import type { NDKEvent } from "./nostrUtils";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from './search_constants'; import { EVENT_KINDS } from "./search_constants";
// ========================= // =========================
// Validation // Validation
@ -12,14 +12,16 @@ import { EVENT_KINDS } from './search_constants';
* Returns true if the event kind requires a d-tag (kinds 30000-39999). * Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/ */
export function requiresDTag(kind: number): boolean { 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. * Returns true if the tags array contains at least one d-tag with a non-empty value.
*/ */
export function hasDTag(tags: [string, string][]): boolean { 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 {
* Validates that content does NOT contain AsciiDoc headers (for kind 30023). * Validates that content does NOT contain AsciiDoc headers (for kind 30023).
* Returns { valid, reason }. * Returns { valid, reason }.
*/ */
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } { export function validateNotAsciidoc(content: string): {
valid: boolean;
reason?: string;
} {
if (containsAsciiDocHeaders(content)) { if (containsAsciiDocHeaders(content)) {
return { return {
valid: false, 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 }; return { valid: true };
@ -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. * Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header.
* Returns { valid, reason }. * Returns { valid, reason }.
*/ */
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } { export function validateAsciiDoc(content: string): {
if (!content.trim().startsWith('=')) { valid: boolean;
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' }; reason?: string;
} {
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: 'AsciiDoc must start with a document title ("=").',
};
} }
if (!/^==\s+/m.test(content)) { 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 }; return { valid: true };
} }
@ -61,7 +76,10 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st
* Validates that a 30040 event set will be created correctly. * Validates that a 30040 event set will be created correctly.
* Returns { valid, reason }. * 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 // First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content); const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) { if (!asciiDocValidation.valid) {
@ -71,19 +89,29 @@ export function validate30040EventSet(content: string): { valid: boolean; reason
// Check that we have at least one section // Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content); const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) { 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 // Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content); const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) { 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 // 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 // The 30040 event should have empty content, with all content split into 30041 events
if (!content.trim().startsWith('=')) { if (!content.trim().startsWith("=")) {
return { valid: false, reason: '30040 events must start with a document title ("=").' }; return {
valid: false,
reason: '30040 events must start with a document title ("=").',
};
} }
return { valid: true }; return { valid: true };
@ -99,8 +127,8 @@ export function validate30040EventSet(content: string): { valid: boolean; reason
function normalizeDTagValue(header: string): string { function normalizeDTagValue(header: string): string {
return header return header
.toLowerCase() .toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, "");
} }
/** /**
@ -109,8 +137,8 @@ function normalizeDTagValue(header: string): string {
export function titleToDTag(title: string): string { export function titleToDTag(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens .replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
} }
/** /**
@ -125,7 +153,7 @@ function extractAsciiDocDocumentHeader(content: string): string | null {
* Extracts all section headers (lines starting with '== '). * Extracts all section headers (lines starting with '== ').
*/ */
function extractAsciiDocSectionHeaders(content: string): string[] { 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 {
* Section headers (==) are discarded from content. * Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section. * 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 lines = content.split(/\r?\n/);
const sections: string[] = []; const sections: string[] = [];
const sectionHeaders: string[] = []; const sectionHeaders: string[] = [];
@ -160,7 +192,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
// If we encounter a section header (==) and we have content, start a new section // If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) { if (/^==\s+/.test(line)) {
if (current.length > 0) { if (current.length > 0) {
sections.push(current.join('\n').trim()); sections.push(current.join("\n").trim());
current = []; current = [];
} }
@ -176,7 +208,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
current.push(line); current.push(line);
} else { } else {
// Text before first section becomes preamble // Text before first section becomes preamble
if (line.trim() !== '') { if (line.trim() !== "") {
preambleContent.push(line); preambleContent.push(line);
} }
} }
@ -184,13 +216,13 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
// Add the last section // Add the last section
if (current.length > 0) { if (current.length > 0) {
sections.push(current.join('\n').trim()); sections.push(current.join("\n").trim());
} }
// Add preamble as first section if it exists // Add preamble as first section if it exists
if (preambleContent.length > 0) { if (preambleContent.length > 0) {
sections.unshift(preambleContent.join('\n').trim()); sections.unshift(preambleContent.join("\n").trim());
sectionHeaders.unshift('Preamble'); sectionHeaders.unshift("Preamble");
hasPreamble = true; hasPreamble = true;
} }
@ -216,26 +248,27 @@ function getNdk() {
export function build30040EventSet( export function build30040EventSet(
content: string, content: string,
tags: [string, string][], tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number } baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number },
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { ): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
console.log('=== build30040EventSet called ==='); console.log("=== build30040EventSet called ===");
console.log('Input content:', content); console.log("Input content:", content);
console.log('Input tags:', tags); console.log("Input tags:", tags);
console.log('Input baseEvent:', baseEvent); console.log("Input baseEvent:", baseEvent);
const ndk = getNdk(); const ndk = getNdk();
console.log('NDK instance:', ndk); console.log("NDK instance:", ndk);
const sectionsResult = splitAsciiDocSections(content); const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections; const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders; const sectionHeaders = sectionsResult.sectionHeaders;
console.log('Sections:', sections); console.log("Sections:", sections);
console.log('Section headers:', sectionHeaders); console.log("Section headers:", sectionHeaders);
const dTags = sectionHeaders.length === sections.length const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue) ? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`); : sections.map((_, i) => `section${i}`);
console.log('D tags:', dTags); console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => { const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`; const header = sectionHeaders[i] || `Section ${i + 1}`;
@ -244,41 +277,39 @@ export function build30040EventSet(
return new NDKEventClass(ndk, { return new NDKEventClass(ndk, {
kind: 30041, kind: 30041,
content: section, content: section,
tags: [ tags: [...tags, ["d", dTag], ["title", header]],
...tags,
['d', dTag],
['title', header],
],
pubkey: baseEvent.pubkey, pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
}); });
// Create proper a tags with format: kind:pubkey:d-tag // Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(dTag => ['a', `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]); const aTags = dTags.map(
console.log('A tags:', aTags); (dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string],
);
console.log("A tags:", aTags);
// Extract document title for the index event // Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content); const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : 'index'; const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log('Index event:', { documentTitle, indexDTag }); console.log("Index event:", { documentTitle, indexDTag });
const indexTags = [ const indexTags = [
...tags, ...tags,
['d', indexDTag], ["d", indexDTag],
['title', documentTitle || 'Untitled'], ["title", documentTitle || "Untitled"],
...aTags, ...aTags,
]; ];
const indexEvent: NDKEvent = new NDKEventClass(ndk, { const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040, kind: 30040,
content: '', content: "",
tags: indexTags, tags: indexTags,
pubkey: baseEvent.pubkey, pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
console.log('Final index event:', indexEvent); console.log("Final index event:", indexEvent);
console.log('=== build30040EventSet completed ==='); console.log("=== build30040EventSet completed ===");
return { indexEvent, sectionEvents }; return { indexEvent, sectionEvents };
} }
@ -287,7 +318,10 @@ export function build30040EventSet(
* - 30041, 30818: AsciiDoc document header (first '= ' line) * - 30041, 30818: AsciiDoc document header (first '= ' line)
* - 30023: Markdown topmost '# ' header * - 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) { if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content); return extractAsciiDocDocumentHeader(content);
} }
@ -303,8 +337,12 @@ export function getTitleTagForEvent(kind: number, content: string): string | nul
* - 30041, 30818: Normalized AsciiDoc document header * - 30041, 30818: Normalized AsciiDoc document header
* - 30040: Uses existing d-tag or generates from content * - 30040: Uses existing d-tag or generates from content
*/ */
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null { export function getDTagForEvent(
if (existingDTag && existingDTag.trim() !== '') { kind: number,
content: string,
existingDTag?: string,
): string | null {
if (existingDTag && existingDTag.trim() !== "") {
return existingDTag.trim(); return existingDTag.trim();
} }
@ -338,41 +376,47 @@ The content is split into sections, each published as a separate 30041 event.`;
* Analyzes a 30040 event to determine if it was created correctly. * Analyzes a 30040 event to determine if it was created correctly.
* Returns { valid, issues } where issues is an array of problems found. * 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[] = []; const issues: string[] = [];
// Check if it's actually a 30040 event // Check if it's actually a 30040 event
if (event.kind !== 30040) { if (event.kind !== 30040) {
issues.push('Event is not kind 30040'); issues.push("Event is not kind 30040");
return { valid: false, issues }; return { valid: false, issues };
} }
// Check if content is empty (30040 should be metadata only) // Check if content is empty (30040 should be metadata only)
if (event.content && event.content.trim() !== '') { if (event.content && event.content.trim() !== "") {
issues.push('30040 events should have empty content (metadata only)'); issues.push("30040 events should have empty content (metadata only)");
issues.push('Content should be split into separate 30041 events'); issues.push("Content should be split into separate 30041 events");
} }
// Check for required tags // Check for required tags
const hasTitle = event.tags.some(([k, v]) => k === 'title' && v); const hasTitle = event.tags.some(([k, v]) => k === "title" && v);
const hasDTag = event.tags.some(([k, v]) => k === 'd' && v); const hasDTag = event.tags.some(([k, v]) => k === "d" && v);
const hasATags = event.tags.some(([k, v]) => k === 'a' && v); const hasATags = event.tags.some(([k, v]) => k === "a" && v);
if (!hasTitle) { if (!hasTitle) {
issues.push('Missing title tag'); issues.push("Missing title tag");
} }
if (!hasDTag) { if (!hasDTag) {
issues.push('Missing d tag'); issues.push("Missing d tag");
} }
if (!hasATags) { 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) // 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) { for (const [, value] of aTags) {
if (!value.includes(':')) { if (!value.includes(":")) {
issues.push(`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`); issues.push(
`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`,
);
} }
} }

105
src/lib/utils/event_search.ts

@ -1,10 +1,10 @@
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from '$lib/utils/nostrUtils'; import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils'; import { nip19 } from "$lib/utils/nostrUtils";
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from './search_utils'; import { wellKnownUrl, isValidNip05Address } from "./search_utils";
import { TIMEOUTS, VALIDATION } from './search_constants'; import { TIMEOUTS, VALIDATION } from "./search_constants";
/** /**
* Search for a single event by ID or filter * Search for a single event by ID or filter
@ -15,7 +15,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
let filterOrId: any = cleanedQuery; let filterOrId: any = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile) // 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 // Try as event id
filterOrId = cleanedQuery; filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback( const eventResult = await fetchEventWithFallback(
@ -40,7 +42,10 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
return eventResult; return eventResult;
} }
} else if ( } 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 { try {
const decoded = nip19.decode(cleanedQuery); const decoded = nip19.decode(cleanedQuery);
@ -102,7 +107,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
/** /**
* Search for NIP-05 address * 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 // NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) { if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain"); throw new Error("Invalid NIP-05 address format. Expected: user@domain");
@ -130,14 +137,88 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null
if (profileEvent) { if (profileEvent) {
return profileEvent; return profileEvent;
} else { } else {
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`); throw new Error(
`No profile found for ${name}@${domain} (pubkey: ${pubkey})`,
);
} }
} else { } else {
throw new Error(`NIP-05 address not found: ${name}@${domain}`); throw new Error(`NIP-05 address not found: ${name}@${domain}`);
} }
} catch (e) { } 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); const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`); 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 @@
/**
* 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);
}

22
src/lib/utils/indexEventCache.ts

@ -1,5 +1,5 @@
import type { NDKEvent } from "./nostrUtils"; import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants";
export interface IndexEventCacheEntry { export interface IndexEventCacheEntry {
events: NDKEvent[]; events: NDKEvent[];
@ -16,7 +16,7 @@ class IndexEventCache {
* Generate a cache key based on relay URLs * Generate a cache key based on relay URLs
*/ */
private generateKey(relayUrls: string[]): string { private generateKey(relayUrls: string[]): string {
return relayUrls.sort().join('|'); return relayUrls.sort().join("|");
} }
/** /**
@ -40,7 +40,9 @@ class IndexEventCache {
return null; 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; return entry.events;
} }
@ -61,10 +63,12 @@ class IndexEventCache {
this.cache.set(key, { this.cache.set(key, {
events, events,
timestamp: Date.now(), 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,7 +109,11 @@ class IndexEventCache {
/** /**
* Get cache statistics * Get cache statistics
*/ */
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } { getStats(): {
size: number;
totalEvents: number;
oldestEntry: number | null;
} {
let totalEvents = 0; let totalEvents = 0;
let oldestTimestamp: number | null = null; let oldestTimestamp: number | null = null;
@ -119,7 +127,7 @@ class IndexEventCache {
return { return {
size: this.cache.size, size: this.cache.size,
totalEvents, totalEvents,
oldestEntry: oldestTimestamp oldestEntry: oldestTimestamp,
}; };
} }
} }

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

@ -60,7 +60,7 @@ function fixAllMathBlocks(html: string): string {
return `<span class="math-inline">$${trimmedCode}$</span>`; return `<span class="math-inline">$${trimmedCode}$</span>`;
} }
return match; // Return original if not LaTeX return match; // Return original if not LaTeX
} },
); );
// Also process code blocks without language class // Also process code blocks without language class
@ -72,7 +72,7 @@ function fixAllMathBlocks(html: string): string {
return `<span class="math-inline">$${trimmedCode}$</span>`; return `<span class="math-inline">$${trimmedCode}$</span>`;
} }
return match; // Return original if not LaTeX return match; // Return original if not LaTeX
} },
); );
return html; return html;
@ -139,7 +139,7 @@ function isLaTeXContent(content: string): boolean {
/\\mathscr\{/, // Script /\\mathscr\{/, // Script
]; ];
return latexPatterns.some(pattern => pattern.test(trimmed)); return latexPatterns.some((pattern) => pattern.test(trimmed));
} }
/** /**

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

@ -10,18 +10,19 @@ hljs.configure({
// Escapes HTML characters for safe display // Escapes HTML characters for safe display
function escapeHtml(text: string): string { 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) { if (div) {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// Fallback for non-browser environments // Fallback for non-browser environments
return text return text
.replace(/&/g, '&amp;') .replace(/&/g, "&amp;")
.replace(/</g, '&lt;') .replace(/</g, "&lt;")
.replace(/>/g, '&gt;') .replace(/>/g, "&gt;")
.replace(/"/g, '&quot;') .replace(/"/g, "&quot;")
.replace(/'/g, '&#039;'); .replace(/'/g, "&#039;");
} }
// Regular expressions for advanced markup elements // Regular expressions for advanced markup elements
@ -406,7 +407,7 @@ function processDollarMath(content: string): string {
return `<div class="math-block">$$${expr}$$</div>`; return `<div class="math-block">$$${expr}$$</div>`;
} else { } else {
// Strip all $ or $$ from AsciiMath // 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>`; return `<div class="math-block" data-math-type="asciimath">${clean}</div>`;
} }
}); });
@ -415,7 +416,7 @@ function processDollarMath(content: string): string {
if (isLaTeXContent(expr)) { if (isLaTeXContent(expr)) {
return `<span class="math-inline">$${expr}$</span>`; return `<span class="math-inline">$${expr}$</span>`;
} else { } else {
const clean = expr.replace(/\$+/g, '').trim(); const clean = expr.replace(/\$+/g, "").trim();
return `<span class="math-inline" data-math-type="asciimath">${clean}</span>`; return `<span class="math-inline" data-math-type="asciimath">${clean}</span>`;
} }
}); });
@ -447,19 +448,19 @@ function processMathExpressions(content: string): string {
// Detect LaTeX display math (\\[...\\]) // Detect LaTeX display math (\\[...\\])
if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) { if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) {
// Remove the delimiters for rendering // Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\\\[|\\\]$/g, ''); const inner = trimmedCode.replace(/^\\\[|\\\]$/g, "");
return `<div class="math-block">$$${inner}$$</div>`; return `<div class="math-block">$$${inner}$$</div>`;
} }
// Detect display math ($$...$$) // Detect display math ($$...$$)
if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) { if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering // Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$\$|\$\$$/g, ''); const inner = trimmedCode.replace(/^\$\$|\$\$$/g, "");
return `<div class="math-block">$$${inner}$$</div>`; return `<div class="math-block">$$${inner}$$</div>`;
} }
// Detect inline math ($...$) // Detect inline math ($...$)
if (/^\$[\s\S]*\$$/.test(trimmedCode)) { if (/^\$[\s\S]*\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering // Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$|\$$/g, ''); const inner = trimmedCode.replace(/^\$|\$$/g, "");
return `<span class="math-inline">$${inner}$</span>`; return `<span class="math-inline">$${inner}$</span>`;
} }
// Default to inline math for any other LaTeX content // Default to inline math for any other LaTeX content
@ -511,7 +512,7 @@ function processMathExpressions(content: string): string {
]; ];
// If it matches code patterns, treat as regular code // 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 const escapedCode = trimmedCode
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
@ -685,7 +686,7 @@ function isLaTeXContent(content: string): boolean {
/\\\\mathscr\{/, // Script with double backslashes /\\\\mathscr\{/, // Script with double backslashes
]; ];
return latexPatterns.some(pattern => pattern.test(trimmed)); return latexPatterns.some((pattern) => pattern.test(trimmed));
} }
/** /**

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

@ -35,14 +35,11 @@ function replaceWikilinks(html: string): string {
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags. * Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/ */
function replaceAsciiDocAnchors(html: string): string { function replaceAsciiDocAnchors(html: string): string {
return html.replace( return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
/<a id="([^"]+)"><\/a>/g,
(_match, id) => {
const normalized = normalizeDTag(id.trim()); const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`; 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 `<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> {
.filter((para) => para.length > 0) .filter((para) => para.length > 0)
.map((para) => { .map((para) => {
// Skip wrapping if para already contains block-level elements or math blocks // 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 para;
} }
return `<p class="my-4">${para}</p>`; return `<p class="my-4">${para}</p>`;

19
src/lib/utils/mime.ts

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

189
src/lib/utils/network_detection.ts

@ -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;
}
};
}

312
src/lib/utils/nostrEventService.ts

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

243
src/lib/utils/nostrUtils.ts

@ -4,13 +4,14 @@ import { ndkInstance } from "$lib/ndk";
import { npubCache } from "./npubCache"; import { npubCache } from "./npubCache";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import 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 { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha256"; import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from "@noble/curves/secp256k1"; import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { wellKnownUrl } from "./search_utility"; import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants'; import { TIMEOUTS, VALIDATION } from "./search_constants";
const badgeCheckSvg = 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>'; '<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 {
*/ */
export async function getUserMetadata( export async function getUserMetadata(
identifier: string, identifier: string,
force = false,
): Promise<NostrProfile> { ): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
if (npubCache.has(cleanId)) { console.log("getUserMetadata called with identifier:", identifier, "force:", force);
return npubCache.get(cleanId)!;
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)}` }; const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
@ -67,12 +73,14 @@ export async function getUserMetadata(
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.warn("getUserMetadata: No NDK instance available");
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
const decoded = nip19.decode(cleanId); const decoded = nip19.decode(cleanId);
if (!decoded) { if (!decoded) {
console.warn("getUserMetadata: Failed to decode identifier:", cleanId);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
@ -84,33 +92,43 @@ export async function getUserMetadata(
} else if (decoded.type === "nprofile") { } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey; pubkey = decoded.data.pubkey;
} else { } else {
console.warn("getUserMetadata: Unsupported identifier type:", decoded.type);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
console.log("getUserMetadata: Fetching profile for pubkey:", pubkey);
const profileEvent = await fetchEventWithFallback(ndk, { const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0], kinds: [0],
authors: [pubkey], authors: [pubkey],
}); });
console.log("getUserMetadata: Profile event found:", profileEvent);
const profile = const profile =
profileEvent && profileEvent.content profileEvent && profileEvent.content
? JSON.parse(profileEvent.content) ? JSON.parse(profileEvent.content)
: null; : null;
console.log("getUserMetadata: Parsed profile:", profile);
const metadata: NostrProfile = { const metadata: NostrProfile = {
name: profile?.name || fallback.name, name: profile?.name || fallback.name,
displayName: profile?.displayName || profile?.display_name, displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05, nip05: profile?.nip05,
picture: profile?.image, picture: profile?.picture || profile?.image,
about: profile?.about, about: profile?.about,
banner: profile?.banner, banner: profile?.banner,
website: profile?.website, website: profile?.website,
lud16: profile?.lud16, lud16: profile?.lud16,
}; };
console.log("getUserMetadata: Final metadata:", metadata);
npubCache.set(cleanId, metadata); npubCache.set(cleanId, metadata);
return metadata; return metadata;
} catch (e) { } catch (e) {
console.error("getUserMetadata: Error fetching profile:", e);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
@ -128,6 +146,7 @@ export function createProfileLink(
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
// Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
} }
@ -157,12 +176,28 @@ export async function createProfileLinkWithVerification(
const userRelays = Array.from(ndk.pool?.relays.values() || []).map( const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url, (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 = [ const allRelays = [
...standardRelays, ...communityRelays,
...userRelays, ...userRelays,
...fallbackRelays, ...secondaryRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx); ].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( const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] }, { kinds: [0], authors: [user.pubkey] },
undefined, undefined,
@ -267,32 +302,74 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise<string | null> { export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try { try {
// Parse the NIP-05 address // Parse the NIP-05 address
const [name, domain] = nip05.split('@'); const [name, domain] = nip05.split("@");
if (!name || !domain) { if (!name || !domain) {
console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05); console.error("[getNpubFromNip05] Invalid NIP-05 format:", nip05);
return null; 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 url = wellKnownUrl(domain, name);
const response = await fetch(url); 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) { if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText); console.error(
"[getNpubFromNip05] HTTP error:",
response.status,
response.statusText,
);
return null; return null;
} }
const data = await response.json(); const data = await response.json();
const pubkey = data.names?.[name]; // 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) { if (!pubkey) {
console.error('[getNpubFromNip05] No pubkey found for name:', name); console.error("[getNpubFromNip05] No pubkey found for name:", name);
return null; return null;
} }
// Convert pubkey to npub // Convert pubkey to npub
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
return npub; 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;
}
} catch (error) { } catch (error) {
console.error("[getNpubFromNip05] Error getting npub from nip05:", error); console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null; return null;
@ -362,41 +439,40 @@ export async function fetchEventWithFallback(
filterOrId: string | NDKFilter<NDKKind>, filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000, timeoutMs: number = 3000,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// Get user relays if logged in // Use both inbox and outbox relays for better event discovery
const userRelays = ndk.activeUser const inboxRelays = get(activeInboxRelays);
? Array.from(ndk.pool?.relays.values() || []) const outboxRelays = get(activeOutboxRelays);
.filter((r) => r.status === 1) // Only use connected relays const allRelays = [...(inboxRelays || []), ...(outboxRelays || [])];
.map((r) => r.url)
: []; console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays);
// Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser; // Check if we have any relays available
const primaryRelays = isSignedIn ? standardRelays : anonymousRelays; if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch");
// Create three relay sets in priority order return null;
const relaySets = [ }
NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in) // Create relay set from all available relays
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort) const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
];
try { try {
let found: NDKEvent | null = null; if (relaySet.relays.size === 0) {
const triedRelaySets: string[] = []; console.warn("fetchEventWithFallback: No relays in relay set for event fetch");
return null;
}
// Helper function to try fetching from a relay set console.log("fetchEventWithFallback: Relay set size:", relaySet.relays.size);
async function tryFetchFromRelaySet( console.log("fetchEventWithFallback: Filter:", filterOrId);
relaySet: NDKRelaySetFromNDK, console.log("fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r: any) => r.url));
setName: string,
): Promise<NDKEvent | null> { let found: NDKEvent | null = null;
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
if ( if (
typeof filterOrId === "string" && typeof filterOrId === "string" &&
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId) new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, "i").test(filterOrId)
) { ) {
return await ndk found = await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet) .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
} else { } else {
@ -405,55 +481,33 @@ export async function fetchEventWithFallback(
const results = await ndk const results = await ndk
.fetchEvents(filter, undefined, relaySet) .fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
return results instanceof Set found = results instanceof Set
? (Array.from(results)[0] as NDKEvent) ? (Array.from(results)[0] as NDKEvent)
: null; : 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;
}
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets const relayUrls = Array.from(relaySet.relays).map((r: any) => r.url).join(", ");
.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 ");
console.warn( 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; return null;
} }
console.log("fetchEventWithFallback: Found event:", found.id);
// Always wrap as NDKEvent // Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) { } 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; return null;
} }
} }
@ -464,7 +518,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null { export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null; if (!pubkey) return null;
try { 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); return nip19.npubEncode(pubkey);
} }
if (pubkey.startsWith("npub1")) return pubkey; if (pubkey.startsWith("npub1")) return pubkey;
@ -534,7 +588,8 @@ export function prefixNostrAddresses(content: string): string {
// Regex to match Nostr addresses that are not already prefixed with "nostr:" // Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link // 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 // 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) => { return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url) // Check if this match is part of a markdown link [text](url)
@ -542,13 +597,13 @@ export function prefixNostrAddresses(content: string): string {
const afterMatch = content.substring(offset + match.length); const afterMatch = content.substring(offset + match.length);
// Check if it's part of a markdown link // Check if it's part of a markdown link
const beforeBrackets = beforeMatch.lastIndexOf('['); const beforeBrackets = beforeMatch.lastIndexOf("[");
const afterParens = afterMatch.indexOf(')'); const afterParens = afterMatch.indexOf(")");
if (beforeBrackets !== -1 && afterParens !== -1) { if (beforeBrackets !== -1 && afterParens !== -1) {
const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets); const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
const lastOpenBracket = textBeforeBrackets.lastIndexOf('['); const lastOpenBracket = textBeforeBrackets.lastIndexOf("[");
const lastCloseBracket = textBeforeBrackets.lastIndexOf(']'); const lastCloseBracket = textBeforeBrackets.lastIndexOf("]");
// If we have [text] before this, it might be a markdown link // If we have [text] before this, it might be a markdown link
if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) { if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
@ -557,7 +612,7 @@ export function prefixNostrAddresses(content: string): string {
} }
// Check if it's part of an HTML link // Check if it's part of an HTML link
const beforeHref = beforeMatch.lastIndexOf('href='); const beforeHref = beforeMatch.lastIndexOf("href=");
if (beforeHref !== -1) { if (beforeHref !== -1) {
const afterHref = afterMatch.indexOf('"'); const afterHref = afterMatch.indexOf('"');
if (afterHref !== -1) { if (afterHref !== -1) {
@ -566,10 +621,10 @@ export function prefixNostrAddresses(content: string): string {
} }
// Check if it's already prefixed with "nostr:" // Check if it's already prefixed with "nostr:"
const beforeNostr = beforeMatch.lastIndexOf('nostr:'); const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) { if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6); const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(' ')) { if (!textAfterNostr.includes(" ")) {
return match; // Already prefixed return match; // Already prefixed
} }
} }
@ -591,7 +646,19 @@ export function prefixNostrAddresses(content: string): string {
const wordBefore = beforeMatch.match(/\b(\w+)\s*$/); const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
if (wordBefore) { if (wordBefore) {
const beforeWord = wordBefore[1].toLowerCase(); 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)) { if (commonWords.includes(beforeWord)) {
return match; // Likely just a general reference, not an actual address return match; // Likely just a general reference, not an actual address
} }

400
src/lib/utils/profile_search.ts

@ -1,106 +1,122 @@
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils'; import { getUserMetadata, getNpubFromNip05 } from "$lib/utils/nostrUtils";
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from '$lib/utils/searchCache'; import { searchCache } from "$lib/utils/searchCache";
import { communityRelay, profileRelay } from '$lib/consts'; import { communityRelays, secondaryRelays } from "$lib/consts";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from './search_types'; import type { NostrProfile, ProfileSearchResult } from "./search_types";
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils'; import {
import { checkCommunityStatus } from './community_checker'; fieldMatches,
import { TIMEOUTS } from './search_constants'; 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) * Search for profiles by various criteria (display name, name, NIP-05, npub)
*/ */
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> { export async function searchProfiles(
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log(
"searchProfiles called with:",
searchTerm,
"normalized:",
normalizedSearchTerm,
);
// Check cache first // Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm); const cachedResult = searchCache.get("profile", normalizedSearchTerm);
if (cachedResult) { if (cachedResult) {
const profiles = cachedResult.events.map(event => { console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try { try {
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData); return createProfileFromEvent(event, profileData);
} catch { } catch {
return null; return null;
} }
}).filter(Boolean) as NostrProfile[]; })
.filter(Boolean) as NostrProfile[];
const communityStatus = await checkCommunityStatus(profiles); console.log("Cached profiles found:", profiles.length);
return { profiles, Status: communityStatus }; return { profiles, Status: {} };
} }
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
throw new Error('NDK not initialized'); console.error("NDK not initialized");
throw new Error("NDK not initialized");
} }
let foundProfiles: NostrProfile[] = []; console.log("NDK initialized, starting search logic");
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Set a timeout to force completion after profile search timeout let foundProfiles: NostrProfile[] = [];
timeoutId = setTimeout(() => {
if (foundProfiles.length === 0) {
// Timeout reached, but no need to log this
}
}, TIMEOUTS.PROFILE_SEARCH);
try { try {
// Check if it's a valid npub/nprofile first // Check if it's a valid npub/nprofile first
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) { if (
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile")
) {
try { try {
const metadata = await getUserMetadata(normalizedSearchTerm); const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) { if (metadata) {
foundProfiles = [metadata]; foundProfiles = [metadata];
} }
} catch (error) { } catch (error) {
console.error('Error fetching metadata for npub:', error); console.error("Error fetching metadata for npub:", error);
} }
} else if (normalizedSearchTerm.includes('@')) { } else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address // Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try { try {
const npub = await getNpubFromNip05(normalizedSearchTerm); const npub = await getNpubFromNip05(normalizedNip05);
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub pubkey: npub,
}; };
foundProfiles = [profile]; foundProfiles = [profile];
} }
} catch (e) { } catch (e) {
console.error('[Search] NIP-05 lookup failed:', e); console.error("[Search] NIP-05 lookup failed:", e);
// If NIP-05 lookup fails, continue with regular search
} }
} else { } 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); 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) { 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 // Cache the results
if (foundProfiles.length > 0) { if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => { const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile); event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || ''; event.pubkey = profile.pubkey || "";
return event; return event;
}); });
@ -110,99 +126,229 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
tTagEvents: [], tTagEvents: [],
eventIds: new Set<string>(), eventIds: new Set<string>(),
addresses: new Set<string>(), addresses: new Set<string>(),
searchType: 'profile', searchType: "profile",
searchTerm: normalizedSearchTerm searchTerm: normalizedSearchTerm,
}; };
searchCache.set('profile', normalizedSearchTerm, result); searchCache.set("profile", normalizedSearchTerm, result);
} }
// Check community status for all profiles console.log("Search completed, found profiles:", foundProfiles.length);
const communityStatus = await checkCommunityStatus(foundProfiles); return { profiles: foundProfiles, Status: {} };
return { profiles: foundProfiles, Status: communityStatus };
} catch (error) { } catch (error) {
console.error('Error searching profiles:', error); console.error("Error searching profiles:", error);
return { profiles: [], Status: {} }; return { profiles: [], Status: {} };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
} }
} }
/** /**
* Search for NIP-05 addresses across common domains * 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 { try {
for (const domain of COMMON_DOMAINS) { const npub = await getNpubFromNip05(gitcitadelAddress);
const nip05Address = `${searchTerm}@${domain}`; 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.log("NIP-05 search: error for gitcitadel.com:", e);
}
// 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 { try {
const npub = await getNpubFromNip05(nip05Address); const npub = await getNpubFromNip05(nip05Address);
if (npub) { if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub pubkey: npub,
}; };
return [profile]; 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) { } catch (e) {
console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain // 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);
} }
} catch (e) {
console.error('[Search] NIP-05 domain search failed:', e);
} }
return [];
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[] = []; const foundProfiles: NostrProfile[] = [];
// Prioritize community relays for better search results // Normalize the search term for relay search
const allRelays = Array.from(ndk.pool.relays.values()) as any[]; const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
const prioritizedRelays = new Set([ console.log("Normalized search term for relay search:", normalizedSearchTerm);
...allRelays.filter((relay: any) => relay.url === communityRelay),
...allRelays.filter((relay: any) => relay.url !== communityRelay) // Use all profile relays for better coverage
]); const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk); console.log("Using all relays for search:", quickRelayUrls);
// Create relay sets for parallel search
const relaySets = quickRelayUrls
.map((url) => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
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]}`,
);
// Subscribe to profile events
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ kinds: [0] }, { kinds: [0] },
{ closeOnEose: true }, { closeOnEose: true, relaySet },
relaySet
); );
return new Promise((resolve) => { sub.on("event", (event: NDKEvent) => {
sub.on('event', (event: NDKEvent) => { eventCount++;
try { try {
if (!event.content) return; if (!event.content) return;
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || ''; const displayName =
const display_name = profileData.display_name || ''; profileData.displayName || profileData.display_name || "";
const name = profileData.name || ''; const display_name = profileData.display_name || "";
const nip05 = profileData.nip05 || ''; const name = profileData.name || "";
const about = profileData.about || ''; const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term
const matchesDisplayName = fieldMatches(displayName, searchTerm); // Check if any field matches the search term using normalized comparison
const matchesDisplay_name = fieldMatches(display_name, searchTerm); const matchesDisplayName = fieldMatches(
const matchesName = fieldMatches(name, searchTerm); displayName,
const matchesNip05 = nip05Matches(nip05, searchTerm); normalizedSearchTerm,
const matchesAbout = fieldMatches(about, searchTerm); );
const matchesDisplay_name = fieldMatches(
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) { 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); const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile // Check if we already have this profile in this relay
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey); const existingIndex = foundInRelay.findIndex(
(p) => p.pubkey === event.pubkey,
);
if (existingIndex === -1) { if (existingIndex === -1) {
foundProfiles.push(profile); foundInRelay.push(profile);
} }
} }
} catch (e) { } catch (e) {
@ -210,24 +356,42 @@ async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise
} }
}); });
sub.on('eose', () => { sub.on("eose", () => {
if (foundProfiles.length > 0) { console.log(
// Deduplicate by pubkey, keep only newest `Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {}; );
for (const profile of foundProfiles) { resolve(foundInRelay);
const pubkey = profile.pubkey; });
if (pubkey) {
// We don't have created_at from getUserMetadata, so just keep the first one // Short timeout for quick search
if (!deduped[pubkey]) { setTimeout(() => {
deduped[pubkey] = { profile, created_at: 0 }; 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;
} }
} }
} }
const dedupedProfiles = Object.values(deduped).map(x => x.profile);
resolve(dedupedProfiles);
} else {
resolve([]);
} }
});
}); console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles);
} }

44
src/lib/utils/relayDiagnostics.ts

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

531
src/lib/utils/relay_management.ts

@ -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;
}

10
src/lib/utils/searchCache.ts

@ -1,5 +1,5 @@
import type { NDKEvent } from "./nostrUtils"; import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; import { CACHE_DURATIONS, TIMEOUTS } from "./search_constants";
export interface SearchResult { export interface SearchResult {
events: NDKEvent[]; events: NDKEvent[];
@ -53,11 +53,15 @@ class SearchCache {
/** /**
* Store search results in cache * 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); const key = this.generateKey(searchType, searchTerm);
this.cache.set(key, { this.cache.set(key, {
...result, ...result,
timestamp: Date.now() timestamp: Date.now(),
}); });
} }

7
src/lib/utils/search_constants.ts

@ -14,7 +14,10 @@ export const TIMEOUTS = {
PROFILE_SEARCH: 15000, PROFILE_SEARCH: 15000,
/** Timeout for subscription search operations */ /** 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 */ /** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000, RELAY_DIAGNOSTICS: 5000,
@ -84,7 +87,7 @@ export const EVENT_KINDS = {
// Relay-specific constants // Relay-specific constants
export const RELAY_CONSTANTS = { export const RELAY_CONSTANTS = {
/** Request ID for community relay checks */ /** Request ID for community relay checks */
COMMUNITY_REQUEST_ID: 'alexandria-forest', COMMUNITY_REQUEST_ID: "alexandria-forest",
/** Default relay request kinds for community checks */ /** Default relay request kinds for community checks */
COMMUNITY_REQUEST_KINDS: [1], COMMUNITY_REQUEST_KINDS: [1],

6
src/lib/utils/search_types.ts

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

26
src/lib/utils/search_utility.ts

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

27
src/lib/utils/search_utils.ts

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

576
src/lib/utils/subscription_search.ts

@ -1,13 +1,31 @@
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils'; import { getMatchingTags, getNpubFromNip05 } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils'; import { nip19 } from "$lib/utils/nostrUtils";
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from '$lib/utils/searchCache'; import { searchCache } from "$lib/utils/searchCache";
import { communityRelay, profileRelay } from '$lib/consts'; import { communityRelays, searchRelays } from "$lib/consts";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types'; import type {
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils'; SearchResult,
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants'; SearchSubscriptionType,
SearchFilter,
SearchCallbacks,
SecondOrderSearchParams,
} from "./search_types";
import {
fieldMatches,
nip05Matches,
normalizeSearchTerm,
COMMON_DOMAINS,
isEmojiReaction,
} from "./search_utils";
import { TIMEOUTS, SEARCH_LIMITS } from "./search_constants";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
// Helper function to normalize URLs for comparison
const normalizeUrl = (url: string): string => {
return url.replace(/\/$/, ''); // Remove trailing slash
};
/** /**
* Search for events by subscription type (d, t, n) * Search for events by subscription type (d, t, n)
@ -16,11 +34,15 @@ export async function searchBySubscription(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
searchTerm: string, searchTerm: string,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
abortSignal?: AbortSignal abortSignal?: AbortSignal,
): Promise<SearchResult> { ): Promise<SearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", { searchType, searchTerm, normalizedSearchTerm }); console.log("subscription_search: Starting search:", {
searchType,
searchTerm,
normalizedSearchTerm,
});
// Check cache first // Check cache first
const cachedResult = searchCache.get(searchType, normalizedSearchTerm); const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
@ -32,7 +54,7 @@ export async function searchBySubscription(
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.error("subscription_search: NDK not initialized"); console.error("subscription_search: NDK not initialized");
throw new Error('NDK not initialized'); throw new Error("NDK not initialized");
} }
console.log("subscription_search: NDK initialized, creating search state"); console.log("subscription_search: NDK initialized, creating search state");
@ -49,49 +71,98 @@ export async function searchBySubscription(
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
console.log("subscription_search: Search aborted"); console.log("subscription_search: Search aborted");
cleanup(); cleanup();
throw new Error('Search cancelled'); throw new Error("Search cancelled");
} }
const searchFilter = await createSearchFilter(searchType, normalizedSearchTerm); const searchFilter = await createSearchFilter(
searchType,
normalizedSearchTerm,
);
console.log("subscription_search: Created search filter:", searchFilter); console.log("subscription_search: Created search filter:", searchFilter);
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk); const primaryRelaySet = createPrimaryRelaySet(searchType, ndk);
console.log("subscription_search: Created primary relay set with", primaryRelaySet.relays.size, "relays"); console.log(
"subscription_search: Created primary relay set with",
primaryRelaySet.relays.size,
"relays",
);
// Phase 1: Search primary relay // Phase 1: Search primary relay
if (primaryRelaySet.relays.size > 0) { if (primaryRelaySet.relays.size > 0) {
try { try {
console.log("subscription_search: Searching primary relay with filter:", searchFilter.filter); console.log(
"subscription_search: Searching primary relay with filter:",
searchFilter.filter,
);
const primaryEvents = await ndk.fetchEvents( const primaryEvents = await ndk.fetchEvents(
searchFilter.filter, searchFilter.filter,
{ closeOnEose: true }, { closeOnEose: true },
primaryRelaySet primaryRelaySet,
); );
console.log("subscription_search: Primary relay returned", primaryEvents.size, "events"); console.log(
processPrimaryRelayResults(primaryEvents, searchType, searchFilter.subscriptionType, normalizedSearchTerm, searchState, abortSignal, cleanup); "subscription_search: Primary relay returned",
primaryEvents.size,
"events",
);
processPrimaryRelayResults(
primaryEvents,
searchType,
searchFilter.subscriptionType,
normalizedSearchTerm,
searchState,
abortSignal,
cleanup,
);
// If we found results from primary relay, return them immediately // If we found results from primary relay, return them immediately
if (hasResults(searchState, searchType)) { if (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from primary relay, returning immediately"); console.log(
const immediateResult = createSearchResult(searchState, searchType, normalizedSearchTerm); "subscription_search: Found results from primary relay, returning immediately",
);
const immediateResult = createSearchResult(
searchState,
searchType,
normalizedSearchTerm,
);
searchCache.set(searchType, normalizedSearchTerm, immediateResult); searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results // Start Phase 2 in background for additional results
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
abortSignal,
cleanup,
);
return immediateResult; return immediateResult;
} else { } else {
console.log("subscription_search: No results from primary relay, continuing to Phase 2"); console.log(
"subscription_search: No results from primary relay, continuing to Phase 2",
);
} }
} catch (error) { } catch (error) {
console.error(`subscription_search: Error searching primary relay:`, error); console.error(
`subscription_search: Error searching primary relay:`,
error,
);
} }
} else { } else {
console.log("subscription_search: No primary relays available, skipping Phase 1"); console.log(
"subscription_search: No primary relays available, skipping Phase 1",
);
} }
// Always do Phase 2: Search all other relays in parallel // Always do Phase 2: Search all other relays in parallel
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); return searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
abortSignal,
cleanup,
);
} }
/** /**
@ -107,7 +178,7 @@ function createSearchState() {
eventAddresses: new Set<string>(), eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[], foundProfiles: [] as NDKEvent[],
isCompleted: false, isCompleted: false,
currentSubscription: null as any currentSubscription: null as any,
}; };
} }
@ -124,7 +195,7 @@ function createCleanupFunction(searchState: any) {
try { try {
searchState.currentSubscription.stop(); searchState.currentSubscription.stop();
} catch (e) { } catch (e) {
console.warn('Error stopping subscription:', e); console.warn("Error stopping subscription:", e);
} }
searchState.currentSubscription = null; searchState.currentSubscription = null;
} }
@ -134,25 +205,31 @@ function createCleanupFunction(searchState: any) {
/** /**
* Create search filter based on search type * Create search filter based on search type
*/ */
async function createSearchFilter(searchType: SearchSubscriptionType, normalizedSearchTerm: string): Promise<SearchFilter> { async function createSearchFilter(
console.log("subscription_search: Creating search filter for:", { searchType, normalizedSearchTerm }); searchType: SearchSubscriptionType,
normalizedSearchTerm: string,
): Promise<SearchFilter> {
console.log("subscription_search: Creating search filter for:", {
searchType,
normalizedSearchTerm,
});
switch (searchType) { switch (searchType) {
case 'd': case "d":
const dFilter = { const dFilter = {
filter: { "#d": [normalizedSearchTerm] }, filter: { "#d": [normalizedSearchTerm] },
subscriptionType: 'd-tag' subscriptionType: "d-tag",
}; };
console.log("subscription_search: Created d-tag filter:", dFilter); console.log("subscription_search: Created d-tag filter:", dFilter);
return dFilter; return dFilter;
case 't': case "t":
const tFilter = { const tFilter = {
filter: { "#t": [normalizedSearchTerm] }, filter: { "#t": [normalizedSearchTerm] },
subscriptionType: 't-tag' subscriptionType: "t-tag",
}; };
console.log("subscription_search: Created t-tag filter:", tFilter); console.log("subscription_search: Created t-tag filter:", tFilter);
return tFilter; return tFilter;
case 'n': case "n":
const nFilter = await createProfileSearchFilter(normalizedSearchTerm); const nFilter = await createProfileSearchFilter(normalizedSearchTerm);
console.log("subscription_search: Created profile filter:", nFilter); console.log("subscription_search: Created profile filter:", nFilter);
return nFilter; return nFilter;
@ -164,14 +241,20 @@ async function createSearchFilter(searchType: SearchSubscriptionType, normalized
/** /**
* Create profile search filter * Create profile search filter
*/ */
async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<SearchFilter> { async function createProfileSearchFilter(
normalizedSearchTerm: string,
): Promise<SearchFilter> {
// For npub searches, try to decode the search term first // For npub searches, try to decode the search term first
try { try {
const decoded = nip19.decode(normalizedSearchTerm); const decoded = nip19.decode(normalizedSearchTerm);
if (decoded && decoded.type === 'npub') { if (decoded && decoded.type === "npub") {
return { return {
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE }, filter: {
subscriptionType: 'npub-specific' kinds: [0],
authors: [decoded.data],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
},
subscriptionType: "npub-specific",
}; };
} }
} catch (e) { } catch (e) {
@ -186,8 +269,12 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<
const npub = await getNpubFromNip05(nip05Address); const npub = await getNpubFromNip05(nip05Address);
if (npub) { if (npub) {
return { return {
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE }, filter: {
subscriptionType: 'nip05-found' kinds: [0],
authors: [npub],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
},
subscriptionType: "nip05-found",
}; };
} }
} catch (e) { } catch (e) {
@ -200,26 +287,51 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<
return { return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE }, filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: 'profile' subscriptionType: "profile",
}; };
} }
/** /**
* Create primary relay set based on search type * Create primary relay set based on search type
*/ */
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet { function createPrimaryRelaySet(
if (searchType === 'n') { searchType: SearchSubscriptionType,
// For profile searches, use profile relay first ndk: any,
const profileRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) => ): NDKRelaySet {
relay.url === profileRelay || relay.url === profileRelay + '/' // Use the new relay management system
const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.debug('subscription_search: Active relay stores:', {
inboxRelays: get(activeInboxRelays),
outboxRelays: get(activeOutboxRelays),
searchRelays
});
// Debug: Log all relays in NDK pool
const poolRelays = Array.from(ndk.pool.relays.values());
console.debug('subscription_search: NDK pool relays:', poolRelays.map((r: any) => r.url));
if (searchType === "n") {
// For profile searches, use search relays first
const profileRelaySet = poolRelays.filter(
(relay: any) =>
searchRelays.some(
(searchRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(searchRelay),
),
); );
return new NDKRelaySet(new Set(profileRelays) as any, ndk); console.debug('subscription_search: Profile relay set:', profileRelaySet.map((r: any) => r.url));
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
} else { } else {
// For other searches, use community relay first // For other searches, use active relays first
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) => const activeRelaySet = poolRelays.filter(
relay.url === communityRelay || relay.url === communityRelay + '/' (relay: any) =>
searchRelays.some(
(searchRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(searchRelay),
),
); );
return new NDKRelaySet(new Set(communityRelays) as any, ndk); console.debug('subscription_search: Active relay set:', activeRelaySet.map((r: any) => r.url));
return new NDKRelaySet(new Set(activeRelaySet) as any, ndk);
} }
} }
@ -233,20 +345,29 @@ function processPrimaryRelayResults(
normalizedSearchTerm: string, normalizedSearchTerm: string,
searchState: any, searchState: any,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
cleanup?: () => void cleanup?: () => void,
) { ) {
console.log("subscription_search: Processing", events.size, "events from primary relay"); console.log(
"subscription_search: Processing",
events.size,
"events from primary relay",
);
for (const event of events) { for (const event of events) {
// Check for abort signal // Check for abort signal
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
cleanup?.(); cleanup?.();
throw new Error('Search cancelled'); throw new Error("Search cancelled");
} }
try { try {
if (searchType === 'n') { if (searchType === "n") {
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState); processProfileEvent(
event,
subscriptionType,
normalizedSearchTerm,
searchState,
);
} else { } else {
processContentEvent(event, searchType, searchState); processContentEvent(event, searchType, searchState);
} }
@ -256,30 +377,45 @@ function processPrimaryRelayResults(
} }
} }
console.log("subscription_search: Processed events - firstOrder:", searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, "tTag:", searchState.tTagEvents.length); console.log(
"subscription_search: Processed events - firstOrder:",
searchState.firstOrderEvents.length,
"profiles:",
searchState.foundProfiles.length,
"tTag:",
searchState.tTagEvents.length,
);
} }
/** /**
* Process profile event * Process profile event
*/ */
function processProfileEvent(event: NDKEvent, subscriptionType: string, normalizedSearchTerm: string, searchState: any) { function processProfileEvent(
event: NDKEvent,
subscriptionType: string,
normalizedSearchTerm: string,
searchState: any,
) {
if (!event.content) return; if (!event.content) return;
// If this is a specific npub search or NIP-05 found search, include all matching events // If this is a specific npub search or NIP-05 found search, include all matching events
if (subscriptionType === 'npub-specific' || subscriptionType === 'nip05-found') { if (
subscriptionType === "npub-specific" ||
subscriptionType === "nip05-found"
) {
searchState.foundProfiles.push(event); searchState.foundProfiles.push(event);
return; return;
} }
// For general profile searches, filter by content // For general profile searches, filter by content
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || ''; const displayName = profileData.display_name || profileData.displayName || "";
const name = profileData.name || ''; const name = profileData.name || "";
const nip05 = profileData.nip05 || ''; const nip05 = profileData.nip05 || "";
const username = profileData.username || ''; const username = profileData.username || "";
const about = profileData.about || ''; const about = profileData.about || "";
const bio = profileData.bio || ''; const bio = profileData.bio || "";
const description = profileData.description || ''; const description = profileData.description || "";
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm); const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm); const matchesName = fieldMatches(name, normalizedSearchTerm);
@ -289,7 +425,15 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz
const matchesBio = fieldMatches(bio, normalizedSearchTerm); const matchesBio = fieldMatches(bio, normalizedSearchTerm);
const matchesDescription = fieldMatches(description, normalizedSearchTerm); const matchesDescription = fieldMatches(description, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesUsername || matchesAbout || matchesBio || matchesDescription) { if (
matchesDisplayName ||
matchesName ||
matchesNip05 ||
matchesUsername ||
matchesAbout ||
matchesBio ||
matchesDescription
) {
searchState.foundProfiles.push(event); searchState.foundProfiles.push(event);
} }
} }
@ -297,24 +441,37 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz
/** /**
* Process content event * Process content event
*/ */
function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType, searchState: any) { function processContentEvent(
event: NDKEvent,
searchType: SearchSubscriptionType,
searchState: any,
) {
if (isEmojiReaction(event)) return; // Skip emoji reactions if (isEmojiReaction(event)) return; // Skip emoji reactions
if (searchType === 'd') { if (searchType === "d") {
console.log("subscription_search: Processing d-tag event:", { id: event.id, kind: event.kind, pubkey: event.pubkey }); console.log("subscription_search: Processing d-tag event:", {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
});
searchState.firstOrderEvents.push(event); searchState.firstOrderEvents.push(event);
// Collect event IDs and addresses for second-order search // Collect event IDs and addresses for second-order search
if (event.id) { if (event.id) {
searchState.eventIds.add(event.id); searchState.eventIds.add(event.id);
} }
const aTags = getMatchingTags(event, "a"); // Handle both "a" tags (NIP-62) and "e" tags (legacy)
aTags.forEach((tag: string[]) => { let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
tags.forEach((tag: string[]) => {
if (tag[1]) { if (tag[1]) {
searchState.eventAddresses.add(tag[1]); searchState.eventAddresses.add(tag[1]);
} }
}); });
} else if (searchType === 't') { } else if (searchType === "t") {
searchState.tTagEvents.push(event); searchState.tTagEvents.push(event);
} }
} }
@ -322,12 +479,15 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
/** /**
* Check if search state has results * Check if search state has results
*/ */
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean { function hasResults(
if (searchType === 'n') { searchState: any,
searchType: SearchSubscriptionType,
): boolean {
if (searchType === "n") {
return searchState.foundProfiles.length > 0; return searchState.foundProfiles.length > 0;
} else if (searchType === 'd') { } else if (searchType === "d") {
return searchState.firstOrderEvents.length > 0; return searchState.firstOrderEvents.length > 0;
} else if (searchType === 't') { } else if (searchType === "t") {
return searchState.tTagEvents.length > 0; return searchState.tTagEvents.length > 0;
} }
return false; return false;
@ -336,15 +496,24 @@ function hasResults(searchState: any, searchType: SearchSubscriptionType): boole
/** /**
* Create search result from state * Create search result from state
*/ */
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult { function createSearchResult(
searchState: any,
searchType: SearchSubscriptionType,
normalizedSearchTerm: string,
): SearchResult {
return { return {
events: searchType === 'n' ? searchState.foundProfiles : searchState.firstOrderEvents, events:
searchType === "n"
? searchState.foundProfiles
: searchType === "t"
? searchState.tTagEvents
: searchState.firstOrderEvents,
secondOrder: [], secondOrder: [],
tTagEvents: searchType === 't' ? searchState.tTagEvents : [], tTagEvents: [],
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
addresses: searchState.eventAddresses, addresses: searchState.eventAddresses,
searchType: searchType, searchType: searchType,
searchTerm: normalizedSearchTerm searchTerm: normalizedSearchTerm,
}; };
} }
@ -357,28 +526,36 @@ async function searchOtherRelaysInBackground(
searchState: any, searchState: any,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
cleanup?: () => void cleanup?: () => void,
): Promise<SearchResult> { ): Promise<SearchResult> {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const otherRelays = new NDKRelaySet( const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => { new Set(
if (searchType === 'n') { Array.from(ndk.pool.relays.values()).filter((relay: any) => {
// For profile searches, exclude profile relay from fallback search if (searchType === "n") {
return relay.url !== profileRelay && relay.url !== profileRelay + '/'; // For profile searches, exclude search relays from fallback search
return !searchRelays.some(
(searchRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(searchRelay),
);
} else { } else {
// For other searches, exclude community relay from fallback search // For other searches, exclude community relays from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/'; return !communityRelays.some(
(communityRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(communityRelay),
);
} }
})), }),
ndk ),
ndk,
); );
// Subscribe to events from other relays // Subscribe to events from other relays
const sub = ndk.subscribe( const sub = ndk.subscribe(
searchFilter.filter, searchFilter.filter,
{ closeOnEose: true }, { closeOnEose: true },
otherRelays otherRelays,
); );
// Store the subscription for cleanup // Store the subscription for cleanup
@ -389,10 +566,15 @@ async function searchOtherRelaysInBackground(
callbacks.onSubscriptionCreated(sub); callbacks.onSubscriptionCreated(sub);
} }
sub.on('event', (event: NDKEvent) => { sub.on("event", (event: NDKEvent) => {
try { try {
if (searchType === 'n') { if (searchType === "n") {
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState); processProfileEvent(
event,
searchFilter.subscriptionType,
searchState.normalizedSearchTerm,
searchState,
);
} else { } else {
processContentEvent(event, searchType, searchState); processContentEvent(event, searchType, searchState);
} }
@ -402,8 +584,13 @@ async function searchOtherRelaysInBackground(
}); });
return new Promise<SearchResult>((resolve) => { return new Promise<SearchResult>((resolve) => {
sub.on('eose', () => { sub.on("eose", () => {
const result = processEoseResults(searchType, searchState, searchFilter, callbacks); const result = processEoseResults(
searchType,
searchState,
searchFilter,
callbacks,
);
searchCache.set(searchType, searchState.normalizedSearchTerm, result); searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.(); cleanup?.();
resolve(result); resolve(result);
@ -418,13 +605,13 @@ function processEoseResults(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
searchState: any, searchState: any,
searchFilter: SearchFilter, searchFilter: SearchFilter,
callbacks?: SearchCallbacks callbacks?: SearchCallbacks,
): SearchResult { ): SearchResult {
if (searchType === 'n') { if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, callbacks); return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === 'd') { } else if (searchType === "d") {
return processContentEoseResults(searchState, searchType); return processContentEoseResults(searchState, searchType);
} else if (searchType === 't') { } else if (searchType === "t") {
return processTTagEoseResults(searchState); return processTTagEoseResults(searchState);
} }
@ -434,9 +621,13 @@ function processEoseResults(
/** /**
* Process profile EOSE results * Process profile EOSE results
*/ */
function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, callbacks?: SearchCallbacks): SearchResult { function processProfileEoseResults(
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks,
): SearchResult {
if (searchState.foundProfiles.length === 0) { if (searchState.foundProfiles.length === 0) {
return createEmptySearchResult('n', searchState.normalizedSearchTerm); return createEmptySearchResult("n", searchState.normalizedSearchTerm);
} }
// Deduplicate by pubkey, keep only newest // Deduplicate by pubkey, keep only newest
@ -452,19 +643,36 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter,
// Sort by creation time (newest first) and take only the most recent profiles // Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped) const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.map(x => x.event); .map((x) => x.event);
// Perform second-order search for npub searches // Perform second-order search for npub searches
if (searchFilter.subscriptionType === 'npub-specific' || searchFilter.subscriptionType === 'nip05-found') { if (
searchFilter.subscriptionType === "npub-specific" ||
searchFilter.subscriptionType === "nip05-found"
) {
const targetPubkey = dedupedProfiles[0]?.pubkey; const targetPubkey = dedupedProfiles[0]?.pubkey;
if (targetPubkey) { if (targetPubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), targetPubkey, callbacks); performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
new Set(),
new Set(),
targetPubkey,
callbacks,
);
} }
} else if (searchFilter.subscriptionType === 'profile') { } else if (searchFilter.subscriptionType === "profile") {
// For general profile searches, perform second-order search for each found profile // For general profile searches, perform second-order search for each found profile
for (const profile of dedupedProfiles) { for (const profile of dedupedProfiles) {
if (profile.pubkey) { if (profile.pubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks); performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
new Set(),
new Set(),
profile.pubkey,
callbacks,
);
} }
} }
} }
@ -473,36 +681,47 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter,
events: dedupedProfiles, events: dedupedProfiles,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: new Set(dedupedProfiles.map(p => p.id)), eventIds: new Set(dedupedProfiles.map((p) => p.id)),
addresses: new Set(), addresses: new Set(),
searchType: 'n', searchType: "n",
searchTerm: searchState.normalizedSearchTerm searchTerm: searchState.normalizedSearchTerm,
}; };
} }
/** /**
* Process content EOSE results * Process content EOSE results
*/ */
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult { function processContentEoseResults(
searchState: any,
searchType: SearchSubscriptionType,
): SearchResult {
if (searchState.firstOrderEvents.length === 0) { if (searchState.firstOrderEvents.length === 0) {
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); return createEmptySearchResult(
searchType,
searchState.normalizedSearchTerm,
);
} }
// Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination // Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {}; const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.firstOrderEvents) { for (const event of searchState.firstOrderEvents) {
const dTag = getMatchingTags(event, 'd')[0]?.[1] || ''; const dTag = getMatchingTags(event, "d")[0]?.[1] || "";
const key = `${event.kind}:${event.pubkey}:${dTag}`; const key = `${event.kind}:${event.pubkey}:${dTag}`;
const created_at = event.created_at || 0; const created_at = event.created_at || 0;
if (!deduped[key] || deduped[key].created_at < created_at) { if (!deduped[key] || deduped[key].created_at < created_at) {
deduped[key] = { event, created_at }; deduped[key] = { event, created_at };
} }
} }
const dedupedEvents = Object.values(deduped).map(x => x.event); const dedupedEvents = Object.values(deduped).map((x) => x.event);
// Perform second-order search for d-tag searches // Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) { if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses); performSecondOrderSearchInBackground(
"d",
dedupedEvents,
searchState.eventIds,
searchState.eventAddresses,
);
} }
return { return {
@ -512,7 +731,7 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
addresses: searchState.eventAddresses, addresses: searchState.eventAddresses,
searchType: searchType, searchType: searchType,
searchTerm: searchState.normalizedSearchTerm searchTerm: searchState.normalizedSearchTerm,
}; };
} }
@ -521,24 +740,27 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript
*/ */
function processTTagEoseResults(searchState: any): SearchResult { function processTTagEoseResults(searchState: any): SearchResult {
if (searchState.tTagEvents.length === 0) { if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult('t', searchState.normalizedSearchTerm); return createEmptySearchResult("t", searchState.normalizedSearchTerm);
} }
return { return {
events: [], events: searchState.tTagEvents,
secondOrder: [], secondOrder: [],
tTagEvents: searchState.tTagEvents, tTagEvents: [],
eventIds: new Set(), eventIds: new Set(),
addresses: new Set(), addresses: new Set(),
searchType: 't', searchType: "t",
searchTerm: searchState.normalizedSearchTerm searchTerm: searchState.normalizedSearchTerm,
}; };
} }
/** /**
* Create empty search result * Create empty search result
*/ */
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult { function createEmptySearchResult(
searchType: SearchSubscriptionType,
searchTerm: string,
): SearchResult {
return { return {
events: [], events: [],
secondOrder: [], secondOrder: [],
@ -546,7 +768,7 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm:
eventIds: new Set(), eventIds: new Set(),
addresses: new Set(), addresses: new Set(),
searchType: searchType, searchType: searchType,
searchTerm: searchTerm searchTerm: searchTerm,
}; };
} }
@ -554,18 +776,27 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm:
* Perform second-order search in background * Perform second-order search in background
*/ */
async function performSecondOrderSearchInBackground( async function performSecondOrderSearchInBackground(
searchType: 'n' | 'd', searchType: "n" | "d",
firstOrderEvents: NDKEvent[], firstOrderEvents: NDKEvent[],
eventIds: Set<string> = new Set(), eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(), addresses: Set<string> = new Set(),
targetPubkey?: string, targetPubkey?: string,
callbacks?: SearchCallbacks callbacks?: SearchCallbacks,
) { ) {
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = []; let allSecondOrderEvents: NDKEvent[] = [];
if (searchType === 'n' && targetPubkey) { // Set a timeout for second-order search
const timeoutPromise = new Promise((_, reject) => {
setTimeout(
() => reject(new Error("Second-order search timeout")),
TIMEOUTS.SECOND_ORDER_SEARCH,
);
});
const searchPromise = (async () => {
if (searchType === "n" && targetPubkey) {
// Search for events that mention this pubkey via p-tags // Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey] }; const pTagFilter = { "#p": [targetPubkey] };
const pTagEvents = await ndk.fetchEvents( const pTagEvents = await ndk.fetchEvents(
@ -573,46 +804,50 @@ async function performSecondOrderSearchInBackground(
{ closeOnEose: true }, { closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
); );
// Filter out emoji reactions // Filter out emoji reactions
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event)); const filteredEvents = Array.from(pTagEvents).filter(
(event) => !isEmojiReaction(event),
);
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents]; allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents];
} else if (searchType === "d") {
} else if (searchType === 'd') { // Parallel fetch for #e and #a tag events
// Search for events that reference the original events via e-tags and a-tags const relaySet = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
// Search for events that reference the original events via e-tags ndk,
if (eventIds.size > 0) {
const eTagFilter = { "#e": Array.from(eventIds) };
const eTagEvents = await ndk.fetchEvents(
eTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
); );
const [eTagEvents, aTagEvents] = await Promise.all([
// Filter out emoji reactions eventIds.size > 0
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event)); ? ndk.fetchEvents(
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents]; { "#e": Array.from(eventIds) },
}
// Search for events that reference the original events via a-tags
if (addresses.size > 0) {
const aTagFilter = { "#a": Array.from(addresses) };
const aTagEvents = await ndk.fetchEvents(
aTagFilter,
{ closeOnEose: true }, { closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), relaySet,
); )
: Promise.resolve([]),
addresses.size > 0
? ndk.fetchEvents(
{ "#a": Array.from(addresses) },
{ closeOnEose: true },
relaySet,
)
: Promise.resolve([]),
]);
// Filter out emoji reactions // Filter out emoji reactions
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event)); const filteredETagEvents = Array.from(eTagEvents).filter(
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredATagEvents]; (event) => !isEmojiReaction(event),
} );
const filteredATagEvents = Array.from(aTagEvents).filter(
(event) => !isEmojiReaction(event),
);
allSecondOrderEvents = [
...allSecondOrderEvents,
...filteredETagEvents,
...filteredATagEvents,
];
} }
// Deduplicate by event ID // Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>(); const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach(event => { allSecondOrderEvents.forEach((event) => {
if (event.id) { if (event.id) {
uniqueSecondOrder.set(event.id, event); uniqueSecondOrder.set(event.id, event);
} }
@ -621,8 +856,10 @@ async function performSecondOrderSearchInBackground(
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Remove any events already in first order // Remove any events already in first order
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id)); const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id)); deduplicatedSecondOrder = deduplicatedSecondOrder.filter(
(e) => !firstOrderIds.has(e.id),
);
// Sort by creation date (newest first) and limit to newest results // Sort by creation date (newest first) and limit to newest results
const sortedSecondOrder = deduplicatedSecondOrder const sortedSecondOrder = deduplicatedSecondOrder
@ -634,18 +871,27 @@ async function performSecondOrderSearchInBackground(
events: firstOrderEvents, events: firstOrderEvents,
secondOrder: sortedSecondOrder, secondOrder: sortedSecondOrder,
tTagEvents: [], tTagEvents: [],
eventIds: searchType === 'n' ? new Set(firstOrderEvents.map(p => p.id)) : eventIds, eventIds:
addresses: searchType === 'n' ? new Set() : addresses, searchType === "n"
? new Set(firstOrderEvents.map((p) => p.id))
: eventIds,
addresses: searchType === "n" ? new Set() : addresses,
searchType: searchType, searchType: searchType,
searchTerm: '' // This will be set by the caller searchTerm: "", // This will be set by the caller
}; };
// Notify UI of updated results // Notify UI of updated results
if (callbacks?.onSecondOrderUpdate) { if (callbacks?.onSecondOrderUpdate) {
callbacks.onSecondOrderUpdate(result); callbacks.onSecondOrderUpdate(result);
} }
})();
// Race between search and timeout
await Promise.race([searchPromise, timeoutPromise]);
} catch (err) { } catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err); console.error(
`[Search] Error in second-order ${searchType}-tag search:`,
err,
);
} }
} }

4
src/routes/+layout.svelte

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

116
src/routes/+layout.ts

@ -1,39 +1,115 @@
import { feedTypeStorageKey } from "$lib/consts"; import { getPersistedLogin, initNdk, ndkInstance } from "$lib/ndk";
import { FeedType } from "$lib/consts";
import { import {
getPersistedLogin,
initNdk,
loginWithExtension, loginWithExtension,
ndkInstance, loginWithAmber,
} from "$lib/ndk"; loginWithNpub,
} from "$lib/stores/userStore";
import { loginMethodStorageKey } from "$lib/stores/userStore";
import Pharos, { pharosInstance } from "$lib/parser"; import Pharos, { pharosInstance } from "$lib/parser";
import { feedType } from "$lib/stores";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import { get } from "svelte/store";
export const ssr = false; export const ssr = false;
export const load: LayoutLoad = () => { export const load: LayoutLoad = () => {
const initialFeedType = // Initialize NDK with new relay management system
(localStorage.getItem(feedTypeStorageKey) as FeedType) ??
FeedType.StandardRelays;
feedType.set(initialFeedType);
const ndk = initNdk(); const ndk = initNdk();
ndkInstance.set(ndk); ndkInstance.set(ndk);
try { 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(); const pubkey = getPersistedLogin();
if (pubkey) { const loginMethod = localStorage.getItem(loginMethodStorageKey);
// Michael J - 27 Jan 2025 - We don't await this call; it will run in the background and const logoutFlag = localStorage.getItem("alexandria/logout/flag");
// update Svelte stores to propagate data. console.log("Layout load - persisted pubkey:", pubkey);
loginWithExtension(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) { } catch (e) {
console.warn( console.warn(
`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`, `Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
); );
} }

96
src/routes/+page.svelte

@ -1,94 +1,38 @@
<script lang="ts"> <script lang="ts">
import { import { Alert, Input } from "flowbite-svelte";
FeedType, import { HammerSolid } from "flowbite-svelte-icons";
feedTypeStorageKey, import { userStore } from "$lib/stores/userStore";
standardRelays, import { activeInboxRelays, ndkSignedIn } from "$lib/ndk";
fallbackRelays, import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte";
} 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(() => { let searchQuery = $state("");
localStorage.setItem(feedTypeStorageKey, $feedType); let user = $derived($userStore);
}); let eventCount = $state({ displayed: 0, total: 0 });
$effect(() => {
if (!$ndkSignedIn && $feedType !== FeedType.StandardRelays) {
feedType.set(FeedType.StandardRelays);
}
});
const getFeedTypeFriendlyName = (feedType: FeedType): string => { function handleEventCountUpdate(counts: { displayed: number; total: number }) {
switch (feedType) { eventCount = counts;
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return "";
} }
};
let searchQuery = $state("");
</script> </script>
<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>
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4"> <main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
<div <div
class="leather w-full flex flex-row items-center justify-center gap-4 mb-4" 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 <Input
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Search publications by title or author..." placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base" 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> </div>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} /> {#if eventCount.total > 0}
{:else if $feedType === FeedType.StandardRelays} <div class="text-center text-sm text-gray-600 dark:text-gray-400">
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} /> Showing {eventCount.displayed} of {eventCount.total} events.
{:else if $feedType === FeedType.UserRelays} </div>
<PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} />
{/if} {/if}
<PublicationFeed
{searchQuery}
onEventCountUpdate={handleEventCountUpdate}
/>
</main> </main>

9
src/routes/about/+page.svelte

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

26
src/routes/contact/+page.svelte

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

394
src/routes/events/+page.svelte

@ -8,17 +8,17 @@
import EventDetails from "$lib/components/EventDetails.svelte"; import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte"; import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte"; import CommentBox from "$lib/components/CommentBox.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte'; import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from '$lib/stores/authStore.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 CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte'; import { neventEncode, naddrEncode } from "$lib/utils";
import { neventEncode, naddrEncode } from '$lib/utils'; import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk";
import { standardRelays } from '$lib/consts'; import { getEventType } from "$lib/utils/mime";
import { getEventType } from '$lib/utils/mime'; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte'; import { checkCommunity } from "$lib/utils/search_utility";
import { checkCommunity } from '$lib/utils/search_utility';
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -42,12 +42,15 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let user = $state($userStore);
let userRelayPreference = $state(false); let userRelayPreference = $state(false);
let showSidePanel = $state(false); let showSidePanel = $state(false);
let searchInProgress = $state(false); let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null); let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({}); let communityStatus = $state<Record<string, boolean>>({});
userStore.subscribe((val) => (user = val));
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true; showSidePanel = true;
@ -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; searchResults = results;
secondOrderResults = secondOrder; secondOrderResults = secondOrder;
tTagResults = tTagEvents; tTagResults = tTagEvents;
@ -83,12 +147,21 @@
searchTerm = searchTermParam || null; searchTerm = searchTermParam || null;
// Track search progress // 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 // 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...`; 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...`; secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) { } else if (secondOrder.length > 0) {
secondOrderSearchMessage = null; secondOrderSearchMessage = null;
@ -124,7 +197,7 @@
searchInProgress = false; searchInProgress = false;
secondOrderSearchMessage = null; secondOrderSearchMessage = null;
communityStatus = {}; communityStatus = {};
goto('/events', { replaceState: true }); goto("/events", { replaceState: true });
} }
function closeSidePanel() { function closeSidePanel() {
@ -148,7 +221,11 @@
return getMatchingTags(event, "deferral")[0]?.[1]; 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 // Check if this event has e-tags referencing original events
const eTags = getMatchingTags(event, "e"); const eTags = getMatchingTags(event, "e");
for (const tag of eTags) { for (const tag of eTags) {
@ -157,9 +234,13 @@
} }
} }
// Check if this event has a-tags referencing original events // Check if this event has a-tags or e-tags referencing original events
const aTags = getMatchingTags(event, "a"); let tags = getMatchingTags(event, "a");
for (const tag of aTags) { if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
for (const tag of tags) {
if (originalAddresses.has(tag[1])) { if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)"; return "Reply/Reference (a-tag)";
} }
@ -168,15 +249,18 @@
// Check if this event has content references // Check if this event has content references
if (event.content) { if (event.content) {
for (const id of originalEventIds) { for (const id of originalEventIds) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, 'i'); const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i");
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, 'i'); const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i");
if (neventPattern.test(event.content) || notePattern.test(event.content)) { if (
neventPattern.test(event.content) ||
notePattern.test(event.content)
) {
return "Content Reference"; return "Content Reference";
} }
} }
for (const address of originalAddresses) { 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)) { if (naddrPattern.test(event.content)) {
return "Content Reference"; return "Content Reference";
} }
@ -186,8 +270,15 @@
return "Reference"; return "Reference";
} }
function getNeventAddress(event: NDKEvent): string { function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, standardRelays); 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 { function isAddressableEvent(event: NDKEvent): boolean {
@ -199,7 +290,7 @@
return null; return null;
} }
try { try {
return naddrEncode(event, standardRelays); return naddrEncode(event, $activeInboxRelays);
} catch { } catch {
return null; return null;
} }
@ -218,12 +309,13 @@
function shortenAddress(addr: string, head = 10, tail = 10): string { function shortenAddress(addr: string, head = 10, tail = 10): string {
if (!addr || addr.length <= head + tail + 3) return addr; 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) { function onLoadingChange(val: boolean) {
loading = val; loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0); searchInProgress =
val || (searchResults.length > 0 && secondOrderResults.length === 0);
} }
/** /**
@ -237,7 +329,11 @@
try { try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey); newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) { } 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; newCommunityStatus[event.pubkey] = false;
} }
} else if (event.pubkey) { } else if (event.pubkey) {
@ -248,104 +344,13 @@
communityStatus = { ...communityStatus, ...newCommunityStatus }; 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;
}
}
// 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(() => { onMount(() => {
userRelayPreference = localStorage.getItem('useUserRelays') === 'true'; logCurrentRelayConfiguration();
// Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error);
}); });
</script> </script>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
@ -380,11 +385,13 @@
onEventFound={handleEventFound} onEventFound={handleEventFound}
onSearchResults={handleSearchResults} onSearchResults={handleSearchResults}
onClear={handleClear} onClear={handleClear}
onLoadingChange={onLoadingChange} {onLoadingChange}
/> />
{#if secondOrderSearchMessage} {#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} {secondOrderSearchMessage}
</div> </div>
{/if} {/if}
@ -392,12 +399,14 @@
{#if searchResults.length > 0} {#if searchResults.length > 0}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4">
{#if searchType === 'n'} {#if searchType === "n"}
Search Results for name: "{searchTerm}" ({searchResults.length} profiles) Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
{:else if searchType === 't'} {:else if searchType === "t"}
Search Results for t-tag: "{searchTerm}" ({searchResults.length} events) Search Results for t-tag: "{searchTerm}" ({searchResults.length}
events)
{:else} {:else}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events) Search Results for d-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if} {/if}
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4">
@ -409,15 +418,25 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100" <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" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} {#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"> <div
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
<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"/> 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> </svg>
</div> </div>
{:else} {:else}
@ -433,7 +452,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {result.created_at
? new Date(result.created_at * 1000).toLocaleDateString() ? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"} : "Unknown date"}
</span> </span>
</div> </div>
@ -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" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
} }
}} }}
tabindex="0" tabindex="0"
@ -470,7 +495,9 @@
</div> </div>
{/if} {/if}
{#if isAddressableEvent(result)} {#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} /> <ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
@ -478,7 +505,8 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" 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> </div>
@ -496,13 +524,14 @@
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length} Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events) events)
</Heading> </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"> <P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing the 100 newest events. More results may be available. Showing the 100 newest events. More results may be available.
</P> </P>
{/if} {/if}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <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> </P>
<div class="space-y-4"> <div class="space-y-4">
{#each secondOrderResults as result, index} {#each secondOrderResults as result, index}
@ -519,9 +548,18 @@
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} {#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"> <div
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
<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"/> 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> </svg>
</div> </div>
{:else} {:else}
@ -537,12 +575,18 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {result.created_at
? new Date(result.created_at * 1000).toLocaleDateString() ? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"} : "Unknown date"}
</span> </span>
</div> </div>
<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">
{getReferenceType(result, originalEventIds, originalAddresses)} {getReferenceType(
result,
originalEventIds,
originalAddresses,
)}
</div> </div>
{#if getSummary(result)} {#if getSummary(result)}
<div <div
@ -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" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
} }
}} }}
tabindex="0" tabindex="0"
@ -577,7 +625,9 @@
</div> </div>
{/if} {/if}
{#if isAddressableEvent(result)} {#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} /> <ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
@ -585,7 +635,8 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" 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> </div>
@ -600,7 +651,8 @@
{#if tTagResults.length > 0} {#if tTagResults.length > 0}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4"> <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> </Heading>
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag. Events that are tagged with the t-tag.
@ -620,9 +672,18 @@
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} {#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"> <div
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24"> class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
<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"/> 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> </svg>
</div> </div>
{:else} {:else}
@ -638,7 +699,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {result.created_at
? new Date(result.created_at * 1000).toLocaleDateString() ? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"} : "Unknown date"}
</span> </span>
</div> </div>
@ -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" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || ''); navigateToPublication(
getDeferralNaddr(result) || "",
);
} }
}} }}
tabindex="0" tabindex="0"
@ -675,7 +742,9 @@
</div> </div>
{/if} {/if}
{#if isAddressableEvent(result)} {#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} /> <ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
@ -683,7 +752,8 @@
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" 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> </div>
@ -719,8 +789,8 @@
{#if event.kind !== 0} {#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all"> <div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard <CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))} displayText={shortenAddress(getNeventUrl(event))}
copyText={getNeventAddress(event)} copyText={getNeventUrl(event)}
/> />
{#if isAddressableEvent(event)} {#if isAddressableEvent(event)}
{@const naddrAddress = getViewPublicationNaddr(event)} {@const naddrAddress = getViewPublicationNaddr(event)}

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

@ -1,29 +1,98 @@
<script lang="ts"> <script lang="ts">
import Preview from "$lib/components/Preview.svelte"; import { Heading, Button, Alert } from "flowbite-svelte";
import { pharosInstance } from "$lib/parser"; import { PaperPlaneOutline } from "flowbite-svelte-icons";
import { Heading } from "flowbite-svelte"; import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools";
import { publishZettel } from "$lib/services/publisher";
let treeNeedsUpdate: boolean = false; let content = $state("");
let treeUpdateCount: number = 0; let showPreview = $state(false);
let someIndexValue = 0; let isPublishing = $state(false);
let publishResult = $state<{
success: boolean;
eventId?: string;
error?: string;
} | null>(null);
$: { // Handle content changes from ZettelEditor
if (treeNeedsUpdate) { function handleContentChange(newContent: string) {
treeUpdateCount++; 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> </script>
<div class="w-full flex justify-center"> <svelte:head>
<main class="main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4"> <title>Compose Note - Alexandria</title>
<Heading tag="h1" class="h-leather mb-2">Compose</Heading> </svelte:head>
{#key treeUpdateCount}
<Preview <!-- Main container with 75% width and centered -->
rootId={$pharosInstance.getRootIndexId()} <div class="w-3/4 mx-auto">
allowEditing={true} <div class="flex flex-col space-y-4">
bind:needsUpdate={treeNeedsUpdate} <Heading
index={someIndexValue} tag="h1"
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
Compose Notes
</Heading>
<ZettelEditor
{content}
{showPreview}
onContentChange={handleContentChange}
onPreviewToggle={handlePreviewToggle}
/> />
{/key}
</main> <!-- 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> </div>

79
src/routes/publication/+page.svelte

@ -1,17 +1,26 @@
<script lang="ts"> <script lang="ts">
import Publication from "$lib/components/Publication.svelte"; import Publication from "$lib/components/publications/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte"; import { onDestroy, onMount, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor"; import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte"; 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(); 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("publicationTree", publicationTree);
setContext("toc", toc);
setContext("asciidoctor", Processor()); setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags // Get publication metadata for OpenGraph tags
@ -20,7 +29,9 @@
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication", "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 // Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic. // If image unavailable, use the Alexandria default pic.
@ -33,6 +44,58 @@
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.", "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()); onDestroy(() => data.parser.reset());
</script> </script>
@ -56,22 +119,16 @@
<meta name="twitter:image" content={image} /> <meta name="twitter:image" content={image} />
</svelte:head> </svelte:head>
{#key data}
<ArticleNav <ArticleNav
publicationType={data.publicationType} publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()} rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent} indexEvent={data.indexEvent}
/> />
{/key}
<main class="publication {data.publicationType}"> <main class="publication {data.publicationType}">
{#await data.waitable}
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
<Publication <Publication
rootAddress={data.indexEvent.tagAddress()} rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType} publicationType={data.publicationType}
indexEvent={data.indexEvent} indexEvent={data.indexEvent}
/> />
{/await}
</main> </main>

11
src/routes/publication/+page.ts

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

38
src/routes/start/+page.svelte

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

13
src/routes/visualize/+page.svelte

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

53
src/styles/asciidoc.css

@ -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;
}

20
test_data/LaTeXtestfile.json

File diff suppressed because one or more lines are too long

13
test_data/LaTeXtestfile.md

@ -27,17 +27,23 @@ f(x)=
1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\ 1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\
0 & \quad \text{otherwise} 0 & \quad \text{otherwise}
\end{cases} \end{cases}
$$`
$$
`
And a matrix: And a matrix:
`$$ `
$$
M = M =
\begin{bmatrix} \begin{bmatrix}
\frac{5}{6} & \frac{1}{6} & 0 \\[0.3em] \frac{5}{6} & \frac{1}{6} & 0 \\[0.3em]
\frac{5}{6} & 0 & \frac{1}{6} \\[0.3em] \frac{5}{6} & 0 & \frac{1}{6} \\[0.3em]
0 & \frac{5}{6} & \frac{1}{6} 0 & \frac{5}{6} & \frac{1}{6}
\end{bmatrix} \end{bmatrix}
$$`
$$
`
LaTeX ypesetting won't be rendered. Use NostrMarkup delimeter tables for this sort of thing. LaTeX ypesetting won't be rendered. Use NostrMarkup delimeter tables for this sort of thing.
@ -133,3 +139,4 @@ This document should demonstrate that:
3. Regular code blocks remain unchanged 3. Regular code blocks remain unchanged
4. Mixed content is handled correctly 4. Mixed content is handled correctly
5. Edge cases are handled gracefully 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", () => {
// Extract the markdown content field from the JSON event // Extract the markdown content field from the JSON event
const content = JSON.parse(raw).content; 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); const html = await parseAdvancedmarkup(content);
// Test basic LaTeX examples from the test document // Test basic LaTeX examples from the test document
expect(html).toMatch(/<span class="math-inline">\$\\sqrt\{x\}\$<\/span>/); expect(html).toMatch(/<span class="math-inline">\$\\sqrt\{x\}\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$\\sqrt\{x\}\$\$<\/div>/); 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(
expect(html).toMatch(/<div class="math-block">\$\$P \\left\( A=2 \\, \\middle\| \\, \\dfrac\{A\^2\}\{B\}>4 \\right\)\$\$<\/div>/); /<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); const html = await parseAdvancedmarkup(content);
// Test AsciiMath examples // Test AsciiMath examples
expect(html).toMatch(/<span class="math-inline">\$E=mc\^2\$<\/span>/); 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(
expect(html).toMatch(/<div class="math-block">\$\$int_0\^1 x\^2 dx\$\$<\/div>/); /<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); const html = await parseAdvancedmarkup(content);
// Test array and matrix environments // 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(
expect(html).toMatch(/<div class="math-block">\$\$[\s\S]*\\begin\{bmatrix\}[\s\S]*\\end\{bmatrix\}[\s\S]*\$\$<\/div>/); /<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); const html = await parseAdvancedmarkup(content);
// Should show a message and plaintext for tabular // Should show a message and plaintext for tabular
expect(html).toMatch(/<div class="unrendered-latex">/); 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\}/); 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); const html = await parseAdvancedmarkup(content);
// Test mixed 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(/<span class="math-inline">\$1\/2\$<\/span>/);
expect(html).toMatch(/<div class="math-block">\$\$\\sum_\{i=1\}\^n x_i\$\$<\/div>/); expect(html).toMatch(
expect(html).toMatch(/<div class="math-block">\$\$sum_\(i=1\)\^n x_i\$\$<\/div>/); /<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); const html = await parseAdvancedmarkup(content);
// Test regular code blocks (should remain as code, not math) // Test regular code blocks (should remain as code, not math)
expect(html).toMatch(/<code[^>]*>\$19\.99<\/code>/); expect(html).toMatch(/<code[^>]*>\$19\.99<\/code>/);
expect(html).toMatch(/<code[^>]*>echo &quot;Price: \$100&quot;<\/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>/); expect(html).toMatch(/<code[^>]*>color: \$primary-color<\/code>/);
}); });
}); });

Loading…
Cancel
Save