Browse Source

ran prettier

master
silberengel 8 months ago
parent
commit
5ceaced771
  1. 2
      src/app.css
  2. 199
      src/lib/components/CommentBox.svelte
  3. 164
      src/lib/components/EventDetails.svelte
  4. 290
      src/lib/components/EventInput.svelte
  5. 232
      src/lib/components/EventSearch.svelte
  6. 220
      src/lib/components/LoginMenu.svelte
  7. 16
      src/lib/components/LoginModal.svelte
  8. 4
      src/lib/components/Preview.svelte
  9. 74
      src/lib/components/ZettelEditor.svelte
  10. 2
      src/lib/components/cards/BlogHeader.svelte
  11. 26
      src/lib/components/cards/ProfileHeader.svelte
  12. 26
      src/lib/components/publications/Publication.svelte
  13. 29
      src/lib/components/publications/PublicationFeed.svelte
  14. 49
      src/lib/components/publications/PublicationHeader.svelte
  15. 91
      src/lib/components/publications/PublicationSection.svelte
  16. 38
      src/lib/components/publications/TableOfContents.svelte
  17. 4
      src/lib/components/publications/svelte_publication_tree.svelte.ts
  18. 45
      src/lib/components/publications/table_of_contents.svelte.ts
  19. 90
      src/lib/components/util/ArticleNav.svelte
  20. 48
      src/lib/components/util/CardActions.svelte
  21. 10
      src/lib/components/util/ContainingIndexes.svelte
  22. 57
      src/lib/components/util/Details.svelte
  23. 15
      src/lib/components/util/Profile.svelte
  24. 7
      src/lib/components/util/ViewPublicationLink.svelte
  25. 6
      src/lib/consts.ts
  26. 129
      src/lib/data_structures/publication_tree.ts
  27. 7
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  28. 53
      src/lib/ndk.ts
  29. 48
      src/lib/services/publisher.ts
  30. 46
      src/lib/snippets/UserSnippets.svelte
  31. 13
      src/lib/stores.ts
  32. 2
      src/lib/stores/authStore.Svelte.ts
  33. 165
      src/lib/stores/userStore.ts
  34. 59
      src/lib/utils/ZettelParser.ts
  35. 34
      src/lib/utils/community_checker.ts
  36. 184
      src/lib/utils/event_input_utils.ts
  37. 22
      src/lib/utils/indexEventCache.ts
  38. 6
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  39. 27
      src/lib/utils/markup/advancedMarkupParser.ts
  40. 7
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  41. 6
      src/lib/utils/markup/basicMarkupParser.ts
  42. 19
      src/lib/utils/mime.ts
  43. 210
      src/lib/utils/nostrEventService.ts
  44. 90
      src/lib/utils/nostrUtils.ts
  45. 267
      src/lib/utils/profile_search.ts
  46. 44
      src/lib/utils/relayDiagnostics.ts
  47. 10
      src/lib/utils/searchCache.ts
  48. 2
      src/lib/utils/search_constants.ts
  49. 6
      src/lib/utils/search_types.ts
  50. 26
      src/lib/utils/search_utility.ts
  51. 27
      src/lib/utils/search_utils.ts
  52. 489
      src/lib/utils/subscription_search.ts
  53. 107
      src/routes/+layout.ts
  54. 26
      src/routes/+page.svelte
  55. 8
      src/routes/about/+page.svelte
  56. 23
      src/routes/contact/+page.svelte
  57. 286
      src/routes/events/+page.svelte
  58. 21
      src/routes/new/compose/+page.svelte
  59. 32
      src/routes/publication/+page.svelte
  60. 46
      src/routes/publication/+page.ts
  61. 25
      src/routes/start/+page.svelte
  62. 4
      src/routes/visualize/+page.svelte
  63. 20
      test_data/LaTeXtestfile.json
  64. 13
      test_data/LaTeXtestfile.md
  65. 56
      tests/unit/latexRendering.test.ts

2
src/app.css

@ -3,7 +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'; @import "./styles/asciidoc.css";
/* Custom styles */ /* Custom styles */
@layer base { @layer base {

199
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, ProfileSearchResult } from "$lib/utils/search_utility"; import type {
NostrProfile,
ProfileSearchResult,
} from "$lib/utils/search_utility";
import { userPubkey } from '$lib/stores/authStore.Svelte'; 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,7 +19,7 @@
publishEvent, publishEvent,
navigateToEvent, navigateToEvent,
} from "$lib/utils/nostrEventService"; } from "$lib/utils/nostrEventService";
import { tick } from 'svelte'; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
const props = $props<{ const props = $props<{
@ -36,11 +39,11 @@
// 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;
@ -48,7 +51,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
@ -57,7 +60,7 @@
}, 100); }, 100);
} else { } else {
// Reset search when modal closes // Reset search when modal closes
mentionSearch = ''; mentionSearch = "";
mentionResults = []; mentionResults = [];
mentionLoading = false; mentionLoading = false;
} }
@ -68,12 +71,12 @@
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;
@ -83,10 +86,9 @@
$effect(() => { $effect(() => {
if (!success) return; if (!success) return;
content = ''; content = "";
preview = ''; preview = "";
} });
);
// Markup buttons // Markup buttons
const markupButtons = [ const markupButtons = [
@ -99,8 +101,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) {
@ -162,15 +176,17 @@
success = null; success = null;
try { try {
const pk = $userPubkey || ''; 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;
@ -185,14 +201,19 @@
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,
pk,
kind,
tags,
);
// Publish the event // Publish the event
const result = await publishEvent( const result = await publishEvent(
signedEvent, signedEvent,
useOtherRelays, useOtherRelays,
useFallbackRelays, useFallbackRelays,
props.userRelayPreference props.userRelayPreference,
); );
if (result.success) { if (result.success) {
@ -202,17 +223,19 @@
} else { } else {
if (!useOtherRelays && !useFallbackRelays) { if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true; showOtherRelays = true;
error = "Failed to publish to primary relays. Would you like to try the other relays?"; error =
"Failed to publish to primary relays. Would you like to try the other relays?";
} else if (useOtherRelays && !useFallbackRelays) { } else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true; showFallbackRelays = true;
error = "Failed to publish to other relays. Would you like to try the fallback relays?"; error =
"Failed to publish to other relays. Would you like to try the fallback relays?";
} else { } else {
error = "Failed to publish comment. Please try again later."; error = "Failed to publish comment. Please try again later.";
} }
} }
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error publishing comment:', e); console.error("Error publishing comment:", e);
error = e instanceof Error ? e.message : 'An unexpected error occurred'; error = e instanceof Error ? e.message : "An unexpected error occurred";
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -220,8 +243,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) {
@ -257,38 +280,49 @@
return; return;
} }
console.log('Starting search for:', mentionSearch.trim()); 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...'); 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("Search completed, found profiles:", result.profiles.length);
console.log('Profile details:', result.profiles); console.log("Profile details:", result.profiles);
console.log('Community status:', result.Status); console.log("Community status:", result.Status);
// Update state // Update state
mentionResults = result.profiles; mentionResults = result.profiles;
communityStatus = result.Status; communityStatus = result.Status;
console.log('State updated - mentionResults length:', mentionResults.length); console.log(
console.log('State updated - communityStatus keys:', Object.keys(communityStatus)); "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); 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);
@ -299,22 +333,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 {
@ -322,8 +356,8 @@
} }
insertAtCursor(markup); insertAtCursor(markup);
showWikilinkModal = false; showWikilinkModal = false;
wikilinkTarget = ''; wikilinkTarget = "";
wikilinkLabel = ''; wikilinkLabel = "";
} }
function handleViewComment() { function handleViewComment() {
@ -362,7 +396,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();
} }
}} }}
@ -389,24 +423,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="text-center py-2 text-xs text-gray-500">Found {mentionResults.length} results</div> <div class="text-center py-2 text-xs text-gray-500">
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"> 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">
@ -414,11 +471,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}
@ -427,7 +497,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>
@ -454,8 +526,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>
@ -469,7 +548,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>
@ -522,7 +603,7 @@
<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($userPubkey || '').slice(0, 8) + "..."} nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}

164
src/lib/components/EventDetails.svelte

@ -8,8 +8,8 @@
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 { goto } from "$app/navigation";
import { onMount } from 'svelte'; 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 { navigateToEvent } from "$lib/utils/nostrEventService"; import { navigateToEvent } from "$lib/utils/nostrEventService";
@ -35,8 +35,8 @@
}>(); }>();
let showFullContent = $state(false); let showFullContent = $state(false);
let parsedContent = $state(''); let parsedContent = $state("");
let contentPreview = $state(''); let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
@ -55,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) {
@ -86,8 +89,8 @@
} }
function renderTag(tag: string[]): string { function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) { if (tag[0] === "a" && tag.length > 1) {
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 // Validate that pubkey is a valid hex string
@ -96,70 +99,82 @@
const mockEvent = { const mockEvent = {
kind: +kind, kind: +kind,
pubkey, pubkey,
tags: [['d', d]], tags: [["d", d]],
content: '', content: "",
id: '', id: "",
sig: '' sig: "",
} as any; } as any;
const naddr = naddrEncode(mockEvent, standardRelays); const naddr = naddrEncode(mockEvent, standardRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`; return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} catch (error) { } catch (error) {
console.warn('Failed to encode naddr for a tag in renderTag:', tag[1], 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>`; return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
} }
} else { } else {
console.warn('Invalid pubkey in a tag in renderTag:', pubkey); 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>`; return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
} }
} else { } else {
console.warn('Invalid a tag format in renderTag:', tag[1]); 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>`; 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) { } else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string // Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try { try {
const mockEvent = { const mockEvent = {
id: tag[1], id: tag[1],
kind: 1, kind: 1,
content: '', content: "",
tags: [], tags: [],
pubkey: '', pubkey: "",
sig: '' sig: "",
} as any; } as any;
const nevent = neventEncode(mockEvent, standardRelays); const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`; return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} catch (error) { } catch (error) {
console.warn('Failed to encode nevent for e tag in renderTag:', tag[1], 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>`; return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
} }
} else { } else {
console.warn('Invalid event ID in e tag in renderTag:', tag[1]); 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>`; 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) { } else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix // 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try { try {
const mockEvent = { const mockEvent = {
id: tag[1], id: tag[1],
kind: 1, kind: 1,
content: '', content: "",
tags: [], tags: [],
pubkey: '', pubkey: "",
sig: '' sig: "",
} as any; } as any;
const nevent = neventEncode(mockEvent, standardRelays); const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`; return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) { } catch (error) {
console.warn('Failed to encode nevent for note tag in renderTag:', tag[1], 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>`; return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
} }
} else { } else {
console.warn('Invalid event ID in note tag in renderTag:', tag[1]); 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>`; 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) { } else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events // '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>`; return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${tag[1]}</a>`;
} else { } else {
@ -171,8 +186,8 @@
text: string; text: string;
gotoValue?: string; gotoValue?: string;
} { } {
if (tag[0] === 'a' && tag.length > 1) { if (tag[0] === "a" && tag.length > 1) {
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 // Validate that pubkey is a valid hex string
@ -181,95 +196,95 @@
const mockEvent = { const mockEvent = {
kind: +kind, kind: +kind,
pubkey, pubkey,
tags: [['d', d]], tags: [["d", d]],
content: '', content: "",
id: '', id: "",
sig: '' sig: "",
} as any; } as any;
const naddr = naddrEncode(mockEvent, standardRelays); const naddr = naddrEncode(mockEvent, standardRelays);
return { return {
text: `a:${tag[1]}`, text: `a:${tag[1]}`,
gotoValue: naddr gotoValue: naddr,
}; };
} catch (error) { } catch (error) {
console.warn('Failed to encode naddr for a tag:', tag[1], error); console.warn("Failed to encode naddr for a tag:", tag[1], error);
return { text: `a:${tag[1]}` }; return { text: `a:${tag[1]}` };
} }
} else { } else {
console.warn('Invalid pubkey in a tag:', pubkey); console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` }; return { text: `a:${tag[1]}` };
} }
} else { } else {
console.warn('Invalid a tag format:', tag[1]); console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` }; return { text: `a:${tag[1]}` };
} }
} else if (tag[0] === 'e' && tag.length > 1) { } else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string // Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try { try {
const mockEvent = { const mockEvent = {
id: tag[1], id: tag[1],
kind: 1, kind: 1,
content: '', content: "",
tags: [], tags: [],
pubkey: '', pubkey: "",
sig: '' sig: "",
} as any; } as any;
const nevent = neventEncode(mockEvent, standardRelays); const nevent = neventEncode(mockEvent, standardRelays);
return { return {
text: `e:${tag[1]}`, text: `e:${tag[1]}`,
gotoValue: nevent gotoValue: nevent,
}; };
} catch (error) { } catch (error) {
console.warn('Failed to encode nevent for e tag:', tag[1], error); console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` }; return { text: `e:${tag[1]}` };
} }
} else { } else {
console.warn('Invalid event ID in e tag:', tag[1]); console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` }; return { text: `e:${tag[1]}` };
} }
} else if (tag[0] === 'p' && tag.length > 1) { } else if (tag[0] === "p" && tag.length > 1) {
const npub = toNpub(tag[1]); const npub = toNpub(tag[1]);
return { return {
text: `p:${npub || tag[1]}`, text: `p:${npub || tag[1]}`,
gotoValue: npub ? npub : undefined gotoValue: npub ? npub : undefined,
}; };
} else if (tag[0] === 'note' && tag.length > 1) { } else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix // 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try { try {
const mockEvent = { const mockEvent = {
id: tag[1], id: tag[1],
kind: 1, kind: 1,
content: '', content: "",
tags: [], tags: [],
pubkey: '', pubkey: "",
sig: '' sig: "",
} as any; } as any;
const nevent = neventEncode(mockEvent, standardRelays); const nevent = neventEncode(mockEvent, standardRelays);
return { return {
text: `note:${tag[1]}`, text: `note:${tag[1]}`,
gotoValue: nevent gotoValue: nevent,
}; };
} catch (error) { } catch (error) {
console.warn('Failed to encode nevent for note tag:', tag[1], error); console.warn("Failed to encode nevent for note tag:", tag[1], error);
return { text: `note:${tag[1]}` }; return { text: `note:${tag[1]}` };
} }
} else { } else {
console.warn('Invalid event ID in note tag:', tag[1]); console.warn("Invalid event ID in note tag:", tag[1]);
return { text: `note:${tag[1]}` }; return { text: `note:${tag[1]}` };
} }
} else if (tag[0] === 'd' && tag.length > 1) { } else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events // 'd' tags are used for identifiers in addressable events
return { return {
text: `d:${tag[1]}`, text: `d:${tag[1]}`,
gotoValue: `d:${tag[1]}` gotoValue: `d:${tag[1]}`,
}; };
} else if (tag[0] === 't' && tag.length > 1) { } else if (tag[0] === "t" && tag.length > 1) {
// 't' tags are hashtags - navigate to t-tag search // 't' tags are hashtags - navigate to t-tag search
return { return {
text: `t:${tag[1]}`, text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}` gotoValue: `t:${tag[1]}`,
}; };
} }
return { text: `${tag[0]}:${tag[1]}` }; return { text: `${tag[0]}:${tag[1]}` };
@ -353,16 +368,16 @@
onMount(() => { onMount(() => {
function handleInternalLinkClick(event: MouseEvent) { function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.tagName === 'A') { if (target.tagName === "A") {
const href = (target as HTMLAnchorElement).getAttribute('href'); const href = (target as HTMLAnchorElement).getAttribute("href");
if (href && href.startsWith('/')) { if (href && href.startsWith("/")) {
event.preventDefault(); event.preventDefault();
goto(href); goto(href);
} }
} }
} }
document.addEventListener('click', handleInternalLinkClick); document.addEventListener("click", handleInternalLinkClick);
return () => document.removeEventListener('click', handleInternalLinkClick); return () => document.removeEventListener("click", handleInternalLinkClick);
}); });
</script> </script>
@ -375,9 +390,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-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span> <span class="text-gray-600 dark:text-gray-400"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || event.pubkey,
)}</span
>
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span> <span class="text-gray-600 dark:text-gray-400"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if} {/if}
</div> </div>
@ -450,17 +472,23 @@
<button <button
onclick={() => { onclick={() => {
// Handle different types of 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')) { 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 // For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`); goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith('/')) { } else if (tagInfo.gotoValue!.startsWith("/")) {
// For relative URLs - navigate directly // For relative URLs - navigate directly
goto(tagInfo.gotoValue!); goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith('d:')) { } else if (tagInfo.gotoValue!.startsWith("d:")) {
// For d-tag searches - navigate to d-tag search // For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2); const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`); goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith('t:')) { } else if (tagInfo.gotoValue!.startsWith("t:")) {
// For t-tag searches - navigate to t-tag search // For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2); const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`); goto(`/events?t=${encodeURIComponent(tTag)}`);

290
src/lib/components/EventInput.svelte

@ -1,30 +1,43 @@
<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 { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { standardRelays } from "$lib/consts";
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 +46,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 +69,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 +99,9 @@
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.' }; if (!currentUserPubkey) return { valid: false, reason: "Not logged in." };
const pubkey = String(currentUserPubkey); const pubkey = String(currentUserPubkey);
if (!content.trim()) return { valid: false, reason: 'Content required.' }; 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 +119,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();
@ -120,14 +138,14 @@
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const currentUserPubkey = get(userPubkey as any); const currentUserPubkey = get(userPubkey as any);
if (!ndk || !currentUserPubkey) { if (!ndk || !currentUserPubkey) {
error = 'NDK or pubkey missing.'; error = "NDK or pubkey missing.";
loading = false; loading = false;
return; return;
} }
const pubkey = String(currentUserPubkey); const pubkey = String(currentUserPubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) { if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
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,7 +153,7 @@
// 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;
} }
@ -143,38 +161,44 @@
const baseEvent = { pubkey, created_at: createdAt }; const baseEvent = { pubkey, 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 +207,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 +224,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
@ -224,11 +248,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 +260,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 +288,12 @@
}; };
// 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",
...standardRelays,
];
let published = false; let published = false;
for (const relayUrl of relays) { for (const relayUrl of relays) {
@ -314,8 +349,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 +361,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 +373,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 +393,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-primary-600 dark:text-primary-500 hover:underline ml-2"
>
View your event View your event
</Button> </Button>
</div> </div>

232
src/lib/components/EventSearch.svelte

@ -4,7 +4,11 @@
import { goto } from "$app/navigation"; 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 RelayDisplay from "./RelayDisplay.svelte";
import { searchEvent, searchBySubscription, searchNip05 } from "$lib/utils/search_utility"; import {
searchEvent,
searchBySubscription,
searchNip05,
} from "$lib/utils/search_utility";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
@ -33,7 +37,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;
@ -43,7 +47,9 @@
// 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 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);
@ -56,7 +62,11 @@
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 ||
(Object.values(relayStatuses).some((s) => s === "pending") &&
!foundEvent),
);
let showError = $derived(localError || error); let showError = $derived(localError || error);
let showSuccess = $derived(searchCompleted && searchResultCount !== null); let showSuccess = $derived(searchCompleted && searchResultCount !== null);
@ -75,18 +85,39 @@
const foundEvent = await searchNip05(query); const foundEvent = await searchNip05(query);
if (foundEvent) { if (foundEvent) {
handleFoundEvent(foundEvent); handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'nip05'); updateSearchState(false, true, 1, "nip05");
} else { } else {
relayStatuses = {}; relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; } if (activeSub) {
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } try {
updateSearchState(false, true, 0, 'nip05'); activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, "nip05");
} }
} catch (error) { } catch (error) {
localError = error instanceof Error ? error.message : 'NIP-05 lookup failed'; localError =
error instanceof Error ? error.message : "NIP-05 lookup failed";
relayStatuses = {}; relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; } if (activeSub) {
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
isProcessingSearch = false; isProcessingSearch = false;
currentProcessingSearchValue = null; currentProcessingSearchValue = null;
@ -102,26 +133,49 @@
console.warn("[Events] Event not found for query:", query); console.warn("[Events] Event not found for query:", query);
localError = "Event not found"; localError = "Event not found";
relayStatuses = {}; relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; } if (activeSub) {
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
} else { } else {
console.log("[Events] Event found:", foundEvent); console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent); handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'event'); updateSearchState(false, true, 1, "event");
} }
} catch (err) { } catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query); console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again."; localError = "Error fetching event. Please check the ID and try again.";
relayStatuses = {}; relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; } if (activeSub) {
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
isProcessingSearch = false; isProcessingSearch = false;
} }
} }
async function handleSearchEvent(clearInput: boolean = true, queryOverride?: string) { async function handleSearchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
if (searching) { if (searching) {
console.log("EventSearch: Already searching, skipping"); console.log("EventSearch: Already searching, skipping");
return; return;
@ -131,7 +185,9 @@
updateSearchState(true); updateSearchState(true);
isResetting = false; isResetting = false;
isUserEditing = false; // Reset user editing flag when search starts isUserEditing = false; // Reset user editing flag when search starts
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim(); const query = (
queryOverride !== undefined ? queryOverride : searchQuery
).trim();
if (!query) { if (!query) {
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
return; return;
@ -140,7 +196,7 @@
const dTag = query.slice(2).trim().toLowerCase(); const dTag = query.slice(2).trim().toLowerCase();
if (dTag) { if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag); console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, 'd'); navigateToSearch(dTag, "d");
updateSearchState(false, false, null, null); updateSearchState(false, false, null, null);
return; return;
} }
@ -148,23 +204,23 @@
if (query.toLowerCase().startsWith("t:")) { if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim(); const searchTerm = query.slice(2).trim();
if (searchTerm) { if (searchTerm) {
await handleSearchBySubscription('t', searchTerm); await handleSearchBySubscription("t", searchTerm);
return; return;
} }
} }
if (query.toLowerCase().startsWith("n:")) { if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim(); const searchTerm = query.slice(2).trim();
if (searchTerm) { if (searchTerm) {
await handleSearchBySubscription('n', searchTerm); await handleSearchBySubscription("n", searchTerm);
return; return;
} }
} }
if (query.includes('@')) { if (query.includes("@")) {
await handleNip05Search(query); await handleNip05Search(query);
return; return;
} }
if (clearInput) { if (clearInput) {
navigateToSearch(query, 'id'); navigateToSearch(query, "id");
// Don't clear searchQuery here - let the effect handle it // Don't clear searchQuery here - let the effect handle it
} }
await handleEventSearch(query); await handleEventSearch(query);
@ -191,7 +247,13 @@
// Debounced effect to handle searchValue changes // Debounced effect to handle searchValue changes
$effect(() => { $effect(() => {
if (!searchValue || searching || isResetting || isProcessingSearch || isWaitingForSearchResult) { if (
!searchValue ||
searching ||
isResetting ||
isProcessingSearch ||
isWaitingForSearchResult
) {
return; return;
} }
@ -205,7 +267,7 @@
currentNevent = neventEncode(foundEvent, standardRelays); currentNevent = neventEncode(foundEvent, standardRelays);
} catch {} } catch {}
try { try {
currentNaddr = getMatchingTags(foundEvent, 'd')[0]?.[1] currentNaddr = getMatchingTags(foundEvent, "d")[0]?.[1]
? naddrEncode(foundEvent, standardRelays) ? naddrEncode(foundEvent, standardRelays)
: null; : null;
} catch {} } catch {}
@ -214,11 +276,30 @@
} catch {} } catch {}
// Debug log for comparison // Debug log for comparison
console.log('[EventSearch effect] searchValue:', searchValue, 'foundEvent.id:', currentEventId, 'foundEvent.pubkey:', foundEvent.pubkey, 'toNpub(pubkey):', currentNpub, 'foundEvent.kind:', foundEvent.kind, 'currentNaddr:', currentNaddr, 'currentNevent:', currentNevent); console.log(
"[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 // Also check if searchValue is an nprofile and matches the current event's pubkey
let currentNprofile = null; let currentNprofile = null;
if (searchValue && searchValue.startsWith('nprofile1') && foundEvent.kind === 0) { if (
searchValue &&
searchValue.startsWith("nprofile1") &&
foundEvent.kind === 0
) {
try { try {
currentNprofile = nprofileEncode(foundEvent.pubkey, standardRelays); currentNprofile = nprofileEncode(foundEvent.pubkey, standardRelays);
} catch {} } catch {}
@ -261,10 +342,15 @@
// 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); handleSearchBySubscription("d", dTagValue);
} }
}); });
@ -276,7 +362,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;
@ -309,7 +400,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;
} }
@ -338,7 +429,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;
} }
@ -353,7 +444,7 @@
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 // Update last processed search value to prevent re-processing
if (searchValue) { if (searchValue) {
@ -379,8 +470,14 @@
} }
// 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);
@ -403,7 +500,7 @@
updatedResult.eventIds, updatedResult.eventIds,
updatedResult.addresses, updatedResult.addresses,
updatedResult.searchType, updatedResult.searchType,
updatedResult.searchTerm updatedResult.searchTerm,
); );
}, },
onSubscriptionCreated: (sub) => { onSubscriptionCreated: (sub) => {
@ -412,9 +509,9 @@
activeSub.stop(); activeSub.stop();
} }
activeSub = sub; activeSub = sub;
}
}, },
currentAbortController.signal },
currentAbortController.signal,
); );
console.log("EventSearch: Search completed:", result); console.log("EventSearch: Search completed:", result);
onSearchResults( onSearchResults(
@ -424,16 +521,19 @@
result.eventIds, result.eventIds,
result.addresses, result.addresses,
result.searchType, result.searchType,
result.searchTerm result.searchTerm,
); );
const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length; const totalCount =
result.events.length +
result.secondOrder.length +
result.tTagEvents.length;
relayStatuses = {}; // Clear relay statuses when search completes relayStatuses = {}; // Clear relay statuses 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;
} }
@ -447,20 +547,25 @@
currentProcessingSearchValue = null; currentProcessingSearchValue = null;
isWaitingForSearchResult = false; isWaitingForSearchResult = false;
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'Search cancelled') { if (error instanceof Error && error.message === "Search cancelled") {
isProcessingSearch = false; isProcessingSearch = false;
currentProcessingSearchValue = null; currentProcessingSearchValue = null;
isWaitingForSearchResult = false; 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}`;
} }
@ -471,7 +576,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;
} }
@ -489,12 +594,12 @@
function handleClear() { function handleClear() {
isResetting = true; isResetting = true;
searchQuery = ''; searchQuery = "";
isUserEditing = false; // Reset user editing flag isUserEditing = false; // Reset user editing flag
resetSearchState(); resetSearchState();
// Clear URL parameters to reset the page // Clear URL parameters to reset the page
goto('', { goto("", {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
noScroll: true, noScroll: true,
@ -534,9 +639,13 @@
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}.`
@ -551,9 +660,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) =>
oninput={() => isUserEditing = true} e.key === "Enter" && handleSearchEvent(true)}
onblur={() => isUserEditing = false} oninput={() => (isUserEditing = true)}
onblur={() => (isUserEditing = false)}
/> />
<Button onclick={() => handleSearchEvent(true)} disabled={loading}> <Button onclick={() => handleSearchEvent(true)} disabled={loading}>
{#if searching} {#if searching}
@ -573,14 +683,20 @@
<!-- 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
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{localError || error} {localError || error}
</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}

220
src/lib/components/LoginMenu.svelte

@ -1,11 +1,20 @@
<script lang='ts'> <script lang="ts">
import { Avatar, Popover } from 'flowbite-svelte'; import { Avatar, Popover } from "flowbite-svelte";
import { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons'; import {
import { userStore, loginWithExtension, loginWithAmber, loginWithNpub, logoutUser } from '$lib/stores/userStore'; UserOutline,
import { get } from 'svelte/store'; ArrowRightToBracketOutline,
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; } from "flowbite-svelte-icons";
import { onMount } from 'svelte'; import {
import { goto } from '$app/navigation'; userStore,
loginWithExtension,
loginWithAmber,
loginWithNpub,
logoutUser,
} from "$lib/stores/userStore";
import { get } from "svelte/store";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// UI state // UI state
let isLoadingExtension: boolean = $state(false); let isLoadingExtension: boolean = $state(false);
@ -16,32 +25,43 @@
let qrCodeDataUrl: string | undefined = $state(undefined); let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state(); let loginButtonRef: HTMLElement | undefined = $state();
let resultTimeout: ReturnType<typeof setTimeout> | null = null; let resultTimeout: ReturnType<typeof setTimeout> | null = null;
let profileAvatarId = 'profile-avatar-btn'; let profileAvatarId = "profile-avatar-btn";
let showAmberFallback = $state(false); let showAmberFallback = $state(false);
let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null; let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null;
onMount(() => { onMount(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1') { if (localStorage.getItem("alexandria/amber/fallback") === "1") {
console.log('LoginMenu: Found fallback flag on mount, showing modal'); console.log("LoginMenu: Found fallback flag on mount, showing modal");
showAmberFallback = true; showAmberFallback = true;
} }
}); });
// Subscribe to userStore // Subscribe to userStore
let user = $state(get(userStore)); let user = $state(get(userStore));
userStore.subscribe(val => { userStore.subscribe((val) => {
user = val; user = val;
// Check for fallback flag when user state changes to signed in // Check for fallback flag when user state changes to signed in
if (val.signedIn && localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) { if (
console.log('LoginMenu: User signed in and fallback flag found, showing modal'); val.signedIn &&
localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"LoginMenu: User signed in and fallback flag found, showing modal",
);
showAmberFallback = true; showAmberFallback = true;
} }
// Set up periodic check when user is signed in // Set up periodic check when user is signed in
if (val.signedIn && !fallbackCheckInterval) { if (val.signedIn && !fallbackCheckInterval) {
fallbackCheckInterval = setInterval(() => { fallbackCheckInterval = setInterval(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) { if (
console.log('LoginMenu: Found fallback flag during periodic check, showing modal'); localStorage.getItem("alexandria/amber/fallback") === "1" &&
!showAmberFallback
) {
console.log(
"LoginMenu: Found fallback flag during periodic check, showing modal",
);
showAmberFallback = true; showAmberFallback = true;
} }
}, 500); // Check every 500ms }, 500); // Check every 500ms
@ -54,18 +74,18 @@
// Generate QR code // Generate QR code
const generateQrCode = async (text: string): Promise<string> => { const generateQrCode = async (text: string): Promise<string> => {
try { try {
const QRCode = await import('qrcode'); const QRCode = await import("qrcode");
return await QRCode.toDataURL(text, { return await QRCode.toDataURL(text, {
width: 256, width: 256,
margin: 2, margin: 2,
color: { color: {
dark: '#000000', dark: "#000000",
light: '#FFFFFF' light: "#FFFFFF",
} },
}); });
} catch (err) { } catch (err) {
console.error('Failed to generate QR code:', err); console.error("Failed to generate QR code:", err);
return ''; return "";
} }
}; };
@ -73,9 +93,9 @@
const copyToClipboard = async (text: string): Promise<void> => { const copyToClipboard = async (text: string): Promise<void> => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
result = '✅ URI copied to clipboard!'; result = "✅ URI copied to clipboard!";
} catch (err) { } catch (err) {
result = '❌ Failed to copy to clipboard'; result = "❌ Failed to copy to clipboard";
} }
}; };
@ -97,7 +117,9 @@
try { try {
await loginWithExtension(); await loginWithExtension();
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage(`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`); showResultMessage(
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally { } finally {
isLoadingExtension = false; isLoadingExtension = false;
} }
@ -108,60 +130,67 @@
isLoadingExtension = false; isLoadingExtension = false;
try { try {
const ndk = new NDK(); const ndk = new NDK();
const relay = 'wss://relay.nsec.app'; const relay = "wss://relay.nsec.app";
const localNsec = localStorage.getItem('amber/nsec') ?? NDKPrivateKeySigner.generate().nsec; const localNsec =
localStorage.getItem("amber/nsec") ??
NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, { const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria', name: "Alexandria",
perms: 'sign_event:1;sign_event:4', perms: "sign_event:1;sign_event:4",
}); });
if (amberSigner.nostrConnectUri) { if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri ?? undefined; nostrConnectUri = amberSigner.nostrConnectUri ?? undefined;
showQrCode = true; showQrCode = true;
qrCodeDataUrl = (await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined; qrCodeDataUrl =
(await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
const user = await amberSigner.blockUntilReady(); const user = await amberSigner.blockUntilReady();
await loginWithAmber(amberSigner, user); await loginWithAmber(amberSigner, user);
showQrCode = false; showQrCode = false;
} else { } else {
throw new Error('Failed to generate Nostr Connect URI'); throw new Error("Failed to generate Nostr Connect URI");
} }
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage(`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`); showResultMessage(
`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally { } finally {
isLoadingAmber = false; isLoadingAmber = false;
} }
}; };
const handleReadOnlyLogin = async () => { const handleReadOnlyLogin = async () => {
const inputNpub = prompt('Enter your npub (public key):'); const inputNpub = prompt("Enter your npub (public key):");
if (inputNpub) { if (inputNpub) {
try { try {
await loginWithNpub(inputNpub); await loginWithNpub(inputNpub);
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage(`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`); showResultMessage(
`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`,
);
} }
} }
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('amber/nsec'); localStorage.removeItem("amber/nsec");
localStorage.removeItem('alexandria/amber/fallback'); localStorage.removeItem("alexandria/amber/fallback");
logoutUser(); logoutUser();
}; };
function handleAmberReconnect() { function handleAmberReconnect() {
showAmberFallback = false; showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback'); localStorage.removeItem("alexandria/amber/fallback");
handleAmberLogin(); handleAmberLogin();
} }
function handleAmberFallbackDismiss() { function handleAmberFallbackDismiss() {
showAmberFallback = false; showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback'); localStorage.removeItem("alexandria/amber/fallback");
} }
function shortenNpub(long: string | undefined) { function shortenNpub(long: string | undefined) {
if (!long) return ''; if (!long) return "";
return long.slice(0, 8) + '…' + long.slice(-4); return long.slice(0, 8) + "…" + long.slice(-4);
} }
function toNullAsUndefined(val: string | null): string | undefined { function toNullAsUndefined(val: string | null): string | undefined {
@ -187,13 +216,13 @@
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy="#login-avatar" triggeredBy="#login-avatar"
class='popover-leather w-[200px]' class="popover-leather w-[200px]"
trigger='click' trigger="click"
> >
<div class='flex flex-col space-y-2'> <div class="flex flex-col space-y-2">
<h3 class='text-lg font-bold mb-2'>Login with...</h3> <h3 class="text-lg font-bold mb-2">Login with...</h3>
<button <button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50' class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleBrowserExtensionLogin} onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber} disabled={isLoadingExtension || isLoadingAmber}
> >
@ -204,7 +233,7 @@
{/if} {/if}
</button> </button>
<button <button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50' class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50"
onclick={handleAmberLogin} onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension} disabled={isLoadingAmber || isLoadingExtension}
> >
@ -215,7 +244,7 @@
{/if} {/if}
</button> </button>
<button <button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' class="btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleReadOnlyLogin} onclick={handleReadOnlyLogin}
> >
📖 npub (read only) 📖 npub (read only)
@ -223,9 +252,14 @@
</div> </div>
</Popover> </Popover>
{#if result} {#if result}
<div class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300"> <div
class="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} {result}
<button class="ml-2 text-gray-500 hover:text-gray-700" onclick={() => result = null}>✖</button> <button
class="ml-2 text-gray-500 hover:text-gray-700"
onclick={() => (result = null)}>✖</button
>
</div> </div>
{/if} {/if}
</div> </div>
@ -233,43 +267,47 @@
<!-- User profile --> <!-- User profile -->
<div class="group"> <div class="group">
<button <button
class='h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer' class="h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer"
id={profileAvatarId} id={profileAvatarId}
type='button' type="button"
aria-label='Open profile menu' aria-label="Open profile menu"
> >
<Avatar <Avatar
rounded rounded
class='h-6 w-6 cursor-pointer' class="h-6 w-6 cursor-pointer"
src={user.profile?.picture || undefined} src={user.profile?.picture || undefined}
alt={user.profile?.displayName || user.profile?.name || 'User'} alt={user.profile?.displayName || user.profile?.name || "User"}
/> />
</button> </button>
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy={`#${profileAvatarId}`} triggeredBy={`#${profileAvatarId}`}
class='popover-leather w-[220px]' class="popover-leather w-[220px]"
trigger='click' 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">
<h3 class='text-lg font-bold'>{user.profile?.displayName || user.profile?.name || (user.npub ? shortenNpub(user.npub) : 'Unknown')}</h3> <h3 class="text-lg font-bold">
{user.profile?.displayName ||
user.profile?.name ||
(user.npub ? shortenNpub(user.npub) : "Unknown")}
</h3>
<ul class="space-y-2 mt-2"> <ul class="space-y-2 mt-2">
<li> <li>
<button <button
class='text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer' class="text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer"
onclick={() => goto(`/events?id=${user.npub}`)} onclick={() => goto(`/events?id=${user.npub}`)}
type='button' type="button"
> >
{user.npub ? shortenNpub(user.npub) : 'Unknown'} {user.npub ? shortenNpub(user.npub) : "Unknown"}
</button> </button>
</li> </li>
<li class="text-xs text-gray-500"> <li class="text-xs text-gray-500">
{#if user.loginMethod === 'extension'} {#if user.loginMethod === "extension"}
Logged in with extension Logged in with extension
{:else if user.loginMethod === 'amber'} {:else if user.loginMethod === "amber"}
Logged in with Amber Logged in with Amber
{:else if user.loginMethod === 'npub'} {:else if user.loginMethod === "npub"}
Logged in with npub Logged in with npub
{:else} {:else}
Unknown login method Unknown login method
@ -277,11 +315,13 @@
</li> </li>
<li> <li>
<button <button
id='sign-out-button' id="sign-out-button"
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleLogout} onclick={handleLogout}
> >
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out <ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button> </button>
</li> </li>
</ul> </ul>
@ -294,14 +334,20 @@
{#if showQrCode && qrCodeDataUrl} {#if showQrCode && qrCodeDataUrl}
<!-- QR Code Modal --> <!-- QR Code Modal -->
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div class="text-center"> <div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Scan with Amber</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">
<p class="text-sm text-gray-600 mb-4">Open Amber on your phone and scan this QR code</p> 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"> <div class="flex justify-center mb-4">
<img <img
src={qrCodeDataUrl || ''} src={qrCodeDataUrl || ""}
alt="Nostr Connect QR Code" alt="Nostr Connect QR Code"
class="border-2 border-gray-300 rounded-lg" class="border-2 border-gray-300 rounded-lg"
width="256" width="256"
@ -309,19 +355,23 @@
/> />
</div> </div>
<div class="space-y-2"> <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> <label
for="nostr-connect-uri-modal"
class="block text-sm font-medium text-gray-700"
>Or copy the URI manually:</label
>
<div class="flex"> <div class="flex">
<input <input
id="nostr-connect-uri-modal" id="nostr-connect-uri-modal"
type="text" type="text"
value={nostrConnectUri || ''} value={nostrConnectUri || ""}
readonly readonly
class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50" class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50"
placeholder="nostrconnect://..." placeholder="nostrconnect://..."
/> />
<button <button
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-r text-sm font-medium transition-colors" 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 || '')} onclick={() => copyToClipboard(nostrConnectUri || "")}
> >
📋 Copy 📋 Copy
</button> </button>
@ -334,7 +384,7 @@
</div> </div>
<button <button
class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors" 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} onclick={() => (showQrCode = false)}
> >
Close Close
</button> </button>
@ -344,13 +394,21 @@
{/if} {/if}
{#if showAmberFallback} {#if showAmberFallback}
<div class="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50"> <div
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg border border-primary-300"> 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"> <div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Amber Session Restored</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">
Amber Session Restored
</h2>
<p class="text-sm text-gray-600 mb-4"> <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/> Your Amber wallet session could not be restored automatically, so
You can still browse and read content, but you'll need to reconnect Amber to publish or comment. 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> </p>
<button <button
class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors" class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"

16
src/lib/components/LoginModal.svelte

@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension } from '$lib/stores/userStore'; import { loginWithExtension } from "$lib/stores/userStore";
import { userStore } from '$lib/stores/userStore'; import { userStore } from "$lib/stores/userStore";
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{ const {
show = false,
onClose = () => {},
onLoginSuccess = () => {},
} = $props<{
show?: boolean; show?: boolean;
onClose?: () => void; onClose?: () => void;
onLoginSuccess?: () => void; onLoginSuccess?: () => void;
}>(); }>();
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(''); let errorMessage = $state<string>("");
let user = $state($userStore); let user = $state($userStore);
let modalOpen = $state(show); let modalOpen = $state(show);
userStore.subscribe(val => user = val); userStore.subscribe((val) => (user = val));
$effect(() => { $effect(() => {
modalOpen = show; modalOpen = show;
@ -36,7 +40,7 @@
async function handleSignInClick() { async function handleSignInClick() {
try { try {
signInFailed = false; signInFailed = false;
errorMessage = ''; errorMessage = "";
await loginWithExtension(); await loginWithExtension();
} catch (e: unknown) { } catch (e: unknown) {

4
src/lib/components/Preview.svelte

@ -20,8 +20,8 @@
sectionHeading, sectionHeading,
} 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'; import { onMount } from "svelte";
// TODO: Fix move between parents. // TODO: Fix move between parents.

74
src/lib/components/ZettelEditor.svelte

@ -1,12 +1,15 @@
<script lang='ts'> <script lang="ts">
import { Textarea, Button } from "flowbite-svelte"; import { Textarea, Button } from "flowbite-svelte";
import { EyeOutline } from "flowbite-svelte-icons"; import { EyeOutline } from "flowbite-svelte-icons";
import { parseAsciiDocSections, type ZettelSection } from '$lib/utils/ZettelParser'; import {
import asciidoctor from 'asciidoctor'; parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import asciidoctor from "asciidoctor";
// Component props // Component props
let { let {
content = '', content = "",
placeholder = `== Note Title placeholder = `== Note Title
:author: {author} // author is optional :author: {author} // author is optional
:tags: tag1, tag2, tag3 // tags are optional :tags: tag1, tag2, tag3 // tags are optional
@ -19,7 +22,7 @@ Note content here...
`, `,
showPreview = false, showPreview = false,
onContentChange = (content: string) => {}, onContentChange = (content: string) => {},
onPreviewToggle = (show: boolean) => {} onPreviewToggle = (show: boolean) => {},
} = $props<{ } = $props<{
content?: string; content?: string;
placeholder?: string; placeholder?: string;
@ -34,8 +37,6 @@ Note content here...
// Parse sections for preview // Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2)); let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Toggle preview panel // Toggle preview panel
function togglePreview() { function togglePreview() {
const newShowPreview = !showPreview; const newShowPreview = !showPreview;
@ -85,11 +86,15 @@ Note content here...
{#if showPreview} {#if showPreview}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4"> <div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4"> <div class="sticky top-4">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"> <h3
class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"
>
AsciiDoc Preview AsciiDoc Preview
</h3> </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"> <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()} {#if !content.trim()}
<div class="text-gray-500 dark:text-gray-400 text-sm"> <div class="text-gray-500 dark:text-gray-400 text-sm">
Start typing to see the preview... Start typing to see the preview...
@ -98,39 +103,55 @@ Note content here...
<div class="prose prose-sm dark:prose-invert max-w-none"> <div class="prose prose-sm dark:prose-invert max-w-none">
{#each parsedSections as section, index} {#each parsedSections as section, index}
<div class="mb-6"> <div class="mb-6">
<div class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"> <div
{@html asciidoctorProcessor.convert(`== ${section.title}\n\n${section.content}`, { class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"
>
{@html asciidoctorProcessor.convert(
`== ${section.title}\n\n${section.content}`,
{
standalone: false, standalone: false,
doctype: 'article', doctype: "article",
attributes: { attributes: {
'showtitle': true, showtitle: true,
'sectids': true sectids: true,
} },
})} },
)}
</div> </div>
{#if index < parsedSections.length - 1} {#if index < parsedSections.length - 1}
<!-- Gray area with tag bubbles above event boundary --> <!-- Gray area with tag bubbles above event boundary -->
<div class="my-4 relative"> <div class="my-4 relative">
<!-- Gray background area --> <!-- Gray background area -->
<div class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"> <div
class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0} {#if section.tags && section.tags.length > 0}
{#each section.tags as tag} {#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"> <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 class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span> <span>{tag[1]}</span>
</div> </div>
{/each} {/each}
{:else} {:else}
<span class="text-gray-500 dark:text-gray-400 text-xs italic">No tags</span> <span
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Event boundary line --> <!-- Event boundary line -->
<div class="border-t-2 border-dashed border-blue-400 relative"> <div
<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"> 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 Event Boundary
</div> </div>
</div> </div>
@ -140,9 +161,14 @@ Note content here...
{/each} {/each}
</div> </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"> <div
<strong>Event Count:</strong> {parsedSections.length} event{parsedSections.length !== 1 ? 's' : ''} class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"
<br> >
<strong>Event Count:</strong>
{parsedSections.length} event{parsedSections.length !== 1
? "s"
: ""}
<br />
<strong>Note:</strong> Currently only the first event will be published. <strong>Note:</strong> Currently only the first event will be published.
</div> </div>
{/if} {/if}

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

@ -63,8 +63,6 @@
<CardActions {event} /> <CardActions {event} />
</div> </div>
{#if image && active} {#if image && active}
<div <div
class="ArticleBoxImage flex col justify-center" class="ArticleBoxImage flex col justify-center"

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

@ -5,7 +5,10 @@
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 {
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 +44,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;
}); });
} }
@ -89,9 +94,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}

26
src/lib/components/publications/Publication.svelte

@ -30,7 +30,9 @@
indexEvent: NDKEvent; indexEvent: NDKEvent;
}>(); }>();
const publicationTree = getContext("publicationTree") as SveltePublicationTree; const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType; const toc = getContext("toc") as TocType;
// #region Loading // #region Loading
@ -191,19 +193,23 @@
</script> </script>
<!-- Table of contents --> <!-- Table of contents -->
{#if publicationType !== 'blog' || !isLeaf} {#if publicationType !== "blog" || !isLeaf}
{#if $publicationColumnVisibility.toc} {#if $publicationColumnVisibility.toc}
<Sidebar <Sidebar
activeUrl={`#${activeAddress ?? ''}`} 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' 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' 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' 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' /> <CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents <TableOfContents
rootAddress={rootAddress} {rootAddress}
depth={2} depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)} onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
/> />
</Sidebar> </Sidebar>
{/if} {/if}
@ -251,7 +257,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 ${isInnerActive() ? 'discreet' : ''}`} class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${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"

29
src/lib/components/publications/PublicationFeed.svelte

@ -57,7 +57,9 @@
// 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;
@ -133,9 +135,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;
} }
@ -190,10 +194,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;
@ -252,7 +256,9 @@
// Watch for changes in include all relays setting // Watch for changes in include all relays setting
$effect(() => { $effect(() => {
console.log(`[PublicationFeed] Include all relays setting changed to: ${includeAllRelays}`); console.log(
`[PublicationFeed] Include all relays setting changed to: ${includeAllRelays}`,
);
// Clear cache when relay configuration changes // Clear cache when relay configuration changes
indexEventCache.clear(); indexEventCache.clear();
searchCache.clear(); searchCache.clear();
@ -270,12 +276,17 @@
<!-- Include all relays checkbox --> <!-- Include all relays checkbox -->
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<Checkbox bind:checked={includeAllRelays} class="mr-2" /> <Checkbox bind:checked={includeAllRelays} class="mr-2" />
<label for="include-all-relays" class="text-sm text-gray-700 dark:text-gray-300"> <label
for="include-all-relays"
class="text-sm text-gray-700 dark:text-gray-300"
>
Include all relays (slower but more comprehensive search) Include all relays (slower but more comprehensive search)
</label> </label>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full"> <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" />

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

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from '$lib/utils'; import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from '../../consts'; import { standardRelays } from "../../consts";
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
@ -14,36 +14,43 @@
}); });
const href = $derived.by(() => { const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1]; const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) { if (d != null) {
return `publication?d=${d}`; return `publication?d=${d}`;
} else { } else {
return `publication?id=${naddrEncode(event, relays)}`; return `publication?id=${naddrEncode(event, relays)}`;
} }
} });
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); );
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1",
);
let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
console.log("PublicationHeader event:", event); console.log("PublicationHeader event:", event);
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'> <Card class="ArticleBox card-leather max-w-md flex flex-row space-x-2">
{#if image} {#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"> <div
class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"
>
<Img src={image} class="rounded w-full h-full object-cover" /> <Img src={image} class="rounded w-full h-full object-cover" />
</div> </div>
{/if} {/if}
<div class='col flex flex-row flex-grow space-x-4'> <div class="col flex flex-row flex-grow space-x-4">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<a href="/{href}" class='flex flex-col space-y-2'> <a href="/{href}" class="flex flex-col space-y-2">
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class='text-base font-normal'> <h3 class="text-base font-normal">
by by
{#if authorPubkey != null} {#if authorPubkey != null}
{@render userBadge(authorPubkey, author)} {@render userBadge(authorPubkey, author)}
@ -51,13 +58,13 @@
{author} {author}
{/if} {/if}
</h3> </h3>
{#if version != '1'} {#if version != "1"}
<h3 class='text-base font-thin'>version: {version}</h3> <h3 class="text-base font-thin">version: {version}</h3>
{/if} {/if}
</a> </a>
</div> </div>
<div class="flex flex-col justify-start items-center"> <div class="flex flex-col justify-start items-center">
<CardActions event={event} /> <CardActions {event} />
</div> </div>
</div> </div>
</Card> </Card>

91
src/lib/components/publications/PublicationSection.svelte

@ -1,11 +1,14 @@
<script lang='ts'> <script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor"; import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
let { let {
@ -14,32 +17,38 @@
leaves, leaves,
ref, ref,
}: { }: {
address: string, address: string;
rootAddress: string, rootAddress: string;
leaves: Array<NDKEvent | null>, leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void, ref: (ref: HTMLElement) => void;
} = $props(); } = $props();
const publicationTree: SveltePublicationTree = getContext('publicationTree'); const publicationTree: SveltePublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext('asciidoctor'); const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => let leafEvent: Promise<NDKEvent | null> = $derived.by(
await publicationTree.getEvent(address)); async () => await publicationTree.getEvent(address),
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => let rootEvent: Promise<NDKEvent | null> = $derived.by(
await publicationTree.getEvent(rootAddress)); async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(async () => let publicationType: Promise<string | undefined> = $derived.by(
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
await publicationTree.getHierarchy(address)); async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(async () => let leafTitle: Promise<string | undefined> = $derived.by(
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
let leafContent: Promise<string | Document> = $derived.by(async () => let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? '')); asciidoctor.convert((await leafEvent)?.content ?? ""),
);
let previousLeafEvent: NDKEvent | null = $derived.by(() => { let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number; let index: number;
@ -47,7 +56,7 @@
let decrement = 1; let decrement = 1;
do { do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) { if (index === 0) {
return null; return null;
} }
@ -57,15 +66,20 @@
return event; return event;
}); });
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) { if (!previousLeafEvent) {
return null; return null;
} }
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
}); },
);
let divergingBranches = $derived.by(async () => { let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([
leafHierarchy,
previousLeafHierarchy,
]);
const branches: [NDKEvent, number][] = []; const branches: [NDKEvent, number][] = [];
@ -76,13 +90,17 @@
return branches; return branches;
} }
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node. // Find the first diverging node.
let divergingIndex = 0; let divergingIndex = 0;
while ( while (
divergingIndex < minLength && divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) { ) {
divergingIndex++; divergingIndex++;
} }
@ -106,17 +124,28 @@
}); });
</script> </script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'> <section
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} id={address}
<TextPlaceholder size='xxl' /> bind:this={sectionRef}
class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)} {@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each} {/each}
{#if leafTitle} {#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1} {@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)} {@render sectionHeading(leafTitle, leafDepth)}
{/if} {/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} {@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
{/await} {/await}
</section> </section>

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

@ -1,22 +1,23 @@
<script lang='ts'> <script lang="ts">
import { import {
TableOfContents, TableOfContents,
type TocEntry type TocEntry,
} from '$lib/components/publications/table_of_contents.svelte'; } from "$lib/components/publications/table_of_contents.svelte";
import { getContext } from 'svelte'; import { getContext } from "svelte";
import { SidebarDropdownWrapper, SidebarGroup, SidebarItem } from 'flowbite-svelte'; import {
import Self from './TableOfContents.svelte'; SidebarDropdownWrapper,
SidebarGroup,
SidebarItem,
} from "flowbite-svelte";
import Self from "./TableOfContents.svelte";
let { let { depth, onSectionFocused } = $props<{
depth,
onSectionFocused,
} = $props<{
rootAddress: string; rootAddress: string;
depth: number; depth: number;
onSectionFocused?: (address: string) => void; onSectionFocused?: (address: string) => void;
}>(); }>();
let toc = getContext('toc') as TableOfContents; let toc = getContext("toc") as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => { let entries = $derived.by<TocEntry[]>(() => {
const newEntries = []; const newEntries = [];
@ -53,24 +54,17 @@
<SidebarItem <SidebarItem
label={entry.title} label={entry.title}
href={`#${address}`} href={`#${address}`}
spanClass='px-2 text-ellipsis' spanClass="px-2 text-ellipsis"
onclick={() => onSectionFocused?.(address)} onclick={() => onSectionFocused?.(address)}
/> />
{:else} {:else}
{@const childDepth = depth + 1} {@const childDepth = depth + 1}
<SidebarDropdownWrapper <SidebarDropdownWrapper
label={entry.title} 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' 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"
bind:isOpen={ bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
() => expanded,
(open) => setEntryExpanded(address, open)
}
> >
<Self <Self rootAddress={address} depth={childDepth} {onSectionFocused} />
rootAddress={address}
depth={childDepth}
onSectionFocused={onSectionFocused}
/>
</SidebarDropdownWrapper> </SidebarDropdownWrapper>
{/if} {/if}
{/each} {/each}

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

@ -91,7 +91,7 @@ export class SveltePublicationTree {
for (const observer of this.#nodeResolvedObservers) { for (const observer of this.#nodeResolvedObservers) {
observer(address); observer(address);
} }
} };
/** /**
* Observer function that is invoked whenever the bookmark is moved on the publication tree. * Observer function that is invoked whenever the bookmark is moved on the publication tree.
@ -105,7 +105,7 @@ export class SveltePublicationTree {
for (const observer of this.#bookmarkMovedObservers) { for (const observer of this.#bookmarkMovedObservers) {
observer(address); observer(address);
} }
} };
// #endregion // #endregion
} }

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

@ -1,7 +1,7 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts'; import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts";
import type { NDKEvent } from '../../utils/nostrUtils.ts'; import type { NDKEvent } from "../../utils/nostrUtils.ts";
import { indexKind } from '../../consts.ts'; import { indexKind } from "../../consts.ts";
export interface TocEntry { export interface TocEntry {
address: string; address: string;
@ -37,7 +37,11 @@ export class TableOfContents {
* @param publicationTree The SveltePublicationTree instance. * @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation. * @param pagePathname The current page pathname for href generation.
*/ */
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) { constructor(
rootAddress: string,
publicationTree: SveltePublicationTree,
pagePathname: string,
) {
this.#publicationTree = publicationTree; this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname; this.#pagePathname = pagePathname;
this.#init(rootAddress); this.#init(rootAddress);
@ -71,10 +75,7 @@ export class TableOfContents {
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or * 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. * from a kind `30023` event with Markdown content.
*/ */
buildTocFromDocument( buildTocFromDocument(parentElement: HTMLElement, parentEntry: TocEntry) {
parentElement: HTMLElement,
parentEntry: TocEntry,
) {
parentElement parentElement
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`) .querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`)
.forEach((header) => { .forEach((header) => {
@ -158,8 +159,8 @@ export class TableOfContents {
// Handle any other nodes that have already been resolved in parallel. // Handle any other nodes that have already been resolved in parallel.
await Promise.all( await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) => Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address) this.#buildTocEntryFromResolvedNode(address),
) ),
); );
// Set up an observer to handle progressive resolution of the publication tree. // Set up an observer to handle progressive resolution of the publication tree.
@ -171,10 +172,10 @@ export class TableOfContents {
#getTitle(event: NDKEvent | null): string { #getTitle(event: NDKEvent | null): string {
if (!event) { if (!event) {
// TODO: What do we want to return in this case? // TODO: What do we want to return in this case?
return '[untitled]'; return "[untitled]";
} }
const titleTag = event.getMatchingTags?.('title')?.[0]?.[1]; const titleTag = event.getMatchingTags?.("title")?.[0]?.[1];
return titleTag || event.tagAddress() || '[untitled]'; return titleTag || event.tagAddress() || "[untitled]";
} }
async #buildTocEntry(address: string): Promise<TocEntry> { async #buildTocEntry(address: string): Promise<TocEntry> {
@ -192,7 +193,9 @@ export class TableOfContents {
return; return;
} }
const childAddresses = await this.#publicationTree.getChildAddresses(entry.address); const childAddresses = await this.#publicationTree.getChildAddresses(
entry.address,
);
for (const childAddress of childAddresses) { for (const childAddress of childAddresses) {
if (!childAddress) { if (!childAddress) {
continue; continue;
@ -201,7 +204,7 @@ export class TableOfContents {
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary // 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 // here so that we can determine whether to render an entry as a leaf before it is fully
// resolved. // resolved.
if (childAddress.split(':')[0] !== indexKind.toString()) { if (childAddress.split(":")[0] !== indexKind.toString()) {
this.leaves.add(childAddress); this.leaves.add(childAddress);
} }
@ -219,7 +222,7 @@ export class TableOfContents {
await this.#matchChildrenToTagOrder(entry); await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true; entry.childrenResolved = true;
} };
const event = await this.#publicationTree.getEvent(address); const event = await this.#publicationTree.getEvent(address);
if (!event) { if (!event) {
@ -262,7 +265,7 @@ export class TableOfContents {
async #matchChildrenToTagOrder(entry: TocEntry) { async #matchChildrenToTagOrder(entry: TocEntry) {
const parentEvent = await this.#publicationTree.getEvent(entry.address); const parentEvent = await this.#publicationTree.getEvent(entry.address);
if (parentEvent?.kind === indexKind) { if (parentEvent?.kind === indexKind) {
const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]); const tagOrder = parentEvent.getMatchingTags("a").map((tag) => tag[1]);
const addressToOrdinal = new Map<string, number>(); const addressToOrdinal = new Map<string, number>();
// Build map of addresses to their ordinals from tag order // Build map of addresses to their ordinals from tag order
@ -271,8 +274,10 @@ export class TableOfContents {
}); });
entry.children.sort((a, b) => { entry.children.sort((a, b) => {
const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER; const aOrdinal =
const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER; addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal =
addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal; return aOrdinal - bOrdinal;
}); });
} }

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

@ -1,35 +1,41 @@
<script lang="ts"> <script lang="ts">
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons"; import {
BookOutline,
CaretLeftOutline,
CloseOutline,
GlobeOutline,
} from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
let { let { publicationType, indexEvent } = $props<{
publicationType, rootId: any;
indexEvent publicationType: string;
} = $props<{ indexEvent: NDKEvent;
rootId: any,
publicationType: string,
indexEvent: NDKEvent
}>(); }>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]); let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags('author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null); indexEvent.getMatchingTags("author")[0]?.[1] ?? "unknown",
);
let pubkey: string = $derived(
indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
);
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let lastScrollY = $state(0); let lastScrollY = $state(0);
let isVisible = $state(true); let isVisible = $state(true);
// Function to toggle column visibility // Function to toggle column visibility
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') { function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const newValue = !current[column]; const newValue = !current[column];
const updated = { ...current, [column]: newValue }; const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === 'blog' && newValue) { if (window.innerWidth < 1400 && column === "blog" && newValue) {
updated.discussion = false; updated.discussion = false;
} }
@ -39,11 +45,13 @@
function shouldShowBack() { function shouldShowBack() {
const vis = $publicationColumnVisibility; const vis = $publicationColumnVisibility;
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]); return ["discussion", "toc", "inner"].some(
(key) => vis[key as keyof typeof vis],
);
} }
function backToMain() { function backToMain() {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const updated = { ...current }; const updated = { ...current };
// if current is 'inner', just go back to blog // if current is 'inner', just go back to blog
@ -56,7 +64,7 @@
updated.discussion = false; updated.discussion = false;
updated.toc = false; updated.toc = false;
if (publicationType === 'blog') { if (publicationType === "blog") {
updated.inner = true; updated.inner = true;
updated.blog = false; updated.blog = false;
} else { } else {
@ -68,13 +76,13 @@
} }
function backToBlog() { function backToBlog() {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const updated = { ...current }; const updated = { ...current };
updated.inner = false; updated.inner = false;
updated.discussion = false; updated.discussion = false;
updated.blog = true; updated.blog = true;
return updated; return updated;
}) });
} }
function handleScroll() { function handleScroll() {
@ -96,42 +104,50 @@
let unsubscribe: () => void; let unsubscribe: () => void;
onMount(() => { onMount(() => {
window.addEventListener('scroll', handleScroll); window.addEventListener("scroll", handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => { unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes isVisible = true; // show navbar when store changes
}); });
}); });
onDestroy(() => { onDestroy(() => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener("scroll", handleScroll);
unsubscribe(); unsubscribe();
}); });
</script> </script>
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}"> <nav
class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
? 'translate-y-0'
: '-translate-y-full'}"
>
<div class="mx-auto flex space-x-2 container"> <div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()} {#if shouldShowBack()}
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}> <Button
class="btn-leather !w-auto sm:hidden"
outline={true}
onclick={backToMain}
>
<CaretLeftOutline class="!fill-none inline mr-1" /> <CaretLeftOutline class="!fill-none inline mr-1" />
<span 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 ? 'active' : ''}`} class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? "active" : ""}`}
outline={true} outline={true}
onclick={() => toggleColumn('blog')} onclick={() => toggleColumn("blog")}
> >
<BookOutline class="!fill-none inline mr-1" /> <BookOutline class="!fill-none inline mr-1" />
<span 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 ${$publicationColumnVisibility.toc ? 'active' : ''}`} 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" /> <BookOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Table of Contents</span> <span class="hidden sm:inline">Table of Contents</span>
@ -144,18 +160,28 @@
<b class="text-nowrap">{title}</b> <b class="text-nowrap">{title}</b>
</p> </p>
<p> <p>
<span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span> <span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span
>
</p> </p>
</div> </div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner} {#if $publicationColumnVisibility.inner}
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}> <Button
class="btn-leather !w-auto hidden sm:flex"
outline={true}
onclick={backToBlog}
>
<CloseOutline class="!fill-none inline mr-1" /> <CloseOutline class="!fill-none inline mr-1" />
<span 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}
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} > <Button
class="btn-leather !hidden sm:flex !w-auto"
outline={true}
onclick={() => toggleColumn("discussion")}
>
<GlobeOutline class="!fill-none inline mr-1" /> <GlobeOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Discussion</span> <span class="hidden sm:inline">Discussion</span>
</Button> </Button>

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

@ -20,20 +20,42 @@
// Subscribe to userStore // Subscribe to userStore
let user = $state($userStore); let user = $state($userStore);
userStore.subscribe(val => user = val); userStore.subscribe((val) => (user = val));
// Derive metadata from event // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? ''); let title = $derived(
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? ''); event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null); );
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? ''); let summary = $derived(
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "",
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? ''); );
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null); let image = $derived(
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "image")?.[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 author = $derived(
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "",
);
let originalAuthor = $derived(
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null,
);
let version = $derived(
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "",
);
let source = $derived(
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null,
);
let type = $derived(
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null,
);
let language = $derived(
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null,
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state // UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
@ -100,7 +122,7 @@
* 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>

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

@ -17,13 +17,19 @@
let lastEventId = $state<string | null>(null); let lastEventId = $state<string | null>(null);
async function loadContainingIndexes() { async function loadContainingIndexes() {
console.log("[ContainingIndexes] Loading containing indexes for event:", event.id); console.log(
"[ContainingIndexes] Loading containing indexes for event:",
event.id,
);
loading = true; loading = true;
error = null; error = null;
try { try {
containingIndexes = await findContainingIndexEvents(event); containingIndexes = await findContainingIndexEvents(event);
console.log("[ContainingIndexes] Found containing indexes:", containingIndexes.length); console.log(
"[ContainingIndexes] Found containing indexes:",
containingIndexes.length,
);
} catch (err) { } catch (err) {
error = error =
err instanceof Error err instanceof Error

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

@ -4,37 +4,54 @@
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 { goto } from "$app/navigation";
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props(); let { event, isModal = false } = $props();
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown", getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
); );
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1'); let version: string = $derived(
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null); getMatchingTags(event, "version")[0]?.[1] ?? "1",
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null); );
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null); let image: string = $derived(getMatchingTags(event, "image")[0]?.[1] ?? null);
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null); let summary: string = $derived(
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null); getMatchingTags(event, "summary")[0]?.[1] ?? null,
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null); );
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null); let type: string = $derived(getMatchingTags(event, "type")[0]?.[1] ?? null);
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1])); let language: string = $derived(getMatchingTags(event, "l")[0]?.[1] ?? null);
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null); let source: string = $derived(
getMatchingTags(event, "source")[0]?.[1] ?? null,
);
let publisher: string = $derived(
getMatchingTags(event, "published_by")[0]?.[1] ?? null,
);
let identifier: string = $derived(
getMatchingTags(event, "i")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(
getMatchingTags(event, "t").map((tag) => tag[1]),
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind); let kind = $derived(event.kind);
let authorTag: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? ''); let authorTag: string = $derived(
let pTag: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? ''); getMatchingTags(event, "author")[0]?.[1] ?? "",
);
let pTag: string = $derived(getMatchingTags(event, "p")[0]?.[1] ?? "");
let originalAuthor: string = $derived( let originalAuthor: string = $derived(
getMatchingTags(event, "p")[0]?.[1] ?? null, getMatchingTags(event, "p")[0]?.[1] ?? null,
); );
function isValidNostrPubkey(str: string): boolean { function isValidNostrPubkey(str: string): boolean {
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63); return (
/^[a-f0-9]{64}$/i.test(str) ||
(str.startsWith("npub1") && str.length >= 59 && str.length <= 63)
);
} }
</script> </script>
@ -42,7 +59,9 @@
{#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 --> <!-- Index author badge -->
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P> <P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P
>
<CardActions {event}></CardActions> <CardActions {event}></CardActions>
</div> </div>
{/if} {/if}
@ -63,11 +82,11 @@
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if authorTag && pTag && isValidNostrPubkey(pTag)} {#if authorTag && pTag && isValidNostrPubkey(pTag)}
{authorTag} {@render userBadge(pTag, '')} {authorTag} {@render userBadge(pTag, "")}
{:else if authorTag} {:else if authorTag}
{authorTag} {authorTag}
{:else if pTag && isValidNostrPubkey(pTag)} {:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, '')} {@render userBadge(pTag, "")}
{:else if originalAuthor !== null} {:else if originalAuthor !== null}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author)}
{:else} {:else}
@ -111,7 +130,7 @@
{:else} {:else}
<span>Author:</span> <span>Author:</span>
{/if} {/if}
{@render userBadge(event.pubkey, '')} {@render userBadge(event.pubkey, "")}
</h4> </h4>
</div> </div>

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

@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logoutUser } from '$lib/stores/userStore'; import { logoutUser } from "$lib/stores/userStore";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; import {
ArrowRightToBracketOutline,
UserOutline,
FileSearchOutline,
} 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 { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();
@ -23,8 +27,7 @@
const user = ndk.getUser({ pubkey: pubkey ?? undefined }); const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub; npub = user.npub;
user.fetchProfile() user.fetchProfile().then((userProfile: NDKUserProfile | null) => {
.then((userProfile: NDKUserProfile | null) => {
profile = userProfile; profile = userProfile;
}); });
}); });

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

@ -56,10 +56,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");

6
src/lib/consts.ts

@ -2,7 +2,11 @@ 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 communityRelay = "wss://theforest.nostr1.com";
export const profileRelays = ["wss://profiles.nostr1.com", "wss://aggr.nostr.land", "wss://relay.noswhere.com"]; export const profileRelays = [
"wss://profiles.nostr1.com",
"wss://aggr.nostr.land",
"wss://relay.noswhere.com",
];
export const standardRelays = [ export const standardRelays = [
"wss://thecitadel.nostr1.com", "wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",

129
src/lib/data_structures/publication_tree.ts

@ -1,6 +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";
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -77,7 +77,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}; };
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>(); this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root))); this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#events = new Map<string, NDKEvent>(); this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent); this.#events.set(rootAddress, rootEvent);
@ -100,7 +103,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -131,7 +134,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -163,13 +166,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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(`[PublicationTree] Node with address ${address} not found.`); throw new Error(
`[PublicationTree] Node with address ${address} not found.`,
);
} }
return Promise.all( return Promise.all(
node.children?.map(async child => node.children?.map(
(await child.value())?.address ?? null async (child) => (await child.value())?.address ?? null,
) ?? [] ) ?? [],
); );
} }
/** /**
@ -181,7 +186,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> { async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value(); let node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`[PublicationTree] Node with address ${address} not found.`); throw new Error(
`[PublicationTree] Node with address ${address} not found.`,
);
} }
const hierarchy: NDKEvent[] = [this.#events.get(address)!]; const hierarchy: NDKEvent[] = [this.#events.get(address)!];
@ -200,9 +207,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
setBookmark(address: string) { setBookmark(address: string) {
this.#bookmark = address; this.#bookmark = address;
this.#cursor.tryMoveTo(address).then(success => { this.#cursor.tryMoveTo(address).then((success) => {
if (success) { if (success) {
this.#bookmarkMovedObservers.forEach(observer => observer(address)); this.#bookmarkMovedObservers.forEach((observer) => observer(address));
} }
}); });
} }
@ -227,7 +234,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #region Iteration Cursor // #region Iteration Cursor
#cursor = new class { #cursor = new (class {
target: PublicationTreeNode | null | undefined; target: PublicationTreeNode | null | undefined;
#tree: PublicationTree; #tree: PublicationTree;
@ -239,7 +246,9 @@ 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();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
?.value();
} else { } else {
this.target = await this.#tree.#nodes.get(address)?.value(); this.target = await this.#tree.#nodes.get(address)?.value();
} }
@ -253,7 +262,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> { async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("[Publication Tree Cursor] Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -271,7 +282,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToLastChild(): Promise<boolean> { async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("[Publication Tree Cursor] Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -289,7 +302,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToNextSibling(): Promise<boolean> { async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("[Publication Tree Cursor] Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -300,7 +315,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentIndex = await siblings.findIndexAsync( const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
); );
if (currentIndex === -1) { if (currentIndex === -1) {
@ -317,7 +333,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> { async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) { if (!this.target) {
console.debug("[Publication Tree Cursor] Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -328,7 +346,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentIndex = await siblings.findIndexAsync( const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
); );
if (currentIndex === -1) { if (currentIndex === -1) {
@ -345,7 +364,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
tryMoveToParent(): boolean { tryMoveToParent(): boolean {
if (!this.target) { if (!this.target) {
console.debug("[Publication Tree Cursor] Target node is null or undefined."); console.debug(
"[Publication Tree Cursor] Target node is null or undefined.",
);
return false; return false;
} }
@ -357,7 +378,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent; this.target = parent;
return true; return true;
} }
}(this); })(this);
// #endregion // #endregion
@ -375,7 +396,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* @returns The next event in the tree, or null if the tree is empty. * @returns The next event in the tree, or null if the tree is empty.
*/ */
async next( async next(
mode: TreeTraversalMode = TreeTraversalMode.Leaves mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> { ): 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)) {
@ -399,7 +420,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* @returns The previous event in the tree, or null if the tree is empty. * @returns The previous event in the tree, or null if the tree is empty.
*/ */
async previous( async previous(
mode: TreeTraversalMode = TreeTraversalMode.Leaves mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> { ): 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)) {
@ -416,7 +437,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
} }
async #yieldEventAtCursor(done: boolean): Promise<IteratorResult<NDKEvent | null>> { async #yieldEventAtCursor(
done: boolean,
): Promise<IteratorResult<NDKEvent | null>> {
const value = (await this.getEvent(this.#cursor.target!.address)) ?? null; const value = (await this.getEvent(this.#cursor.target!.address)) ?? null;
return { done, value }; return { done, value };
} }
@ -431,12 +454,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 * https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
*/ */
async #walkLeaves( async #walkLeaves(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> { ): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) ? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor); : this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) ? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor); : this.#cursor.tryMoveToLastChild.bind(this.#cursor);
@ -472,12 +497,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304 * https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304
*/ */
async #preorderWalkAll( async #preorderWalkAll(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> { ): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) ? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor); : this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) ? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor); : this.#cursor.tryMoveToLastChild.bind(this.#cursor);
@ -517,17 +544,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address]; const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root; let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!; let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
while (stack.length > 0) { while (stack.length > 0) {
const currentAddress = stack.pop(); const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value(); currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) { if (!currentNode) {
throw new Error(`[PublicationTree] Node with address ${currentAddress} not found.`); throw new Error(
`[PublicationTree] Node with address ${currentAddress} not found.`,
);
} }
currentEvent = this.#events.get(currentAddress!); currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) { if (!currentEvent) {
throw new Error(`[PublicationTree] Event with address ${currentAddress} not found.`); throw new Error(
`[PublicationTree] Event with address ${currentAddress} not found.`,
);
} }
// Stop immediately if the target of the search is found. // Stop immediately if the target of the search is found.
@ -536,8 +569,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentChildAddresses = currentEvent.tags const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a') .filter((tag) => tag[0] === "a")
.map(tag => tag[1]); .map((tag) => tag[1]);
// If the current event has no children, it is a leaf. // If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) { if (currentChildAddresses.length === 0) {
@ -569,11 +602,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode)); const lazyNode = new Lazy<PublicationTreeNode>(() =>
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)); this.#nodeAddedObservers.forEach((observer) => observer(address));
} }
/** /**
@ -587,18 +622,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
async #resolveNode( async #resolveNode(
address: string, address: string,
parentNode: PublicationTreeNode parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> { ): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':'); const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({ const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
'#d': [dTag], "#d": [dTag],
}); });
if (!event) { if (!event) {
console.debug( console.debug(
`[PublicationTree] Event with address ${address} not found.` `[PublicationTree] Event with address ${address} not found.`,
); );
return { return {
@ -612,7 +647,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event); this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
type: this.#getNodeType(event), type: this.#getNodeType(event),
@ -626,13 +663,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.addEventByAddress(address, event); this.addEventByAddress(address, event);
} }
this.#nodeResolvedObservers.forEach(observer => observer(address)); this.#nodeResolvedObservers.forEach((observer) => observer(address));
return node; return node;
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
return PublicationTreeNodeType.Branch; return PublicationTreeNodeType.Branch;
} }

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

@ -172,7 +172,12 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
debug("Processing tags for event", { debug("Processing tags for event", {
eventId: event.id, eventId: event.id,
tagCount: tags.length, tagCount: tags.length,
tagType: tags.length > 0 ? (getMatchingTags(event, "a").length > 0 ? "a" : "e") : "none" tagType:
tags.length > 0
? getMatchingTags(event, "a").length > 0
? "a"
: "e"
: "none",
}); });
tags.forEach((tag) => { tags.forEach((tag) => {

53
src/lib/ndk.ts

@ -5,12 +5,18 @@ import NDK, {
NDKRelaySet, NDKRelaySet,
NDKUser, NDKUser,
NDKEvent, NDKEvent,
} 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 { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts'; import {
import { feedType } from './stores'; fallbackRelays,
import { userStore } from './stores/userStore'; FeedType,
import { userPubkey } from '$lib/stores/authStore.Svelte'; loginStorageKey,
standardRelays,
anonymousRelays,
} from "./consts";
import { feedType } from "./stores";
import { userStore } from "./stores/userStore";
import { userPubkey } from "$lib/stores/authStore.Svelte";
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false); export const ndkSignedIn = writable(false);
@ -432,9 +438,9 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
// Filter out problematic relays that are known to cause connection issues // Filter out problematic relays that are known to cause connection issues
const filterProblematicRelays = (relays: string[]) => { const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => { return relays.filter((relay) => {
// Filter out gitcitadel.nostr1.com which is causing connection issues // Filter out gitcitadel.nostr1.com which is causing connection issues
if (relay.includes('gitcitadel.nostr1.com')) { if (relay.includes("gitcitadel.nostr1.com")) {
console.warn(`[NDK.ts] Filtering out problematic relay: ${relay}`); console.warn(`[NDK.ts] Filtering out problematic relay: ${relay}`);
return false; return false;
} }
@ -444,20 +450,22 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays && user.signedIn return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet( ? new NDKRelaySet(
new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay( new Set(
relay, filterProblematicRelays(user.relays.inbox).map(
NDKRelayAuthPolicies.signIn({ ndk }), (relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk, ndk,
))),
ndk
) )
: new NDKRelaySet( : new NDKRelaySet(
new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay( new Set(
relay, filterProblematicRelays(standardRelays).map(
NDKRelayAuthPolicies.signIn({ ndk }), (relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk, ndk,
))),
ndk
); );
} }
@ -492,7 +500,8 @@ export function initNdk(): NDK {
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
// Connect with better error handling // Connect with better error handling
ndk.connect() ndk
.connect()
.then(() => { .then(() => {
console.debug("[NDK.ts] NDK connected successfully"); console.debug("[NDK.ts] NDK connected successfully");
}) })
@ -604,8 +613,10 @@ export async function getUserPreferredRelays(
// Filter out problematic relays // Filter out problematic relays
const filterProblematicRelay = (url: string): boolean => { const filterProblematicRelay = (url: string): boolean => {
if (url.includes('gitcitadel.nostr1.com')) { if (url.includes("gitcitadel.nostr1.com")) {
console.warn(`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`); console.warn(
`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`,
);
return false; return false;
} }
return true; return true;

48
src/lib/services/publisher.ts

@ -1,9 +1,12 @@
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { getMimeTags } from '$lib/utils/mime'; import { getMimeTags } from "$lib/utils/mime";
import { parseAsciiDocSections, type ZettelSection } from '$lib/utils/ZettelParser'; import {
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; parseAsciiDocSections,
import { nip19 } from 'nostr-tools'; type ZettelSection,
} from "$lib/utils/ZettelParser";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface PublishResult { export interface PublishResult {
success: boolean; success: boolean;
@ -23,11 +26,13 @@ export interface PublishOptions {
* @param options - Publishing options * @param options - Publishing options
* @returns Promise resolving to publish result * @returns Promise resolving to publish result
*/ */
export async function publishZettel(options: PublishOptions): Promise<PublishResult> { export async function publishZettel(
options: PublishOptions,
): Promise<PublishResult> {
const { content, kind = 30041, onSuccess, onError } = options; const { content, kind = 30041, onSuccess, onError } = options;
if (!content.trim()) { if (!content.trim()) {
const error = 'Please enter some content'; const error = "Please enter some content";
onError?.(error); onError?.(error);
return { success: false, error }; return { success: false, error };
} }
@ -36,7 +41,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk?.activeUser) { if (!ndk?.activeUser) {
const error = 'Please log in first'; const error = "Please log in first";
onError?.(error); onError?.(error);
return { success: false, error }; return { success: false, error };
} }
@ -46,7 +51,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
const sections = parseAsciiDocSections(content, 2); const sections = parseAsciiDocSections(content, 2);
if (sections.length === 0) { if (sections.length === 0) {
throw new Error('No valid sections found in content'); throw new Error("No valid sections found in content");
} }
// For now, publish only the first section // For now, publish only the first section
@ -55,17 +60,11 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
const cleanContent = firstSection.content; const cleanContent = firstSection.content;
const sectionTags = firstSection.tags || []; const sectionTags = firstSection.tags || [];
// Generate d-tag and create event // Generate d-tag and create event
const dTag = generateDTag(title); const dTag = generateDTag(title);
const [mTag, MTag] = getMimeTags(kind); const [mTag, MTag] = getMimeTags(kind);
const tags: string[][] = [ const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]];
['d', dTag],
mTag,
MTag,
['title', title]
];
if (sectionTags) { if (sectionTags) {
tags.push(...sectionTags); tags.push(...sectionTags);
} }
@ -81,10 +80,12 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
await ndkEvent.sign(); await ndkEvent.sign();
// Publish to relays // Publish to relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(r => r.url); const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) { if (allRelayUrls.length === 0) {
throw new Error('No relays available in NDK pool'); throw new Error("No relays available in NDK pool");
} }
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk); const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
@ -96,10 +97,11 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
return result; return result;
} else { } else {
// Try fallback publishing logic here... // Try fallback publishing logic here...
throw new Error('Failed to publish to any relays'); throw new Error("Failed to publish to any relays");
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage); onError?.(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
@ -108,6 +110,6 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
function generateDTag(title: string): string { function generateDTag(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
.replace(/[^\w\s-]/g, '') .replace(/[^\w\s-]/g, "")
.replace(/\s+/g, '-'); .replace(/\s+/g, "-");
} }

46
src/lib/snippets/UserSnippets.svelte

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

13
src/lib/stores.ts

@ -1,5 +1,5 @@
import { readable, writable } from 'svelte/store'; import { readable, writable } from "svelte/store";
import { FeedType } from './consts.ts'; import { FeedType } from "./consts.ts";
export let idList = writable<string[]>([]); export let idList = writable<string[]>([]);
@ -22,18 +22,19 @@ const defaultVisibility: PublicationLayoutVisibility = {
main: true, main: true,
inner: false, inner: false,
discussion: false, discussion: false,
editing: false editing: false,
}; };
function createVisibilityStore() { function createVisibilityStore() {
const { subscribe, set, update } const { subscribe, set, update } = writable<PublicationLayoutVisibility>({
= writable<PublicationLayoutVisibility>({ ...defaultVisibility }); ...defaultVisibility,
});
return { return {
subscribe, subscribe,
set, set,
update, update,
reset: () => set({ ...defaultVisibility }) reset: () => set({ ...defaultVisibility }),
}; };
} }

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.

165
src/lib/stores/userStore.ts

@ -1,18 +1,23 @@
import { writable, get } from 'svelte/store'; import { writable, get } from "svelte/store";
import type { NostrProfile } from '$lib/utils/nostrUtils'; import type { NostrProfile } from "$lib/utils/nostrUtils";
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk'; import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk";
import { NDKNip07Signer, NDKRelayAuthPolicies, NDKRelaySet, NDKRelay } from '@nostr-dev-kit/ndk'; import {
import { getUserMetadata } from '$lib/utils/nostrUtils'; NDKNip07Signer,
import { ndkInstance } from '$lib/ndk'; NDKRelayAuthPolicies,
import { loginStorageKey, fallbackRelays } from '$lib/consts'; NDKRelaySet,
import { nip19 } from 'nostr-tools'; NDKRelay,
} from "@nostr-dev-kit/ndk";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import { ndkInstance } from "$lib/ndk";
import { loginStorageKey, fallbackRelays } from "$lib/consts";
import { nip19 } from "nostr-tools";
export interface UserState { export interface UserState {
pubkey: string | null; pubkey: string | null;
npub: string | null; npub: string | null;
profile: NostrProfile | null; profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] }; relays: { inbox: string[]; outbox: string[] };
loginMethod: 'extension' | 'amber' | 'npub' | null; loginMethod: "extension" | "amber" | "npub" | null;
ndkUser: NDKUser | null; ndkUser: NDKUser | null;
signer: NDKSigner | null; signer: NDKSigner | null;
signedIn: boolean; signedIn: boolean;
@ -30,27 +35,33 @@ export const userStore = writable<UserState>({
}); });
// Helper functions for relay management // Helper functions for relay management
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`; return `${loginStorageKey}/${user.pubkey}/${type}`;
} }
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void { function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'inbox'), getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map(relay => relay.url)) JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
); );
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'outbox'), getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map(relay => relay.url)) JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
); );
} }
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]') JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
); );
const outboxes = new Set<string>( const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]') JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
); );
return [inboxes, outboxes]; return [inboxes, outboxes];
@ -59,7 +70,7 @@ function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: any, ndk: any,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = fallbackRelays fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
@ -79,23 +90,37 @@ async function getUserPreferredRelays(
if (relayList == null) { if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.(); const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]: [string, any]) => { Object.entries(relayMap ?? {}).forEach(
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk); ([url, relayType]: [string, any]) => {
const relay = new NDKRelay(
url,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
if (relayType.read) inboxRelays.add(relay); if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}); },
);
} else { } else {
relayList.tags.forEach((tag: string[]) => { relayList.tags.forEach((tag: string[]) => {
switch (tag[0]) { switch (tag[0]) {
case 'r': case "r":
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
case 'w': case "w":
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
default: default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
} }
}); });
@ -106,15 +131,20 @@ async function getUserPreferredRelays(
// --- Unified login/logout helpers --- // --- Unified login/logout helpers ---
export const loginMethodStorageKey = 'alexandria/login/method'; export const loginMethodStorageKey = "alexandria/login/method";
function persistLogin(user: NDKUser, method: 'extension' | 'amber' | 'npub') { function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
localStorage.setItem(loginStorageKey, user.pubkey); localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method); localStorage.setItem(loginMethodStorageKey, method);
} }
function getPersistedLoginMethod(): 'extension' | 'amber' | 'npub' | null { function getPersistedLoginMethod(): "extension" | "amber" | "npub" | null {
return (localStorage.getItem(loginMethodStorageKey) as 'extension' | 'amber' | 'npub') ?? null; return (
(localStorage.getItem(loginMethodStorageKey) as
| "extension"
| "amber"
| "npub") ?? null
);
} }
function clearLogin() { function clearLogin() {
@ -127,7 +157,7 @@ function clearLogin() {
*/ */
export async function loginWithExtension() { export async function loginWithExtension() {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized'); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
const user = await signer.user(); const user = await signer.user();
@ -147,17 +177,19 @@ export async function loginWithExtension() {
npub, npub,
profile, profile,
relays: { relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map(relay => relay.url), inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url) outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
}, },
loginMethod: 'extension', loginMethod: "extension",
ndkUser: user, ndkUser: user,
signer, signer,
signedIn: true, signedIn: true,
}); });
clearLogin(); clearLogin();
localStorage.removeItem('alexandria/logout/flag'); localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, 'extension'); persistLogin(user, "extension");
} }
/** /**
@ -165,7 +197,7 @@ export async function loginWithExtension() {
*/ */
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) { export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized'); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
const npub = user.npub; const npub = user.npub;
const profile = await getUserMetadata(npub, true); // Force fresh fetch const profile = await getUserMetadata(npub, true); // Force fresh fetch
@ -182,17 +214,19 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
npub, npub,
profile, profile,
relays: { relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map(relay => relay.url), inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url) outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
}, },
loginMethod: 'amber', loginMethod: "amber",
ndkUser: user, ndkUser: user,
signer: amberSigner, signer: amberSigner,
signedIn: true, signedIn: true,
}); });
clearLogin(); clearLogin();
localStorage.removeItem('alexandria/logout/flag'); localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, 'amber'); persistLogin(user, "amber");
} }
/** /**
@ -200,14 +234,14 @@ export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) {
*/ */
export async function loginWithNpub(pubkeyOrNpub: string) { export async function loginWithNpub(pubkeyOrNpub: string) {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized'); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
let hexPubkey: string; let hexPubkey: string;
if (pubkeyOrNpub.startsWith('npub')) { if (pubkeyOrNpub.startsWith("npub")) {
try { try {
hexPubkey = nip19.decode(pubkeyOrNpub).data as string; hexPubkey = nip19.decode(pubkeyOrNpub).data as string;
} catch (e) { } catch (e) {
console.error('Failed to decode hex pubkey from npub:', pubkeyOrNpub, e); console.error("Failed to decode hex pubkey from npub:", pubkeyOrNpub, e);
throw e; throw e;
} }
} else { } else {
@ -217,7 +251,7 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
try { try {
npub = nip19.npubEncode(hexPubkey); npub = nip19.npubEncode(hexPubkey);
} catch (e) { } catch (e) {
console.error('Failed to encode npub from hex pubkey:', hexPubkey, e); console.error("Failed to encode npub from hex pubkey:", hexPubkey, e);
throw e; throw e;
} }
const user = ndk.getUser({ npub }); const user = ndk.getUser({ npub });
@ -229,26 +263,26 @@ export async function loginWithNpub(pubkeyOrNpub: string) {
npub, npub,
profile, profile,
relays: { inbox: [], outbox: [] }, relays: { inbox: [], outbox: [] },
loginMethod: 'npub', loginMethod: "npub",
ndkUser: user, ndkUser: user,
signer: null, signer: null,
signedIn: true, signedIn: true,
}); });
clearLogin(); clearLogin();
localStorage.removeItem('alexandria/logout/flag'); localStorage.removeItem("alexandria/logout/flag");
persistLogin(user, 'npub'); persistLogin(user, "npub");
} }
/** /**
* Logout and clear all user state * Logout and clear all user state
*/ */
export function logoutUser() { export function logoutUser() {
console.log('Logging out user...'); console.log("Logging out user...");
const currentUser = get(userStore); const currentUser = get(userStore);
if (currentUser.ndkUser) { if (currentUser.ndkUser) {
// Clear persisted relays for the user // Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox')); localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox')); localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox"));
} }
// Clear all possible login states from localStorage // Clear all possible login states from localStorage
@ -258,27 +292,34 @@ export function logoutUser() {
const keysToRemove = []; const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && (key.includes('login') || key.includes('nostr') || key.includes('user') || key.includes('alexandria') || key === 'pubkey')) { if (
key &&
(key.includes("login") ||
key.includes("nostr") ||
key.includes("user") ||
key.includes("alexandria") ||
key === "pubkey")
) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
// Specifically target the login storage key // Specifically target the login storage key
keysToRemove.push('alexandria/login/pubkey'); keysToRemove.push("alexandria/login/pubkey");
keysToRemove.push('alexandria/login/method'); keysToRemove.push("alexandria/login/method");
keysToRemove.forEach(key => { keysToRemove.forEach((key) => {
console.log('Removing localStorage key:', key); console.log("Removing localStorage key:", key);
localStorage.removeItem(key); localStorage.removeItem(key);
}); });
// Clear Amber-specific flags // Clear Amber-specific flags
localStorage.removeItem('alexandria/amber/fallback'); localStorage.removeItem("alexandria/amber/fallback");
// Set a flag to prevent auto-login on next page load // Set a flag to prevent auto-login on next page load
localStorage.setItem('alexandria/logout/flag', 'true'); localStorage.setItem("alexandria/logout/flag", "true");
console.log('Cleared all login data from localStorage'); console.log("Cleared all login data from localStorage");
userStore.set({ userStore.set({
pubkey: null, pubkey: null,
@ -297,5 +338,5 @@ export function logoutUser() {
ndk.signer = undefined; ndk.signer = undefined;
} }
console.log('Logout complete'); console.log("Logout complete");
} }

59
src/lib/utils/ZettelParser.ts

@ -1,8 +1,8 @@
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { signEvent, getEventHash } from '$lib/utils/nostrUtils'; import { signEvent, getEventHash } from "$lib/utils/nostrUtils";
import { getMimeTags } from '$lib/utils/mime'; import { getMimeTags } from "$lib/utils/mime";
import { standardRelays } from '$lib/consts'; import { standardRelays } from "$lib/consts";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
export interface ZettelSection { export interface ZettelSection {
title: string; title: string;
@ -17,14 +17,17 @@ export interface ZettelSection {
* @param level The heading level (2 for '==', 3 for '===', etc.). * @param level The heading level (2 for '==', 3 for '===', etc.).
* @returns Array of section strings, each starting with the heading. * @returns Array of section strings, each starting with the heading.
*/ */
export function splitAsciiDocByHeadingLevel(content: string, level: number): string[] { export function splitAsciiDocByHeadingLevel(
if (level < 1 || level > 6) throw new Error('Heading level must be 1-6'); content: string,
const heading = '^' + '='.repeat(level) + ' '; level: number,
const regex = new RegExp(`(?=${heading})`, 'gm'); ): 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 return content
.split(regex) .split(regex)
.map(section => section.trim()) .map((section) => section.trim())
.filter(section => section.length > 0); .filter((section) => section.length > 0);
} }
/** /**
@ -32,21 +35,19 @@ export function splitAsciiDocByHeadingLevel(content: string, level: number): str
* @param section The section string (must start with heading). * @param section The section string (must start with heading).
*/ */
export function parseZettelSection(section: string): ZettelSection { export function parseZettelSection(section: string): ZettelSection {
const lines = section.split('\n'); const lines = section.split("\n");
let title = 'Untitled'; let title = "Untitled";
let contentLines: string[] = []; let contentLines: string[] = [];
let inHeader = true; let inHeader = true;
let tags: string[][] = []; let tags: string[][] = [];
tags = extractTags(section); tags = extractTags(section);
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (inHeader && trimmed.startsWith('==')) { if (inHeader && trimmed.startsWith("==")) {
title = trimmed.replace(/^==+/, '').trim(); title = trimmed.replace(/^==+/, "").trim();
continue; continue;
} } else if (inHeader && trimmed.startsWith(":")) {
else if (inHeader && trimmed.startsWith(':')) {
continue; continue;
} }
@ -56,7 +57,7 @@ export function parseZettelSection(section: string): ZettelSection {
return { return {
title, title,
content: contentLines.join('\n').trim(), content: contentLines.join("\n").trim(),
tags, tags,
}; };
} }
@ -64,7 +65,10 @@ export function parseZettelSection(section: string): ZettelSection {
/** /**
* Parses AsciiDoc into an array of ZettelSection objects at the given heading level. * Parses AsciiDoc into an array of ZettelSection objects at the given heading level.
*/ */
export function parseAsciiDocSections(content: string, level: number): ZettelSection[] { export function parseAsciiDocSections(
content: string,
level: number,
): ZettelSection[] {
return splitAsciiDocByHeadingLevel(content, level).map(parseZettelSection); return splitAsciiDocByHeadingLevel(content, level).map(parseZettelSection);
} }
@ -76,11 +80,11 @@ export function parseAsciiDocSections(content: string, level: number): ZettelSec
*/ */
export function extractTags(content: string): string[][] { export function extractTags(content: string): string[][] {
const tags: string[][] = []; const tags: string[][] = [];
const lines = content.split('\n'); const lines = content.split("\n");
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.startsWith(':')) { if (trimmed.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value // Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/); const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
if (match) { if (match) {
@ -88,11 +92,14 @@ export function extractTags(content: string): string[][] {
const tagValue = match[2].trim(); const tagValue = match[2].trim();
// Special handling for tags attribute // Special handling for tags attribute
if (tagName === 'tags') { if (tagName === "tags") {
// Split comma-separated values and create individual "t" tags // Split comma-separated values and create individual "t" tags
const tagValues = tagValue.split(',').map(v => v.trim()).filter(v => v.length > 0); const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) { for (const value of tagValues) {
tags.push(['t', value]); tags.push(["t", value]);
} }
} else { } else {
// Regular attribute becomes a tag // Regular attribute becomes a tag
@ -102,7 +109,7 @@ export function extractTags(content: string): string[][] {
} }
} }
console.log('Extracted tags:', tags); console.log("Extracted tags:", tags);
return tags; return tags;
} }
// You can add publishing logic here as needed, e.g., // You can add publishing logic here as needed, e.g.,

34
src/lib/utils/community_checker.ts

@ -1,5 +1,5 @@
import { communityRelay } from '$lib/consts'; import { communityRelay } 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>();
@ -17,21 +17,25 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => { return await new Promise((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);
@ -52,23 +56,25 @@ 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> = {};
// Run all community checks in parallel with timeout // Run all community checks in parallel with timeout
const checkPromises = profiles.map(async (profile) => { const checkPromises = profiles.map(async (profile) => {
if (!profile.pubkey) return { pubkey: '', status: false }; if (!profile.pubkey) return { pubkey: "", status: false };
try { try {
const status = await Promise.race([ const status = await Promise.race([
checkCommunity(profile.pubkey), checkCommunity(profile.pubkey),
new Promise<boolean>((resolve) => { new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 2000); // 2 second timeout per check setTimeout(() => resolve(false), 2000); // 2 second timeout per check
}) }),
]); ]);
return { pubkey: profile.pubkey, status }; return { pubkey: profile.pubkey, status };
} catch (error) { } catch (error) {
console.warn('Community status check failed for', profile.pubkey, error); console.warn("Community status check failed for", profile.pubkey, error);
return { pubkey: profile.pubkey, status: false }; return { pubkey: profile.pubkey, status: false };
} }
}); });
@ -77,7 +83,7 @@ export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>)
const results = await Promise.allSettled(checkPromises); const results = await Promise.allSettled(checkPromises);
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value.pubkey) { if (result.status === "fulfilled" && result.value.pubkey) {
communityStatus[result.value.pubkey] = result.value.status; 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")`,
);
} }
} }

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

210
src/lib/utils/nostrEventService.ts

@ -5,7 +5,7 @@ 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 type { NDKEvent } from "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants'; import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from "./search_constants";
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,14 +353,17 @@ export async function createSignedEvent(
...eventToSign, ...eventToSign,
id, id,
sig, sig,
} },
}; };
} }
/** /**
* Publish event to a single relay * Publish event to a single relay
*/ */
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> { async function publishToRelay(
relayUrl: string,
signedEvent: any,
): Promise<void> {
const ws = new WebSocket(relayUrl); const ws = new WebSocket(relayUrl);
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
@ -365,7 +405,7 @@ export async function publishEvent(
signedEvent: any, signedEvent: any,
useOtherRelays = false, useOtherRelays = false,
useFallbackRelays = false, useFallbackRelays = false,
userRelayPreference = false userRelayPreference = false,
): Promise<EventPublishResult> { ): Promise<EventPublishResult> {
// Determine which relays to use // Determine which relays to use
let relays = userRelayPreference ? get(userRelays) : standardRelays; let relays = userRelayPreference ? get(userRelays) : standardRelays;
@ -383,7 +423,7 @@ export async function publishEvent(
return { return {
success: true, success: true,
relay: relayUrl, relay: relayUrl,
eventId: signedEvent.id eventId: signedEvent.id,
}; };
} catch (e) { } catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e); console.error(`Failed to publish to ${relayUrl}:`, e);
@ -392,7 +432,7 @@ export async function publishEvent(
return { return {
success: false, success: false,
error: "Failed to publish to any relays" error: "Failed to publish to any relays",
}; };
} }
@ -403,29 +443,29 @@ export function navigateToEvent(eventId: string): void {
try { try {
// Validate that eventId is a valid hex string // Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) { if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn('Invalid event ID format:', eventId); console.warn("Invalid event ID format:", eventId);
return; return;
} }
const nevent = nip19.neventEncode({ id: eventId }); const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`); goto(`/events?id=${nevent}`);
} catch (error) { } catch (error) {
console.error('Failed to encode event ID for navigation:', eventId, 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 "";
} }

90
src/lib/utils/nostrUtils.ts

@ -10,7 +10,7 @@ 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>';
@ -52,9 +52,12 @@ function escapeHtml(text: string): string {
/** /**
* Get user metadata for a nostr identifier (npub or nprofile) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
export async function getUserMetadata(identifier: string, force = false): Promise<NostrProfile> { export async function getUserMetadata(
identifier: string,
force = false,
): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
if (!force && npubCache.has(cleanId)) { if (!force && npubCache.has(cleanId)) {
return npubCache.get(cleanId)!; return npubCache.get(cleanId)!;
@ -159,9 +162,11 @@ export async function createProfileLinkWithVerification(
// Filter out problematic relays // Filter out problematic relays
const filterProblematicRelays = (relays: string[]) => { const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => { return relays.filter((relay) => {
if (relay.includes('gitcitadel.nostr1.com')) { if (relay.includes("gitcitadel.nostr1.com")) {
console.info(`[nostrUtils.ts] Filtering out problematic relay: ${relay}`); console.info(
`[nostrUtils.ts] Filtering out problematic relay: ${relay}`,
);
return false; return false;
} }
return true; return true;
@ -207,9 +212,9 @@ export async function createProfileLinkWithVerification(
// TODO: Make this work with an enum in case we add more types. // TODO: Make this work with an enum in case we add more types.
const type = nip05.endsWith("edu") ? "edu" : "standard"; const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) { switch (type) {
case 'edu': case "edu":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case 'standard': case "standard":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
} }
} }
@ -280,9 +285,9 @@ 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;
} }
@ -295,16 +300,20 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
signal: controller.signal, signal: controller.signal,
mode: 'cors', mode: "cors",
headers: { headers: {
'Accept': 'application/json' Accept: "application/json",
} },
}); });
clearTimeout(timeoutId); 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;
} }
@ -316,15 +325,19 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
// If not found, try case-insensitive search // If not found, try case-insensitive search
if (!pubkey && data.names) { if (!pubkey && data.names) {
const names = Object.keys(data.names); const names = Object.keys(data.names);
const matchingName = names.find(n => n.toLowerCase() === name.toLowerCase()); const matchingName = names.find(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (matchingName) { if (matchingName) {
pubkey = data.names[matchingName]; pubkey = data.names[matchingName];
console.log(`[getNpubFromNip05] Found case-insensitive match: ${name} -> ${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;
} }
@ -333,10 +346,10 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
return npub; return npub;
} catch (fetchError: unknown) { } catch (fetchError: unknown) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') { if (fetchError instanceof Error && fetchError.name === "AbortError") {
console.warn('[getNpubFromNip05] Request timeout for:', url); console.warn("[getNpubFromNip05] Request timeout for:", url);
} else { } else {
console.warn('[getNpubFromNip05] CORS or network error for:', url); console.warn("[getNpubFromNip05] CORS or network error for:", url);
} }
return null; return null;
} }
@ -414,7 +427,7 @@ export async function fetchEventWithFallback(
? Array.from(ndk.pool?.relays.values() || []) ? Array.from(ndk.pool?.relays.values() || [])
.filter((r) => r.status === 1) // Only use connected relays .filter((r) => r.status === 1) // Only use connected relays
.map((r) => r.url) .map((r) => r.url)
.filter(url => !url.includes('gitcitadel.nostr1.com')) // Filter out problematic relay .filter((url) => !url.includes("gitcitadel.nostr1.com")) // Filter out problematic relay
: []; : [];
// Determine which relays to use based on user authentication status // Determine which relays to use based on user authentication status
@ -442,7 +455,7 @@ export async function fetchEventWithFallback(
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 return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet) .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
@ -512,7 +525,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;
@ -582,7 +595,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)
@ -590,13 +604,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) {
@ -605,7 +619,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) {
@ -614,10 +628,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
} }
} }
@ -639,7 +653,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
} }

267
src/lib/utils/profile_search.ts

@ -1,61 +1,79 @@
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 { standardRelays, fallbackRelays } from '$lib/consts'; import { standardRelays, fallbackRelays } 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(
searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log('searchProfiles called with:', searchTerm, 'normalized:', normalizedSearchTerm); 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) {
console.log('Found cached result for:', normalizedSearchTerm); console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events.map(event => { 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[];
console.log('Cached profiles found:', profiles.length); console.log("Cached profiles found:", profiles.length);
return { profiles, Status: {} }; return { profiles, Status: {} };
} }
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.error('NDK not initialized'); console.error("NDK not initialized");
throw new Error('NDK not initialized'); throw new Error("NDK not initialized");
} }
console.log('NDK initialized, starting search logic'); console.log("NDK initialized, starting search logic");
let foundProfiles: NostrProfile[] = []; let foundProfiles: NostrProfile[] = [];
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 - normalize it properly // Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase(); const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try { try {
@ -64,33 +82,41 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
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);
} }
} else { } else {
// Try NIP-05 search first (faster than relay search) // Try NIP-05 search first (faster than relay search)
console.log('Starting NIP-05 search for:', normalizedSearchTerm); 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'); console.log(
"NIP-05 search completed, found:",
foundProfiles.length,
"profiles",
);
// If no NIP-05 results, try quick relay search // If no NIP-05 results, try quick relay search
if (foundProfiles.length === 0) { if (foundProfiles.length === 0) {
console.log('No NIP-05 results, trying quick relay search'); console.log("No NIP-05 results, trying quick relay search");
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk); foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
console.log('Quick relay search completed, found:', foundProfiles.length, 'profiles'); console.log(
"Quick relay search completed, found:",
foundProfiles.length,
"profiles",
);
} }
} }
// 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;
}); });
@ -100,17 +126,16 @@ 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);
} }
console.log('Search completed, found profiles:', foundProfiles.length); console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} }; return { profiles: foundProfiles, Status: {} };
} catch (error) { } catch (error) {
console.error('Error searching profiles:', error); console.error("Error searching profiles:", error);
return { profiles: [], Status: {} }; return { profiles: [], Status: {} };
} }
} }
@ -118,84 +143,100 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
/** /**
* 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[] = []; const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups // Enhanced list of common domains for NIP-05 lookups
// Prioritize gitcitadel.com since we know it has profiles // Prioritize gitcitadel.com since we know it has profiles
const commonDomains = [ const commonDomains = [
'gitcitadel.com', // Prioritize this domain "gitcitadel.com", // Prioritize this domain
'theforest.nostr1.com', "theforest.nostr1.com",
'nostr1.com', "nostr1.com",
'nostr.land', "nostr.land",
'sovbit.host', "sovbit.host",
'damus.io', "damus.io",
'snort.social', "snort.social",
'iris.to', "iris.to",
'coracle.social', "coracle.social",
'nostr.band', "nostr.band",
'nostr.wine', "nostr.wine",
'purplepag.es', "purplepag.es",
'relay.noswhere.com', "relay.noswhere.com",
'aggr.nostr.land', "aggr.nostr.land",
'nostr.sovbit.host', "nostr.sovbit.host",
'freelay.sovbit.host', "freelay.sovbit.host",
'nostr21.com', "nostr21.com",
'greensoul.space', "greensoul.space",
'relay.damus.io', "relay.damus.io",
'relay.nostr.band' "relay.nostr.band",
]; ];
// Normalize the search term for NIP-05 lookup // Normalize the search term for NIP-05 lookup
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log('NIP-05 search: normalized search term:', normalizedSearchTerm); console.log("NIP-05 search: normalized search term:", normalizedSearchTerm);
// Try gitcitadel.com first with extra debugging // Try gitcitadel.com first with extra debugging
const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`; const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`;
console.log('NIP-05 search: trying gitcitadel.com first:', gitcitadelAddress); console.log("NIP-05 search: trying gitcitadel.com first:", gitcitadelAddress);
try { try {
const npub = await getNpubFromNip05(gitcitadelAddress); const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) { if (npub) {
console.log('NIP-05 search: SUCCESS! found npub for gitcitadel.com:', npub); console.log(
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub pubkey: npub,
}; };
console.log('NIP-05 search: created profile for gitcitadel.com:', profile); console.log(
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
foundProfiles.push(profile); foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else { } else {
console.log('NIP-05 search: no npub found for gitcitadel.com'); console.log("NIP-05 search: no npub found for gitcitadel.com");
} }
} catch (e) { } catch (e) {
console.log('NIP-05 search: error for gitcitadel.com:', e); console.log("NIP-05 search: error for gitcitadel.com:", e);
} }
// If gitcitadel.com didn't work, try other domains // If gitcitadel.com didn't work, try other domains
console.log('NIP-05 search: gitcitadel.com failed, trying other domains...'); console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(domain => domain !== 'gitcitadel.com'); const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout // Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => { const searchPromises = otherDomains.map(async (domain) => {
const nip05Address = `${normalizedSearchTerm}@${domain}`; const nip05Address = `${normalizedSearchTerm}@${domain}`;
console.log('NIP-05 search: trying address:', nip05Address); 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); 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,
}; };
console.log('NIP-05 search: created profile for', nip05Address, ':', profile); console.log(
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
return profile; return profile;
} else { } else {
console.log('NIP-05 search: no npub found for', nip05Address); console.log("NIP-05 search: no npub found for", nip05Address);
} }
} catch (e) { } catch (e) {
console.log('NIP-05 search: error for', nip05Address, ':', e); console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain // Continue to next domain
} }
return null; return null;
@ -205,39 +246,44 @@ async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrPr
const results = await Promise.allSettled(searchPromises); const results = await Promise.allSettled(searchPromises);
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === "fulfilled" && result.value) {
foundProfiles.push(result.value); foundProfiles.push(result.value);
} }
} }
console.log('NIP-05 search: total profiles found:', foundProfiles.length); console.log("NIP-05 search: total profiles found:", foundProfiles.length);
return foundProfiles; return foundProfiles;
} }
/** /**
* Quick relay search with short timeout * Quick relay search with short timeout
*/ */
async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProfile[]> { async function quickRelaySearch(
console.log('quickRelaySearch called with:', searchTerm); searchTerm: string,
ndk: any,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm);
const foundProfiles: NostrProfile[] = []; const foundProfiles: NostrProfile[] = [];
// Normalize the search term for relay search // Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log('Normalized search term for relay search:', normalizedSearchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use all profile relays for better coverage // Use all profile relays for better coverage
const quickRelayUrls = [...standardRelays, ...fallbackRelays]; // Use all available relays const quickRelayUrls = [...standardRelays, ...fallbackRelays]; // Use all available relays
console.log('Using all relays for search:', quickRelayUrls); console.log("Using all relays for search:", quickRelayUrls);
// Create relay sets for parallel search // Create relay sets for parallel search
const relaySets = quickRelayUrls.map(url => { const relaySets = quickRelayUrls
.map((url) => {
try { try {
return NDKRelaySet.fromRelayUrls([url], ndk); return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) { } catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e); console.warn(`Failed to create relay set for ${url}:`, e);
return null; return null;
} }
}).filter(Boolean); })
.filter(Boolean);
// Search all relays in parallel with short timeout // Search all relays in parallel with short timeout
const searchPromises = relaySets.map(async (relaySet, index) => { const searchPromises = relaySets.map(async (relaySet, index) => {
@ -247,43 +293,60 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
const foundInRelay: NostrProfile[] = []; const foundInRelay: NostrProfile[] = [];
let eventCount = 0; let eventCount = 0;
console.log(`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`); console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`,
);
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ kinds: [0] }, { kinds: [0] },
{ closeOnEose: true, relaySet } { closeOnEose: true, relaySet },
); );
sub.on('event', (event: NDKEvent) => { sub.on("event", (event: NDKEvent) => {
eventCount++; 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 using normalized comparison // Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm); const matchesDisplayName = fieldMatches(
const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm); displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm); const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm); const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm); const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) { if (
matchesDisplayName ||
matchesDisplay_name ||
matchesName ||
matchesNip05 ||
matchesAbout
) {
console.log(`Found matching profile on relay ${index + 1}:`, { console.log(`Found matching profile on relay ${index + 1}:`, {
name: profileData.name, name: profileData.name,
display_name: profileData.display_name, display_name: profileData.display_name,
nip05: profileData.nip05, nip05: profileData.nip05,
pubkey: event.pubkey, pubkey: event.pubkey,
searchTerm: normalizedSearchTerm searchTerm: normalizedSearchTerm,
}); });
const profile = createProfileFromEvent(event, profileData); const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile in this relay // Check if we already have this profile in this relay
const existingIndex = foundInRelay.findIndex(p => p.pubkey === event.pubkey); const existingIndex = foundInRelay.findIndex(
(p) => p.pubkey === event.pubkey,
);
if (existingIndex === -1) { if (existingIndex === -1) {
foundInRelay.push(profile); foundInRelay.push(profile);
} }
@ -293,14 +356,18 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
} }
}); });
sub.on('eose', () => { sub.on("eose", () => {
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`); console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
resolve(foundInRelay); resolve(foundInRelay);
}); });
// Short timeout for quick search // Short timeout for quick search
setTimeout(() => { setTimeout(() => {
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`); console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
sub.stop(); sub.stop();
resolve(foundInRelay); resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay }, 1500); // 1.5 second timeout per relay
@ -314,7 +381,7 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
const allProfiles: Record<string, NostrProfile> = {}; const allProfiles: Record<string, NostrProfile> = {};
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled') { if (result.status === "fulfilled") {
for (const profile of result.value) { for (const profile of result.value) {
if (profile.pubkey) { if (profile.pubkey) {
allProfiles[profile.pubkey] = profile; allProfiles[profile.pubkey] = profile;
@ -323,6 +390,8 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
} }
} }
console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`); console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles); return Object.values(allProfiles);
} }

44
src/lib/utils/relayDiagnostics.ts

@ -1,6 +1,6 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts'; import { standardRelays, anonymousRelays, fallbackRelays } from "$lib/consts";
import NDK from '@nostr-dev-kit/ndk'; import NDK from "@nostr-dev-kit/ndk";
import { TIMEOUTS } from './search_constants'; import { TIMEOUTS } from "./search_constants";
export interface RelayDiagnostic { export interface RelayDiagnostic {
url: string; url: string;
@ -28,7 +28,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 +56,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 +64,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 +85,25 @@ 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])]; const allRelays = [
...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays]),
];
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"}`);
}); });
} }

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

2
src/lib/utils/search_constants.ts

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

489
src/lib/utils/subscription_search.ts

@ -1,13 +1,25 @@
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, profileRelays } from '$lib/consts'; import { communityRelay, profileRelays } 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";
/** /**
* Search for events by subscription type (d, t, n) * Search for events by subscription type (d, t, n)
@ -16,11 +28,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 +48,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 +65,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 +172,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 +189,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 +199,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 +235,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 +263,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,24 +281,32 @@ 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,
ndk: any,
): NDKRelaySet {
if (searchType === "n") {
// For profile searches, use profile relays first // For profile searches, use profile relays first
const profileRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) => const profileRelaySet = Array.from(ndk.pool.relays.values()).filter(
profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/') (relay: any) =>
profileRelays.some(
(profileRelay) =>
relay.url === profileRelay || relay.url === profileRelay + "/",
),
); );
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk); return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
} else { } else {
// For other searches, use community relay first // For other searches, use community relay first
const communityRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) => const communityRelaySet = Array.from(ndk.pool.relays.values()).filter(
relay.url === communityRelay || relay.url === communityRelay + '/' (relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + "/",
); );
return new NDKRelaySet(new Set(communityRelaySet) as any, ndk); return new NDKRelaySet(new Set(communityRelaySet) as any, ndk);
} }
@ -233,20 +322,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 +354,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 +402,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,11 +418,19 @@ 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
@ -319,7 +448,7 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
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);
} }
} }
@ -327,12 +456,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;
@ -341,15 +473,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 : searchType === 't' ? searchState.tTagEvents : searchState.firstOrderEvents, events:
searchType === "n"
? searchState.foundProfiles
: searchType === "t"
? searchState.tTagEvents
: searchState.firstOrderEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
addresses: searchState.eventAddresses, addresses: searchState.eventAddresses,
searchType: searchType, searchType: searchType,
searchTerm: normalizedSearchTerm searchTerm: normalizedSearchTerm,
}; };
} }
@ -362,28 +503,35 @@ 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) => {
if (searchType === "n") {
// For profile searches, exclude profile relays from fallback search // For profile searches, exclude profile relays from fallback search
return !profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/'); return !profileRelays.some(
(profileRelay) =>
relay.url === profileRelay || relay.url === profileRelay + "/",
);
} else { } else {
// For other searches, exclude community relay from fallback search // For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/'; return (
relay.url !== communityRelay && relay.url !== 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
@ -394,10 +542,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);
} }
@ -407,8 +560,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);
@ -423,13 +581,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);
} }
@ -439,9 +597,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
@ -457,19 +619,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,
);
} }
} }
} }
@ -478,36 +657,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 {
@ -517,7 +707,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,
}; };
} }
@ -526,7 +716,7 @@ 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 {
@ -535,15 +725,18 @@ function processTTagEoseResults(searchState: any): SearchResult {
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: [],
@ -551,7 +744,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,
}; };
} }
@ -559,12 +752,12 @@ 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);
@ -572,49 +765,65 @@ async function performSecondOrderSearchInBackground(
// Set a timeout for second-order search // Set a timeout for second-order search
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Second-order search timeout')), TIMEOUTS.SECOND_ORDER_SEARCH); setTimeout(
() => reject(new Error("Second-order search timeout")),
TIMEOUTS.SECOND_ORDER_SEARCH,
);
}); });
const searchPromise = (async () => { const searchPromise = (async () => {
if (searchType === 'n' && targetPubkey) { 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(
pTagFilter, pTagFilter,
{ 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 // Parallel fetch for #e and #a tag events
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk); const relaySet = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
ndk,
);
const [eTagEvents, aTagEvents] = await Promise.all([ const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0 eventIds.size > 0
? ndk.fetchEvents( ? ndk.fetchEvents(
{ '#e': Array.from(eventIds) }, { "#e": Array.from(eventIds) },
{ closeOnEose: true }, { closeOnEose: true },
relaySet relaySet,
) )
: Promise.resolve([]), : Promise.resolve([]),
addresses.size > 0 addresses.size > 0
? ndk.fetchEvents( ? ndk.fetchEvents(
{ '#a': Array.from(addresses) }, { "#a": Array.from(addresses) },
{ closeOnEose: true }, { closeOnEose: true },
relaySet relaySet,
) )
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
// Filter out emoji reactions // Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event)); const filteredETagEvents = Array.from(eTagEvents).filter(
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event)); (event) => !isEmojiReaction(event),
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents, ...filteredATagEvents]; );
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);
} }
@ -623,8 +832,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
@ -636,10 +847,13 @@ 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
@ -651,6 +865,9 @@ async function performSecondOrderSearchInBackground(
// Race between search and timeout // Race between search and timeout
await Promise.race([searchPromise, timeoutPromise]); 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,
);
} }
} }

107
src/routes/+layout.ts

@ -1,12 +1,16 @@
import { feedTypeStorageKey } from '$lib/consts'; import { feedTypeStorageKey } from "$lib/consts";
import { FeedType } from '$lib/consts'; import { FeedType } from "$lib/consts";
import { getPersistedLogin, initNdk, ndkInstance } from '$lib/ndk'; import { getPersistedLogin, initNdk, ndkInstance } from "$lib/ndk";
import { loginWithExtension, loginWithAmber, loginWithNpub } from '$lib/stores/userStore'; import {
import { loginMethodStorageKey } from '$lib/stores/userStore'; loginWithExtension,
import Pharos, { pharosInstance } from '$lib/parser'; loginWithAmber,
import { feedType } from '$lib/stores'; loginWithNpub,
import type { LayoutLoad } from './$types'; } from "$lib/stores/userStore";
import { get } from 'svelte/store'; import { loginMethodStorageKey } from "$lib/stores/userStore";
import Pharos, { pharosInstance } from "$lib/parser";
import { feedType } from "$lib/stores";
import type { LayoutLoad } from "./$types";
import { get } from "svelte/store";
export const ssr = false; export const ssr = false;
@ -22,79 +26,98 @@ export const load: LayoutLoad = () => {
try { try {
const pubkey = getPersistedLogin(); const pubkey = getPersistedLogin();
const loginMethod = localStorage.getItem(loginMethodStorageKey); const loginMethod = localStorage.getItem(loginMethodStorageKey);
const logoutFlag = localStorage.getItem('alexandria/logout/flag'); const logoutFlag = localStorage.getItem("alexandria/logout/flag");
console.log('Layout load - persisted pubkey:', pubkey); console.log("Layout load - persisted pubkey:", pubkey);
console.log('Layout load - persisted login method:', loginMethod); console.log("Layout load - persisted login method:", loginMethod);
console.log('Layout load - logout flag:', logoutFlag); console.log("Layout load - logout flag:", logoutFlag);
console.log('All localStorage keys:', Object.keys(localStorage)); console.log("All localStorage keys:", Object.keys(localStorage));
if (pubkey && loginMethod && !logoutFlag) { if (pubkey && loginMethod && !logoutFlag) {
if (loginMethod === 'extension') { if (loginMethod === "extension") {
console.log('Restoring extension login...'); console.log("Restoring extension login...");
loginWithExtension(); loginWithExtension();
} else if (loginMethod === 'amber') { } else if (loginMethod === "amber") {
// Attempt to restore Amber (NIP-46) session from localStorage // Attempt to restore Amber (NIP-46) session from localStorage
const relay = 'wss://relay.nsec.app'; const relay = "wss://relay.nsec.app";
const localNsec = localStorage.getItem('amber/nsec'); const localNsec = localStorage.getItem("amber/nsec");
if (localNsec) { if (localNsec) {
import('@nostr-dev-kit/ndk').then(async ({ NDKNip46Signer, default: NDK }) => { import("@nostr-dev-kit/ndk").then(
async ({ NDKNip46Signer, default: NDK }) => {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
try { try {
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, { const amberSigner = NDKNip46Signer.nostrconnect(
name: 'Alexandria', ndk,
perms: 'sign_event:1;sign_event:4', 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) // Try to reconnect (blockUntilReady will resolve if Amber is running and session is valid)
await amberSigner.blockUntilReady(); await amberSigner.blockUntilReady();
const user = await amberSigner.user(); const user = await amberSigner.user();
await loginWithAmber(amberSigner, user); await loginWithAmber(amberSigner, user);
console.log('Amber session restored.'); console.log("Amber session restored.");
} catch (err) { } catch (err) {
// If reconnection fails, automatically fallback to npub-only mode // If reconnection fails, automatically fallback to npub-only mode
console.warn('Amber session could not be restored. Falling back to npub-only mode.'); console.warn(
"Amber session could not be restored. Falling back to npub-only mode.",
);
try { try {
// Set the flag first, before login // Set the flag first, before login
localStorage.setItem('alexandria/amber/fallback', '1'); localStorage.setItem("alexandria/amber/fallback", "1");
console.log('Set fallback flag in localStorage'); console.log("Set fallback flag in localStorage");
// Small delay to ensure flag is set // Small delay to ensure flag is set
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
await loginWithNpub(pubkey); await loginWithNpub(pubkey);
console.log('Successfully fell back to npub-only mode.'); console.log("Successfully fell back to npub-only mode.");
} catch (fallbackErr) { } catch (fallbackErr) {
console.error('Failed to fallback to npub-only mode:', fallbackErr); console.error(
"Failed to fallback to npub-only mode:",
fallbackErr,
);
} }
} }
}); },
);
} else { } else {
// No session data, automatically fallback to npub-only mode // No session data, automatically fallback to npub-only mode
console.log('No Amber session data found. Falling back to npub-only mode.'); console.log(
"No Amber session data found. Falling back to npub-only mode.",
);
// Set the flag first, before login // Set the flag first, before login
localStorage.setItem('alexandria/amber/fallback', '1'); localStorage.setItem("alexandria/amber/fallback", "1");
console.log('Set fallback flag in localStorage'); console.log("Set fallback flag in localStorage");
// Small delay to ensure flag is set // Small delay to ensure flag is set
setTimeout(async () => { setTimeout(async () => {
try { try {
await loginWithNpub(pubkey); await loginWithNpub(pubkey);
console.log('Successfully fell back to npub-only mode.'); console.log("Successfully fell back to npub-only mode.");
} catch (fallbackErr) { } catch (fallbackErr) {
console.error('Failed to fallback to npub-only mode:', fallbackErr); console.error(
"Failed to fallback to npub-only mode:",
fallbackErr,
);
} }
}, 100); }, 100);
} }
} else if (loginMethod === 'npub') { } else if (loginMethod === "npub") {
console.log('Restoring npub login...'); console.log("Restoring npub login...");
loginWithNpub(pubkey); loginWithNpub(pubkey);
} }
} else if (logoutFlag) { } else if (logoutFlag) {
console.log('Skipping auto-login due to logout flag'); console.log("Skipping auto-login due to logout flag");
localStorage.removeItem('alexandria/logout/flag'); localStorage.removeItem("alexandria/logout/flag");
} }
} catch (e) { } catch (e) {
console.warn(`Failed to restore login: ${e}\n\nContinuing with anonymous session.`); console.warn(
`Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
);
} }
const parser = new Pharos(ndk); const parser = new Pharos(ndk);

26
src/routes/+page.svelte

@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import { import { standardRelays, fallbackRelays } from "$lib/consts";
standardRelays,
fallbackRelays,
} from "$lib/consts";
import { Alert, Input } from "flowbite-svelte"; import { Alert, Input } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons"; import { HammerSolid } from "flowbite-svelte-icons";
import { userStore } from '$lib/stores/userStore'; import { userStore } from "$lib/stores/userStore";
import { inboxRelays, ndkSignedIn } from "$lib/ndk"; import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from '$lib/components/publications/PublicationFeed.svelte'; import PublicationFeed from "$lib/components/publications/PublicationFeed.svelte";
let searchQuery = $state(''); let searchQuery = $state("");
let user = $state($userStore); let user = $state($userStore);
userStore.subscribe(val => user = val); userStore.subscribe((val) => (user = val));
</script> </script>
<Alert <Alert
@ -26,13 +23,20 @@
</span> </span>
</Alert> </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 class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'> <div
class="leather w-full flex flex-row items-center justify-center gap-4 mb-4"
>
<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"
/> />
</div> </div>
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} userRelays={$ndkSignedIn ? $inboxRelays : []} /> <PublicationFeed
relays={standardRelays}
{fallbackRelays}
{searchQuery}
userRelays={$ndkSignedIn ? $inboxRelays : []}
/>
</main> </main>

8
src/routes/about/+page.svelte

@ -1,7 +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 { 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
@ -36,7 +36,11 @@
</P> </P>
<P class="mb-3"> <P class="mb-3">
Please submit support issues on the <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('/contact')}>Contact</button> page and follow us on <A Please submit support issues on the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/contact")}>Contact</button
>
page and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel" 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"

23
src/routes/contact/+page.svelte

@ -1,10 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; import {
import { ndkInstance, ndkSignedIn } from '$lib/ndk'; Heading,
import { userStore } from '$lib/stores/userStore'; P,
import { standardRelays } from '$lib/consts'; A,
import type NDK from '@nostr-dev-kit/ndk'; Button,
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; Label,
Textarea,
Input,
Modal,
} from "flowbite-svelte";
import { ndkInstance, ndkSignedIn } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { standardRelays } from "$lib/consts";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
// @ts-ignore - Workaround for Svelte component import issue // @ts-ignore - Workaround for Svelte component import issue
import LoginModal from "$lib/components/LoginModal.svelte"; import LoginModal from "$lib/components/LoginModal.svelte";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
@ -47,7 +56,7 @@
// Subscribe to userStore // Subscribe to userStore
let user = $state($userStore); let user = $state($userStore);
userStore.subscribe(val => user = val); userStore.subscribe((val) => (user = val));
// Repository event address from the task // Repository event address from the task
const repoAddress = const repoAddress =

286
src/routes/events/+page.svelte

@ -3,23 +3,26 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from '$lib/components/EventSearch.svelte'; import EventSearch from "$lib/components/EventSearch.svelte";
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 { 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 {
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte'; testAllRelays,
import { neventEncode, naddrEncode } from '$lib/utils'; logRelayDiagnostics,
import { standardRelays } from '$lib/consts'; } from "$lib/utils/relayDiagnostics";
import { getEventType } from '$lib/utils/mime'; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte'; import { neventEncode, naddrEncode } from "$lib/utils";
import { checkCommunity } from '$lib/utils/search_utility'; import { standardRelays } from "$lib/consts";
import { getEventType } from "$lib/utils/mime";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility";
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -50,7 +53,7 @@
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); userStore.subscribe((val) => (user = val));
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
@ -80,14 +83,14 @@
// Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes // Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes
$effect(() => { $effect(() => {
const url = $page.url.searchParams; const url = $page.url.searchParams;
searchValue = url.get('id') ?? url.get('d'); searchValue = url.get("id") ?? url.get("d");
}); });
// Add support for t and n parameters // Add support for t and n parameters
$effect(() => { $effect(() => {
const url = $page.url.searchParams; const url = $page.url.searchParams;
const tParam = url.get('t'); const tParam = url.get("t");
const nParam = url.get('n'); const nParam = url.get("n");
if (tParam) { if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix // Decode the t parameter and set it as searchValue with t: prefix
@ -102,7 +105,15 @@
} }
}); });
function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set(), searchTypeParam?: string, searchTermParam?: string) { 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;
@ -112,12 +123,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;
@ -153,7 +173,7 @@
searchInProgress = false; searchInProgress = false;
secondOrderSearchMessage = null; secondOrderSearchMessage = null;
communityStatus = {}; communityStatus = {};
goto('/events', { replaceState: true }); goto("/events", { replaceState: true });
} }
function closeSidePanel() { function closeSidePanel() {
@ -177,7 +197,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) {
@ -201,15 +225,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";
} }
@ -251,12 +278,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);
} }
/** /**
@ -270,7 +298,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) {
@ -287,10 +319,19 @@
const tParam = $page.url.searchParams.get("t"); const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n"); const nParam = $page.url.searchParams.get("n");
console.log("Events page URL update:", { id, dTag, tParam, nParam, searchValue }); console.log("Events page URL update:", {
id,
dTag,
tParam,
nParam,
searchValue,
});
if (id !== searchValue) { if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id }); console.log("ID changed, updating searchValue:", {
old: searchValue,
new: id,
});
searchValue = id; searchValue = id;
dTagValue = null; dTagValue = null;
// Only close side panel if we're clearing the search // Only close side panel if we're clearing the search
@ -302,7 +343,10 @@
} }
if (dTag !== dTagValue) { if (dTag !== dTagValue) {
console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag }); console.log("DTag changed, updating dTagValue:", {
old: dTagValue,
new: dTag,
});
// Normalize d-tag to lowercase for consistent searching // Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null; dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null; searchValue = null;
@ -317,7 +361,10 @@
const decodedT = decodeURIComponent(tParam); const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`; const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) { if (tSearchValue !== searchValue) {
console.log("T parameter changed, updating searchValue:", { old: searchValue, new: tSearchValue }); console.log("T parameter changed, updating searchValue:", {
old: searchValue,
new: tSearchValue,
});
searchValue = tSearchValue; searchValue = tSearchValue;
dTagValue = null; dTagValue = null;
// For t-tag searches (which return multiple results), close side panel // For t-tag searches (which return multiple results), close side panel
@ -332,7 +379,10 @@
const decodedN = decodeURIComponent(nParam); const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`; const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) { if (nSearchValue !== searchValue) {
console.log("N parameter changed, updating searchValue:", { old: searchValue, new: nSearchValue }); console.log("N parameter changed, updating searchValue:", {
old: searchValue,
new: nSearchValue,
});
searchValue = nSearchValue; searchValue = nSearchValue;
dTagValue = null; dTagValue = null;
// For n-tag searches (which return multiple results), close side panel // For n-tag searches (which return multiple results), close side panel
@ -362,7 +412,14 @@
const tParam = $page.url.searchParams.get("t"); const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n"); const nParam = $page.url.searchParams.get("n");
console.log("Events page URL change:", { id, dTag, tParam, nParam, currentSearchValue: searchValue, currentDTagValue: dTagValue }); console.log("Events page URL change:", {
id,
dTag,
tParam,
nParam,
currentSearchValue: searchValue,
currentDTagValue: dTagValue,
});
// Handle ID parameter changes // Handle ID parameter changes
if (id !== searchValue) { if (id !== searchValue) {
@ -391,7 +448,10 @@
const decodedT = decodeURIComponent(tParam); const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`; const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) { if (tSearchValue !== searchValue) {
console.log("t parameter changed:", { old: searchValue, new: tSearchValue }); console.log("t parameter changed:", {
old: searchValue,
new: tSearchValue,
});
searchValue = tSearchValue; searchValue = tSearchValue;
dTagValue = null; dTagValue = null;
showSidePanel = false; showSidePanel = false;
@ -405,7 +465,10 @@
const decodedN = decodeURIComponent(nParam); const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`; const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) { if (nSearchValue !== searchValue) {
console.log("n parameter changed:", { old: searchValue, new: nSearchValue }); console.log("n parameter changed:", {
old: searchValue,
new: nSearchValue,
});
searchValue = nSearchValue; searchValue = nSearchValue;
dTagValue = null; dTagValue = null;
showSidePanel = false; showSidePanel = false;
@ -436,7 +499,7 @@
}); });
onMount(() => { onMount(() => {
userRelayPreference = localStorage.getItem('useUserRelays') === 'true'; userRelayPreference = localStorage.getItem("useUserRelays") === "true";
// Run relay diagnostics to help identify connection issues // Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error); testAllRelays().then(logRelayDiagnostics).catch(console.error);
@ -475,11 +538,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}
@ -487,12 +552,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">
@ -504,15 +571,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}
@ -528,7 +605,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>
@ -548,13 +627,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"
@ -565,7 +648,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}
@ -573,7 +658,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>
@ -591,13 +677,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}
@ -614,9 +701,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}
@ -632,12 +728,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
@ -655,13 +757,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"
@ -672,7 +778,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}
@ -680,7 +788,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>
@ -695,7 +804,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.
@ -715,9 +825,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}
@ -733,7 +852,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>
@ -753,13 +874,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"
@ -770,7 +895,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}
@ -778,7 +905,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>

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

@ -1,15 +1,19 @@
<script lang='ts'> <script lang="ts">
import { Heading, Button, Alert } from "flowbite-svelte"; import { Heading, Button, Alert } from "flowbite-svelte";
import { PaperPlaneOutline } from "flowbite-svelte-icons"; import { PaperPlaneOutline } from "flowbite-svelte-icons";
import ZettelEditor from '$lib/components/ZettelEditor.svelte'; import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { publishZettel } from '$lib/services/publisher'; import { publishZettel } from "$lib/services/publisher";
let content = $state(''); let content = $state("");
let showPreview = $state(false); let showPreview = $state(false);
let isPublishing = $state(false); let isPublishing = $state(false);
let publishResult = $state<{ success: boolean; eventId?: string; error?: string } | null>(null); let publishResult = $state<{
success: boolean;
eventId?: string;
error?: string;
} | null>(null);
// Handle content changes from ZettelEditor // Handle content changes from ZettelEditor
function handleContentChange(newContent: string) { function handleContentChange(newContent: string) {
@ -34,7 +38,7 @@
}, },
onError: (error) => { onError: (error) => {
publishResult = { success: false, error }; publishResult = { success: false, error };
} },
}); });
isPublishing = false; isPublishing = false;
@ -48,7 +52,10 @@
<!-- Main container with 75% width and centered --> <!-- Main container with 75% width and centered -->
<div class="w-3/4 mx-auto"> <div class="w-3/4 mx-auto">
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<Heading tag="h1" class="text-2xl font-bold text-gray-900 dark:text-gray-100"> <Heading
tag="h1"
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
Compose Notes Compose Notes
</Heading> </Heading>

32
src/routes/publication/+page.svelte

@ -13,7 +13,11 @@
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const publicationTree = new SveltePublicationTree(data.indexEvent, data.ndk); const publicationTree = new SveltePublicationTree(data.indexEvent, data.ndk);
const toc = new TableOfContents(data.indexEvent.tagAddress(), publicationTree, page.url.pathname ?? ""); const toc = new TableOfContents(
data.indexEvent.tagAddress(),
publicationTree,
page.url.pathname ?? "",
);
setContext("publicationTree", publicationTree); setContext("publicationTree", publicationTree);
setContext("toc", toc); setContext("toc", toc);
@ -25,7 +29,9 @@
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication", "Alexandria Publication",
); );
let currentUrl = $derived(`${page.url.origin}${page.url.pathname}${page.url.search}`); 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.
@ -38,21 +44,23 @@
"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 => { publicationTree.onBookmarkMoved((address) => {
goto(`#${address}`, { goto(`#${address}`, {
replaceState: true, replaceState: true,
}); });
// TODO: Extract IndexedDB interaction to a service layer. // TODO: Extract IndexedDB interaction to a service layer.
// Store bookmark in IndexedDB // Store bookmark in IndexedDB
const db = indexedDB.open('alexandria', 1); const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => { db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' }); const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
}; };
db.onsuccess = () => { db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readwrite'); const transaction = db.result.transaction(["bookmarks"], "readwrite");
const store = transaction.objectStore('bookmarks'); const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`; const bookmarkKey = `${data.indexEvent.tagAddress()}`;
store.put({ key: bookmarkKey, address }); store.put({ key: bookmarkKey, address });
}; };
@ -61,14 +69,16 @@
onMount(() => { onMount(() => {
// TODO: Extract IndexedDB interaction to a service layer. // TODO: Extract IndexedDB interaction to a service layer.
// Read bookmark from IndexedDB // Read bookmark from IndexedDB
const db = indexedDB.open('alexandria', 1); const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => { db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' }); const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
}; };
db.onsuccess = () => { db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readonly'); const transaction = db.result.transaction(["bookmarks"], "readonly");
const store = transaction.objectStore('bookmarks'); const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`; const bookmarkKey = `${data.indexEvent.tagAddress()}`;
const request = store.get(bookmarkKey); const request = store.get(bookmarkKey);

46
src/routes/publication/+page.ts

@ -1,28 +1,28 @@
import { error } from '@sveltejs/kit'; 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 { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
/** /**
* Decodes an naddr identifier and returns a filter object * Decodes an naddr identifier and returns a filter object
*/ */
function decodeNaddr(id: string) { function decodeNaddr(id: string) {
try { try {
if (!id.startsWith('naddr')) return {}; if (!id.startsWith("naddr")) return {};
const decoded = nip19.decode(id); const decoded = nip19.decode(id);
if (decoded.type !== 'naddr') return {}; if (decoded.type !== "naddr") return {};
const data = decoded.data; const data = decoded.data;
return { return {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [data.pubkey],
'#d': [data.identifier] "#d": [data.identifier],
}; };
} catch (e) { } catch (e) {
console.error('Failed to decode naddr:', e); console.error("Failed to decode naddr:", e);
return null; return null;
} }
} }
@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
const hasFilter = Object.keys(filter).length > 0; const hasFilter = Object.keys(filter).length > 0;
try { try {
const event = await (hasFilter ? const event = await (hasFilter
ndk.fetchEvent(filter) : ? ndk.fetchEvent(filter)
ndk.fetchEvent(id)); : ndk.fetchEvent(id));
if (!event) { if (!event) {
throw new Error(`Event not found for ID: ${id}`); throw new Error(`Event not found for ID: ${id}`);
@ -69,9 +69,9 @@ 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 event = await ndk.fetchEvent( const event = await ndk.fetchEvent(
{ '#d': [dTag] }, { "#d": [dTag] },
{ closeOnEose: false }, { closeOnEose: false },
getActiveRelays(ndk) getActiveRelays(ndk),
); );
if (!event) { if (!event) {
@ -84,13 +84,19 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
} }
// TODO: Use path params instead of query params. // TODO: Use path params instead of query params.
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => { export const load: Load = async ({
const id = url.searchParams.get('id'); url,
const dTag = url.searchParams.get('d'); parent,
}: {
url: URL;
parent: () => Promise<any>;
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk } = 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.");
} }
// Fetch the event based on available parameters // Fetch the event based on available parameters
@ -98,7 +104,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id) ? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!); : await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1]; const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
return { return {
publicationType, publicationType,

25
src/routes/start/+page.svelte

@ -1,6 +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'; 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";
@ -16,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 <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('./')}>landing page</button>, 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>
@ -143,8 +146,8 @@
<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>
@ -163,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 <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('/publication?d=sybil')}>Sybil utility</button> 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">

4
src/routes/visualize/+page.svelte

@ -72,7 +72,9 @@
tags = event.getMatchingTags("e"); tags = event.getMatchingTags("e");
} }
debug(`Event ${event.id} has ${tags.length} tags (${tags.length > 0 ? (event.getMatchingTags("a").length > 0 ? "a" : "e") : "none"})`); debug(
`Event ${event.id} has ${tags.length} tags (${tags.length > 0 ? (event.getMatchingTags("a").length > 0 ? "a" : "e") : "none"})`,
);
tags.forEach((tag) => { tags.forEach((tag) => {
const eventId = tag[3]; const eventId = tag[3];

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