Browse Source

ran prettier

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

2
src/app.css

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

233
src/lib/components/CommentBox.svelte

@ -4,9 +4,12 @@ @@ -4,9 +4,12 @@
import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile, 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 {
extractRootEventInfo,
@ -16,7 +19,7 @@ @@ -16,7 +19,7 @@
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { tick } from 'svelte';
import { tick } from "svelte";
import { goto } from "$app/navigation";
const props = $props<{
@ -36,11 +39,11 @@ @@ -36,11 +39,11 @@
// Add state for modals and search
let showMentionModal = $state(false);
let showWikilinkModal = $state(false);
let mentionSearch = $state('');
let mentionSearch = $state("");
let mentionResults = $state<NostrProfile[]>([]);
let mentionLoading = $state(false);
let wikilinkTarget = $state('');
let wikilinkLabel = $state('');
let wikilinkTarget = $state("");
let wikilinkLabel = $state("");
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let mentionSearchInput: HTMLInputElement | undefined;
@ -48,7 +51,7 @@ @@ -48,7 +51,7 @@
$effect(() => {
if (showMentionModal) {
// Reset search when modal opens
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
// Focus the search input after a brief delay to ensure modal is rendered
@ -57,7 +60,7 @@ @@ -57,7 +60,7 @@
}, 100);
} else {
// Reset search when modal closes
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
mentionLoading = false;
}
@ -68,12 +71,12 @@ @@ -68,12 +71,12 @@
const npub = toNpub(trimmedPubkey);
if (npub) {
// Call an async function, but don't make the effect itself async
getUserMetadata(npub).then(metadata => {
getUserMetadata(npub).then((metadata) => {
userProfile = metadata;
});
} else if (trimmedPubkey) {
userProfile = null;
error = 'Invalid public key: must be a 64-character hex string.';
error = "Invalid public key: must be a 64-character hex string.";
} else {
userProfile = null;
error = null;
@ -82,11 +85,10 @@ @@ -82,11 +85,10 @@
$effect(() => {
if (!success) return;
content = '';
preview = '';
}
);
content = "";
preview = "";
});
// Markup buttons
const markupButtons = [
@ -99,8 +101,20 @@ @@ -99,8 +101,20 @@
{ label: "List", action: () => insertMarkup("* ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") },
{ label: '@', action: () => { mentionSearch = ''; mentionResults = []; showMentionModal = true; } },
{ label: 'Wikilink', action: () => { showWikilinkModal = true; } },
{
label: "@",
action: () => {
mentionSearch = "";
mentionResults = [];
showMentionModal = true;
},
},
{
label: "Wikilink",
action: () => {
showWikilinkModal = true;
},
},
];
function insertMarkup(prefix: string, suffix: string) {
@ -162,15 +176,17 @@ @@ -162,15 +176,17 @@
success = null;
try {
const pk = $userPubkey || '';
const pk = $userPubkey || "";
const npub = toNpub(pk);
if (!npub) {
throw new Error('Invalid public key: must be a 64-character hex string.');
throw new Error(
"Invalid public key: must be a 64-character hex string.",
);
}
if (props.event.kind === undefined || props.event.kind === null) {
throw new Error('Invalid event: missing kind');
throw new Error("Invalid event: missing kind");
}
const parent = props.event;
@ -185,14 +201,19 @@ @@ -185,14 +201,19 @@
const tags = buildReplyTags(parent, rootInfo, parentInfo, kind);
// 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
const result = await publishEvent(
signedEvent,
useOtherRelays,
useFallbackRelays,
props.userRelayPreference
props.userRelayPreference,
);
if (result.success) {
@ -202,17 +223,19 @@ @@ -202,17 +223,19 @@
} else {
if (!useOtherRelays && !useFallbackRelays) {
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) {
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 {
error = "Failed to publish comment. Please try again later.";
}
}
} catch (e: unknown) {
console.error('Error publishing comment:', e);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
console.error("Error publishing comment:", e);
error = e instanceof Error ? e.message : "An unexpected error occurred";
} finally {
isSubmitting = false;
}
@ -220,8 +243,8 @@ @@ -220,8 +243,8 @@
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
if (!npub) return "";
return npub.slice(0, 8) + "…" + npub.slice(-4);
}
async function insertAtCursor(text: string) {
@ -233,10 +256,10 @@ @@ -233,10 +256,10 @@
content = content.substring(0, start) + text + content.substring(end);
updatePreview();
// Wait for DOM updates to complete
await tick();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}
@ -244,51 +267,62 @@ @@ -244,51 +267,62 @@
// Add mention search functionality using centralized search utility
let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false);
async function searchMentions() {
if (!mentionSearch.trim()) {
mentionResults = [];
communityStatus = {};
return;
}
// Prevent multiple concurrent searches
if (isSearching) {
return;
}
console.log('Starting search for:', mentionSearch.trim());
console.log("Starting search for:", mentionSearch.trim());
// Set loading state
mentionLoading = true;
isSearching = true;
try {
console.log('Search promise created, waiting for result...');
console.log("Search promise created, waiting for result...");
const result = await searchProfiles(mentionSearch.trim());
console.log('Search completed, found profiles:', result.profiles.length);
console.log('Profile details:', result.profiles);
console.log('Community status:', result.Status);
console.log("Search completed, found profiles:", result.profiles.length);
console.log("Profile details:", result.profiles);
console.log("Community status:", result.Status);
// Update state
mentionResults = result.profiles;
communityStatus = result.Status;
console.log('State updated - mentionResults length:', mentionResults.length);
console.log('State updated - communityStatus keys:', Object.keys(communityStatus));
console.log(
"State updated - mentionResults length:",
mentionResults.length,
);
console.log(
"State updated - communityStatus keys:",
Object.keys(communityStatus),
);
} catch (error) {
console.error('Error searching mentions:', error);
console.error("Error searching mentions:", error);
mentionResults = [];
communityStatus = {};
} finally {
mentionLoading = false;
isSearching = false;
console.log('Search finished - loading:', mentionLoading, 'searching:', isSearching);
console.log(
"Search finished - loading:",
mentionLoading,
"searching:",
isSearching,
);
}
}
function selectMention(profile: NostrProfile) {
let mention = '';
let mention = "";
if (profile.pubkey) {
try {
const npub = toNpub(profile.pubkey);
@ -299,22 +333,22 @@ @@ -299,22 +333,22 @@
mention = `nostr:${profile.pubkey}`;
}
} catch (e) {
console.error('Error in toNpub:', e);
console.error("Error in toNpub:", e);
// Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`;
}
} else {
console.warn('No pubkey in profile, falling back to display name');
console.warn("No pubkey in profile, falling back to display name");
mention = `@${profile.displayName || profile.name}`;
}
insertAtCursor(mention);
showMentionModal = false;
mentionSearch = '';
mentionSearch = "";
mentionResults = [];
}
function insertWikilink() {
let markup = '';
let markup = "";
if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
} else {
@ -322,8 +356,8 @@ @@ -322,8 +356,8 @@
}
insertAtCursor(markup);
showWikilinkModal = false;
wikilinkTarget = '';
wikilinkLabel = '';
wikilinkTarget = "";
wikilinkLabel = "";
}
function handleViewComment() {
@ -362,15 +396,15 @@ @@ -362,15 +396,15 @@
bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) {
if (e.key === "Enter" && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5"
/>
<Button
size="xs"
color="primary"
<Button
size="xs"
color="primary"
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
@ -385,28 +419,51 @@ @@ -385,28 +419,51 @@
{/if}
</Button>
</div>
{#if mentionLoading}
<div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0}
<div class="text-center py-2 text-xs text-gray-500">Found {mentionResults.length} results</div>
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="text-center py-2 text-xs text-gray-500">
Found {mentionResults.length} results
</div>
<div
class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"
>
<ul class="space-y-1 p-2">
{#each mentionResults as profile}
<button type="button" class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" onclick={() => selectMention(profile)}>
<button
type="button"
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)}
>
{#if profile.pubkey && communityStatus[profile.pubkey]}
<div class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-4 h-4 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
{#if profile.picture}
<img src={profile.picture} alt="Profile" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
<img
src={profile.picture}
alt="Profile"
class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"></div>
<div
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"
></div>
{/if}
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
@ -414,11 +471,24 @@ @@ -414,11 +471,24 @@
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg class="inline w-4 h-4 text-primary-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg
class="inline w-4 h-4 text-primary-500"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/></svg
>
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono truncate">{shortenNpub(profile.pubkey)}</span>
<span class="text-xs text-gray-400 font-mono truncate"
>{shortenNpub(profile.pubkey)}</span
>
</div>
</button>
{/each}
@ -427,7 +497,9 @@ @@ -427,7 +497,9 @@
{:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else}
<div class="text-center py-4 text-gray-500">Enter a search term to find users</div>
<div class="text-center py-4 text-gray-500">
Enter a search term to find users
</div>
{/if}
</div>
</Modal>
@ -454,8 +526,15 @@ @@ -454,8 +526,15 @@
class="mb-4"
/>
<div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button>
<Button size="xs" color="alternative" on:click={() => { showWikilinkModal = false; }}>Cancel</Button>
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button
>
<Button
size="xs"
color="alternative"
on:click={() => {
showWikilinkModal = false;
}}>Cancel</Button
>
</div>
</Modal>
@ -469,7 +548,9 @@ @@ -469,7 +548,9 @@
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg">
<div
class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg"
>
{@html preview}
</div>
</div>
@ -494,7 +575,7 @@ @@ -494,7 +575,7 @@
{#if success}
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!<br/>
Comment published successfully to {success.relay}!<br />
Event ID: <span class="font-mono">{success.eventId}</span>
<button
onclick={handleViewComment}
@ -522,7 +603,7 @@ @@ -522,7 +603,7 @@
<span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName ||
userProfile.name ||
nip19.npubEncode($userPubkey || '').slice(0, 8) + "..."}
nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
</span>
</div>
{/if}

188
src/lib/components/EventDetails.svelte

@ -8,8 +8,8 @@ @@ -8,8 +8,8 @@
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { navigateToEvent } from "$lib/utils/nostrEventService";
@ -35,8 +35,8 @@ @@ -35,8 +35,8 @@
}>();
let showFullContent = $state(false);
let parsedContent = $state('');
let contentPreview = $state('');
let parsedContent = $state("");
let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string {
@ -45,7 +45,7 @@ @@ -45,7 +45,7 @@
if (titleTag) {
return titleTag;
}
// For kind 30023 events, extract title from markdown content if no title tag
if (event.kind === 30023 && event.content) {
const match = event.content.match(/^#\s+(.+)$/m);
@ -53,22 +53,25 @@ @@ -53,22 +53,25 @@
return match[1].trim();
}
}
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag
if ((event.kind === 30040 || event.kind === 30041 || event.kind === 30818) && event.content) {
if (
(event.kind === 30040 || event.kind === 30041 || event.kind === 30818) &&
event.content
) {
// First try to find a document header (= )
const docMatch = event.content.match(/^=\s+(.+)$/m);
if (docMatch) {
return docMatch[1].trim();
}
// If no document header, try to find the first section header (== )
const sectionMatch = event.content.match(/^==\s+(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
}
}
return "Untitled";
}
@ -86,8 +89,8 @@ @@ -86,8 +89,8 @@
}
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const parts = tag[1].split(':');
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
@ -96,70 +99,82 @@ @@ -96,70 +99,82 @@
const mockEvent = {
kind: +kind,
pubkey,
tags: [['d', d]],
content: '',
id: '',
sig: ''
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, standardRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode naddr for a tag in renderTag:', tag[1], error);
console.warn(
"Failed to encode naddr for a tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn('Invalid pubkey in a tag in renderTag:', pubkey);
console.warn("Invalid pubkey in a tag in renderTag:", pubkey);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn('Invalid a tag format in renderTag:', tag[1]);
console.warn("Invalid a tag format in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else if (tag[0] === 'e' && tag.length > 1) {
} else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
content: "",
tags: [],
pubkey: '',
sig: ''
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode nevent for e tag in renderTag:', tag[1], error);
console.warn(
"Failed to encode nevent for e tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else {
console.warn('Invalid event ID in e tag in renderTag:', tag[1]);
console.warn("Invalid event ID in e tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else if (tag[0] === 'note' && tag.length > 1) {
} else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
content: "",
tags: [],
pubkey: '',
sig: ''
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode nevent for note tag in renderTag:', tag[1], error);
console.warn(
"Failed to encode nevent for note tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else {
console.warn('Invalid event ID in note tag in renderTag:', tag[1]);
console.warn("Invalid event ID in note tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else if (tag[0] === 'd' && tag.length > 1) {
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${tag[1]}</a>`;
} else {
@ -171,8 +186,8 @@ @@ -171,8 +186,8 @@
text: string;
gotoValue?: string;
} {
if (tag[0] === 'a' && tag.length > 1) {
const parts = tag[1].split(':');
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
@ -181,95 +196,95 @@ @@ -181,95 +196,95 @@
const mockEvent = {
kind: +kind,
pubkey,
tags: [['d', d]],
content: '',
id: '',
sig: ''
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, standardRelays);
return {
text: `a:${tag[1]}`,
gotoValue: naddr
gotoValue: naddr,
};
} 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]}` };
}
} else {
console.warn('Invalid pubkey in a tag:', pubkey);
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
}
} else {
console.warn('Invalid a tag format:', tag[1]);
console.warn("Invalid a tag format:", 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
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
content: "",
tags: [],
pubkey: '',
sig: ''
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return {
text: `e:${tag[1]}`,
gotoValue: nevent
gotoValue: nevent,
};
} 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]}` };
}
} 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]}` };
}
} else if (tag[0] === 'p' && tag.length > 1) {
} else if (tag[0] === "p" && tag.length > 1) {
const npub = toNpub(tag[1]);
return {
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
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
content: "",
tags: [],
pubkey: '',
sig: ''
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return {
text: `note:${tag[1]}`,
gotoValue: nevent
gotoValue: nevent,
};
} 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]}` };
}
} 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]}` };
}
} 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
return {
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
return {
text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}`
gotoValue: `t:${tag[1]}`,
};
}
return { text: `${tag[0]}:${tag[1]}` };
@ -285,17 +300,17 @@ @@ -285,17 +300,17 @@
});
$effect(() => {
if(!event?.pubkey) {
if (!event?.pubkey) {
authorDisplayName = undefined;
return;
}
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
});
// --- Identifier helpers ---
@ -353,16 +368,16 @@ @@ -353,16 +368,16 @@
onMount(() => {
function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'A') {
const href = (target as HTMLAnchorElement).getAttribute('href');
if (href && href.startsWith('/')) {
if (target.tagName === "A") {
const href = (target as HTMLAnchorElement).getAttribute("href");
if (href && href.startsWith("/")) {
event.preventDefault();
goto(href);
}
}
}
document.addEventListener('click', handleInternalLinkClick);
return () => document.removeEventListener('click', handleInternalLinkClick);
document.addEventListener("click", handleInternalLinkClick);
return () => document.removeEventListener("click", handleInternalLinkClick);
});
</script>
@ -375,9 +390,16 @@ @@ -375,9 +390,16 @@
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span>
<span class="text-gray-600 dark:text-gray-400"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || event.pubkey,
)}</span
>
{:else}
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span>
<span class="text-gray-600 dark:text-gray-400"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
@ -450,17 +472,23 @@ @@ -450,17 +472,23 @@
<button
onclick={() => {
// Handle different types of gotoValue
if (tagInfo.gotoValue!.startsWith('naddr') || tagInfo.gotoValue!.startsWith('nevent') || tagInfo.gotoValue!.startsWith('npub') || tagInfo.gotoValue!.startsWith('nprofile') || tagInfo.gotoValue!.startsWith('note')) {
if (
tagInfo.gotoValue!.startsWith("naddr") ||
tagInfo.gotoValue!.startsWith("nevent") ||
tagInfo.gotoValue!.startsWith("npub") ||
tagInfo.gotoValue!.startsWith("nprofile") ||
tagInfo.gotoValue!.startsWith("note")
) {
// For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith('/')) {
} else if (tagInfo.gotoValue!.startsWith("/")) {
// For relative URLs - navigate directly
goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith('d:')) {
} else if (tagInfo.gotoValue!.startsWith("d:")) {
// For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith('t:')) {
} else if (tagInfo.gotoValue!.startsWith("t:")) {
// For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);

330
src/lib/components/EventInput.svelte

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

302
src/lib/components/EventSearch.svelte

@ -4,7 +4,11 @@ @@ -4,7 +4,11 @@
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
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 { standardRelays } from "$lib/consts";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
@ -33,7 +37,7 @@ @@ -33,7 +37,7 @@
eventIds: Set<string>,
addresses: Set<string>,
searchType?: string,
searchTerm?: string
searchTerm?: string,
) => void;
event: NDKEvent | null;
onClear?: () => void;
@ -43,7 +47,9 @@ @@ -43,7 +47,9 @@
// Component state
let searchQuery = $state("");
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 searching = $state(false);
let searchCompleted = $state(false);
@ -56,7 +62,11 @@ @@ -56,7 +62,11 @@
let currentAbortController: AbortController | null = null;
// 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 showSuccess = $derived(searchCompleted && searchResultCount !== null);
@ -75,18 +85,39 @@ @@ -75,18 +85,39 @@
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'nip05');
updateSearchState(false, true, 1, "nip05");
} else {
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
updateSearchState(false, true, 0, 'nip05');
if (activeSub) {
try {
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) {
localError = error instanceof Error ? error.message : 'NIP-05 lookup failed';
localError =
error instanceof Error ? error.message : "NIP-05 lookup failed";
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
@ -102,26 +133,49 @@ @@ -102,26 +133,49 @@
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'event');
updateSearchState(false, true, 1, "event");
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
}
}
async function handleSearchEvent(clearInput: boolean = true, queryOverride?: string) {
async function handleSearchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
@ -131,7 +185,9 @@ @@ -131,7 +185,9 @@
updateSearchState(true);
isResetting = false;
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) {
updateSearchState(false, false, null, null);
return;
@ -140,7 +196,7 @@ @@ -140,7 +196,7 @@
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, 'd');
navigateToSearch(dTag, "d");
updateSearchState(false, false, null, null);
return;
}
@ -148,23 +204,23 @@ @@ -148,23 +204,23 @@
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('t', searchTerm);
await handleSearchBySubscription("t", searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('n', searchTerm);
await handleSearchBySubscription("n", searchTerm);
return;
}
}
if (query.includes('@')) {
if (query.includes("@")) {
await handleNip05Search(query);
return;
}
if (clearInput) {
navigateToSearch(query, 'id');
navigateToSearch(query, "id");
// Don't clear searchQuery here - let the effect handle it
}
await handleEventSearch(query);
@ -176,7 +232,7 @@ @@ -176,7 +232,7 @@
if (searching || isResetting || isUserEditing) {
return;
}
if (dTagValue) {
// If dTagValue is set, show it as "d:tag" in the search bar
searchQuery = `d:${dTagValue}`;
@ -191,7 +247,13 @@ @@ -191,7 +247,13 @@
// Debounced effect to handle searchValue changes
$effect(() => {
if (!searchValue || searching || isResetting || isProcessingSearch || isWaitingForSearchResult) {
if (
!searchValue ||
searching ||
isResetting ||
isProcessingSearch ||
isWaitingForSearchResult
) {
return;
}
@ -205,7 +267,7 @@ @@ -205,7 +267,7 @@
currentNevent = neventEncode(foundEvent, standardRelays);
} catch {}
try {
currentNaddr = getMatchingTags(foundEvent, 'd')[0]?.[1]
currentNaddr = getMatchingTags(foundEvent, "d")[0]?.[1]
? naddrEncode(foundEvent, standardRelays)
: null;
} catch {}
@ -214,11 +276,30 @@ @@ -214,11 +276,30 @@
} catch {}
// 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
let currentNprofile = null;
if (searchValue && searchValue.startsWith('nprofile1') && foundEvent.kind === 0) {
if (
searchValue &&
searchValue.startsWith("nprofile1") &&
foundEvent.kind === 0
) {
try {
currentNprofile = nprofileEncode(foundEvent.pubkey, standardRelays);
} catch {}
@ -261,10 +342,15 @@ @@ -261,10 +342,15 @@
// Simple effect to handle dTagValue changes
$effect(() => {
if (dTagValue && !searching && !isResetting && dTagValue !== lastProcessedDTagValue) {
if (
dTagValue &&
!searching &&
!isResetting &&
dTagValue !== lastProcessedDTagValue
) {
console.log("EventSearch: Processing dTagValue:", dTagValue);
lastProcessedDTagValue = dTagValue;
handleSearchBySubscription('d', dTagValue);
handleSearchBySubscription("d", dTagValue);
}
});
@ -276,7 +362,12 @@ @@ -276,7 +362,12 @@
});
// 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;
searchCompleted = completed;
searchResultCount = count;
@ -297,32 +388,32 @@ @@ -297,32 +388,32 @@
currentProcessingSearchValue = null;
lastSearchValue = null;
updateSearchState(false, false, null, null);
// Cancel ongoing search
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
// Clean up subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Clear search results
onSearchResults([], [], [], new Set(), new Set());
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
@ -332,40 +423,40 @@ @@ -332,40 +423,40 @@
function handleFoundEvent(event: NDKEvent) {
foundEvent = event;
relayStatuses = {}; // Clear relay statuses when event is found
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
// Clear search state
searching = false;
searchCompleted = true;
searchResultCount = 1;
searchResultType = 'event';
searchResultType = "event";
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
lastSearchValue = searchValue;
}
// Reset processing flag
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
onEventFound(event);
}
@ -379,8 +470,14 @@ @@ -379,8 +470,14 @@
}
// Search handlers
async function handleSearchBySubscription(searchType: 'd' | 't' | 'n', searchTerm: string) {
console.log("EventSearch: Starting subscription search:", { searchType, searchTerm });
async function handleSearchBySubscription(
searchType: "d" | "t" | "n",
searchTerm: string,
) {
console.log("EventSearch: Starting subscription search:", {
searchType,
searchTerm,
});
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
@ -403,7 +500,7 @@ @@ -403,7 +500,7 @@
updatedResult.eventIds,
updatedResult.addresses,
updatedResult.searchType,
updatedResult.searchTerm
updatedResult.searchTerm,
);
},
onSubscriptionCreated: (sub) => {
@ -412,9 +509,9 @@ @@ -412,9 +509,9 @@
activeSub.stop();
}
activeSub = sub;
}
},
},
currentAbortController.signal
currentAbortController.signal,
);
console.log("EventSearch: Search completed:", result);
onSearchResults(
@ -424,16 +521,19 @@ @@ -424,16 +521,19 @@
result.eventIds,
result.addresses,
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
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
@ -447,20 +547,25 @@ @@ -447,20 +547,25 @@
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
} catch (error) {
if (error instanceof Error && error.message === 'Search cancelled') {
if (error instanceof Error && error.message === "Search cancelled") {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return;
}
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
if (error instanceof Error) {
if (error.message.includes('timeout') || error.message.includes('connection')) {
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.';
if (
error.message.includes("timeout") ||
error.message.includes("connection")
) {
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 {
localError = `Search failed: ${error.message}`;
}
@ -471,35 +576,35 @@ @@ -471,35 +576,35 @@
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
}
}
function handleClear() {
isResetting = true;
searchQuery = '';
searchQuery = "";
isUserEditing = false; // Reset user editing flag
resetSearchState();
// Clear URL parameters to reset the page
goto('', {
goto("", {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// Ensure all search state is cleared
searching = false;
searchCompleted = false;
@ -512,17 +617,17 @@ @@ -512,17 +617,17 @@
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
if (onClear) {
onClear();
}
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
@ -533,12 +638,16 @@ @@ -533,12 +638,16 @@
if (searchResultCount === 0) {
return "Search completed. No results found.";
}
const typeLabel = searchResultType === 'n' ? 'profile' :
searchResultType === 'nip05' ? 'NIP-05 address' : 'event';
const countLabel = searchResultType === 'n' ? 'profiles' : 'events';
return searchResultCount === 1
const typeLabel =
searchResultType === "n"
? "profile"
: searchResultType === "nip05"
? "NIP-05 address"
: "event";
const countLabel = searchResultType === "n" ? "profiles" : "events";
return searchResultCount === 1
? `Search completed. Found 1 ${typeLabel}.`
: `Search completed. Found ${searchResultCount} ${countLabel}.`;
}
@ -551,9 +660,10 @@ @@ -551,9 +660,10 @@
bind:value={searchQuery}
placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..."
class="flex-grow"
onkeydown={(e: KeyboardEvent) => e.key === "Enter" && handleSearchEvent(true)}
oninput={() => isUserEditing = true}
onblur={() => isUserEditing = false}
onkeydown={(e: KeyboardEvent) =>
e.key === "Enter" && handleSearchEvent(true)}
oninput={() => (isUserEditing = true)}
onblur={() => (isUserEditing = false)}
/>
<Button onclick={() => handleSearchEvent(true)} disabled={loading}>
{#if searching}
@ -561,10 +671,10 @@ @@ -561,10 +671,10 @@
{/if}
{searching ? "Searching..." : "Search"}
</Button>
<Button
onclick={handleClear}
color="alternative"
type="button"
<Button
onclick={handleClear}
color="alternative"
type="button"
disabled={loading}
>
Clear
@ -573,14 +683,20 @@ @@ -573,14 +683,20 @@
<!-- Error Display -->
{#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}
</div>
{/if}
<!-- Success Display -->
{#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()}
</div>
{/if}

228
src/lib/components/LoginMenu.svelte

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

22
src/lib/components/LoginModal.svelte

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

4
src/lib/components/Preview.svelte

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

100
src/lib/components/ZettelEditor.svelte

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

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

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

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

@ -5,7 +5,10 @@ @@ -5,7 +5,10 @@
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { lnurlpWellKnownUrl, checkCommunity } from "$lib/utils/search_utility";
import {
lnurlpWellKnownUrl,
checkCommunity,
} from "$lib/utils/search_utility";
// @ts-ignore
import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
@ -41,11 +44,13 @@ @@ -41,11 +44,13 @@
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => {
communityStatus = status;
}).catch(() => {
communityStatus = false;
});
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
});
@ -89,9 +94,18 @@ @@ -89,9 +94,18 @@
event.pubkey,
)}
{#if communityStatus === true}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else if communityStatus === false}

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

@ -30,7 +30,9 @@ @@ -30,7 +30,9 @@
indexEvent: NDKEvent;
}>();
const publicationTree = getContext("publicationTree") as SveltePublicationTree;
const publicationTree = getContext(
"publicationTree",
) as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading
@ -84,7 +86,7 @@ @@ -84,7 +86,7 @@
// #endregion
// #region Columns visibility
let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
@ -129,7 +131,7 @@ @@ -129,7 +131,7 @@
/**
* Performs actions on the DOM element for a publication tree leaf when it is mounted.
*
*
* @param el The DOM element that was mounted.
* @param address The address of the event that was mounted.
*/
@ -191,19 +193,23 @@ @@ -191,19 +193,23 @@
</script>
<!-- Table of contents -->
{#if publicationType !== 'blog' || !isLeaf}
{#if publicationType !== "blog" || !isLeaf}
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ''}`}
asideClass='fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4'
activeClass='flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg'
nonActiveClass='flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg'
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>
<CloseButton onclick={closeToc} class='btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800' />
<CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents
rootAddress={rootAddress}
{rootAddress}
depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
/>
</Sidebar>
{/if}
@ -251,7 +257,7 @@ @@ -251,7 +257,7 @@
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? 'discreet' : ''}`}
class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`}
>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"

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

@ -50,21 +50,23 @@ @@ -50,21 +50,23 @@
const fallback: string[] = fallbackRelays.filter(
(r: string) => !communityRelays.includes(r) && !userRelayList.includes(r),
);
const allRelays = includeAllRelays
? [...communityRelays, ...userRelayList, ...fallback]
const allRelays = includeAllRelays
? [...communityRelays, ...userRelayList, ...fallback]
: [...communityRelays, ...userRelayList];
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) {
console.log(`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`);
console.log(
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`,
);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
return;
}
relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
@ -111,10 +113,10 @@ @@ -111,10 +113,10 @@
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
@ -133,9 +135,11 @@ @@ -133,9 +135,11 @@
);
// Check cache first for publication search
const cachedResult = searchCache.get('publication', query);
const cachedResult = searchCache.get("publication", query);
if (cachedResult) {
console.log(`[PublicationFeed] Using cached results for publication search: ${query}`);
console.log(
`[PublicationFeed] Using cached results for publication search: ${query}`,
);
return cachedResult.events;
}
@ -182,7 +186,7 @@ @@ -182,7 +186,7 @@
}
return matches;
});
// Cache the filtered results
const result = {
events: filtered,
@ -190,11 +194,11 @@ @@ -190,11 +194,11 @@
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'publication',
searchTerm: query
searchType: "publication",
searchTerm: query,
};
searchCache.set('publication', query, result);
searchCache.set("publication", query, result);
console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered;
};
@ -252,11 +256,13 @@ @@ -252,11 +256,13 @@
// Watch for changes in include all relays setting
$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
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
});
@ -270,12 +276,17 @@ @@ -270,12 +276,17 @@
<!-- Include all relays checkbox -->
<div class="flex items-center justify-center">
<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)
</label>
</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}
{#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" />
@ -290,7 +301,7 @@ @@ -290,7 +301,7 @@
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class="flex justify-center mt-4 mb-8">
<Button
@ -317,4 +328,4 @@ @@ -317,4 +328,4 @@
>
</div>
{/if}
</div>
</div>

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

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
<script lang="ts">
import { ndkInstance } from '$lib/ndk';
import { naddrEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { standardRelays } from '../../consts';
import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from "../../consts";
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
@ -14,50 +14,57 @@ @@ -14,50 +14,57 @@
});
const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1];
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
return `publication?id=${naddrEncode(event, relays)}`;
}
}
);
});
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1",
);
let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
console.log("PublicationHeader event:", event);
</script>
{#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}
<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"/>
</div>
<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" />
</div>
{/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">
<a href="/{href}" class='flex flex-col space-y-2'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
<h3 class='text-base font-normal'>
<a href="/{href}" class="flex flex-col space-y-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}
</h3>
{#if version != '1'}
<h3 class='text-base font-thin'>version: {version}</h3>
{#if version != "1"}
<h3 class="text-base font-thin">version: {version}</h3>
{/if}
</a>
</div>
<div class="flex flex-col justify-start items-center">
<CardActions event={event} />
<CardActions {event} />
</div>
</div>
</Card>

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

@ -1,45 +1,54 @@ @@ -1,45 +1,54 @@
<script lang='ts'>
<script lang="ts">
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 { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
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";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: Array<NDKEvent | null>,
ref: (ref: HTMLElement) => void,
address: string;
rootAddress: string;
leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void;
} = $props();
const publicationTree: SveltePublicationTree = getContext('publicationTree');
const asciidoctor: Asciidoctor = getContext('asciidoctor');
const publicationTree: SveltePublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address));
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let publicationType: Promise<string | undefined> = $derived.by(
async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address));
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafTitle: Promise<string | undefined> = $derived.by(
async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
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 index: number;
@ -47,7 +56,7 @@ @@ -47,7 +56,7 @@
let decrement = 1;
do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address);
index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) {
return null;
}
@ -57,16 +66,21 @@ @@ -57,16 +66,21 @@
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
},
);
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][] = [];
if (!previousLeafHierarchyValue) {
@ -76,22 +90,26 @@ @@ -76,22 +90,26 @@
return branches;
}
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length);
const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress()
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
@ -106,17 +124,28 @@ @@ -106,17 +124,28 @@
});
</script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
<section
id={address}
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]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
{/await}
</section>

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

@ -1,22 +1,23 @@ @@ -1,22 +1,23 @@
<script lang='ts'>
import {
TableOfContents,
type TocEntry
} from '$lib/components/publications/table_of_contents.svelte';
import { getContext } from 'svelte';
import { SidebarDropdownWrapper, SidebarGroup, SidebarItem } from 'flowbite-svelte';
import Self from './TableOfContents.svelte';
<script lang="ts">
import {
TableOfContents,
type TocEntry,
} from "$lib/components/publications/table_of_contents.svelte";
import { getContext } from "svelte";
import {
SidebarDropdownWrapper,
SidebarGroup,
SidebarItem,
} from "flowbite-svelte";
import Self from "./TableOfContents.svelte";
let {
depth,
onSectionFocused,
} = $props<{
let { depth, onSectionFocused } = $props<{
rootAddress: string;
depth: number;
onSectionFocused?: (address: string) => void;
}>();
let toc = getContext('toc') as TableOfContents;
let toc = getContext("toc") as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => {
const newEntries = [];
@ -53,24 +54,17 @@ @@ -53,24 +54,17 @@
<SidebarItem
label={entry.title}
href={`#${address}`}
spanClass='px-2 text-ellipsis'
spanClass="px-2 text-ellipsis"
onclick={() => onSectionFocused?.(address)}
/>
{:else}
{@const childDepth = depth + 1}
<SidebarDropdownWrapper
label={entry.title}
btnClass='flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800'
bind:isOpen={
() => expanded,
(open) => setEntryExpanded(address, open)
}
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={() => expanded, (open) => setEntryExpanded(address, open)}
>
<Self
rootAddress={address}
depth={childDepth}
onSectionFocused={onSectionFocused}
/>
<Self rootAddress={address} depth={childDepth} {onSectionFocused} />
</SidebarDropdownWrapper>
{/if}
{/each}

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

@ -80,9 +80,9 @@ export class SveltePublicationTree { @@ -80,9 +80,9 @@ export class SveltePublicationTree {
/**
* Observer function that is invoked whenever a new node is resolved on the publication tree.
*
*
* @param address The address of the resolved node.
*
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
@ -91,13 +91,13 @@ export class SveltePublicationTree { @@ -91,13 +91,13 @@ export class SveltePublicationTree {
for (const observer of this.#nodeResolvedObservers) {
observer(address);
}
}
};
/**
* Observer function that is invoked whenever the bookmark is moved on the publication tree.
*
*
* @param address The address of the new bookmark.
*
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
@ -105,7 +105,7 @@ export class SveltePublicationTree { @@ -105,7 +105,7 @@ export class SveltePublicationTree {
for (const observer of this.#bookmarkMovedObservers) {
observer(address);
}
}
};
// #endregion
}
}

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

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts';
import type { NDKEvent } from '../../utils/nostrUtils.ts';
import { indexKind } from '../../consts.ts';
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts";
import type { NDKEvent } from "../../utils/nostrUtils.ts";
import { indexKind } from "../../consts.ts";
export interface TocEntry {
address: string;
@ -18,7 +18,7 @@ export interface TocEntry { @@ -18,7 +18,7 @@ export interface TocEntry {
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree.
*
*
* @see SveltePublicationTree
*/
export class TableOfContents {
@ -32,12 +32,16 @@ export class TableOfContents { @@ -32,12 +32,16 @@ export class TableOfContents {
/**
* Constructs a `TableOfContents` from a `SveltePublicationTree`.
*
*
* @param rootAddress The address of the root event.
* @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation.
*/
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) {
constructor(
rootAddress: string,
publicationTree: SveltePublicationTree,
pagePathname: string,
) {
this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname;
this.#init(rootAddress);
@ -47,7 +51,7 @@ export class TableOfContents { @@ -47,7 +51,7 @@ export class TableOfContents {
/**
* Returns the root entry of the ToC.
*
*
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized.
*/
getRootEntry(): TocEntry | null {
@ -60,21 +64,18 @@ export class TableOfContents { @@ -60,21 +64,18 @@ export class TableOfContents {
/**
* Builds a table of contents from the DOM subtree rooted at `parentElement`.
*
*
* @param parentElement The root of the DOM subtree containing the content to be added to the
* ToC.
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated
* by `parentElement`.
*
*
* This function is intended for use on segments of HTML markup that are not directly derived
* from a structure publication of the kind supported by `PublicationTree`. It may be used to
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or
* from a kind `30023` event with Markdown content.
*/
buildTocFromDocument(
parentElement: HTMLElement,
parentEntry: TocEntry,
) {
buildTocFromDocument(parentElement: HTMLElement, parentEntry: TocEntry) {
parentElement
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`)
.forEach((header) => {
@ -135,13 +136,13 @@ export class TableOfContents { @@ -135,13 +136,13 @@ export class TableOfContents {
/**
* Initializes the ToC from the associated publication tree.
*
*
* @param rootAddress The address of the publication's root event.
*
*
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the
* publication has been fully resolved.
*
*
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to
* initialize the ToC with all of its first-level children.
*/
@ -158,8 +159,8 @@ export class TableOfContents { @@ -158,8 +159,8 @@ export class TableOfContents {
// Handle any other nodes that have already been resolved in parallel.
await Promise.all(
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.
@ -171,10 +172,10 @@ export class TableOfContents { @@ -171,10 +172,10 @@ export class TableOfContents {
#getTitle(event: NDKEvent | null): string {
if (!event) {
// TODO: What do we want to return in this case?
return '[untitled]';
return "[untitled]";
}
const titleTag = event.getMatchingTags?.('title')?.[0]?.[1];
return titleTag || event.tagAddress() || '[untitled]';
const titleTag = event.getMatchingTags?.("title")?.[0]?.[1];
return titleTag || event.tagAddress() || "[untitled]";
}
async #buildTocEntry(address: string): Promise<TocEntry> {
@ -192,7 +193,9 @@ export class TableOfContents { @@ -192,7 +193,9 @@ export class TableOfContents {
return;
}
const childAddresses = await this.#publicationTree.getChildAddresses(entry.address);
const childAddresses = await this.#publicationTree.getChildAddresses(
entry.address,
);
for (const childAddress of childAddresses) {
if (!childAddress) {
continue;
@ -201,7 +204,7 @@ export class TableOfContents { @@ -201,7 +204,7 @@ export class TableOfContents {
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
// here so that we can determine whether to render an entry as a leaf before it is fully
// resolved.
if (childAddress.split(':')[0] !== indexKind.toString()) {
if (childAddress.split(":")[0] !== indexKind.toString()) {
this.leaves.add(childAddress);
}
@ -219,7 +222,7 @@ export class TableOfContents { @@ -219,7 +222,7 @@ export class TableOfContents {
await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true;
}
};
const event = await this.#publicationTree.getEvent(address);
if (!event) {
@ -246,23 +249,23 @@ export class TableOfContents { @@ -246,23 +249,23 @@ export class TableOfContents {
if (event.kind !== indexKind) {
this.leaves.add(address);
}
return entry;
}
/**
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding
* Nostr index event.
*
*
* @param entry The ToC entry to reorder.
*
*
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the
* parent event has. Average size of `n` is small enough to be negligible.
*/
async #matchChildrenToTagOrder(entry: TocEntry) {
const parentEvent = await this.#publicationTree.getEvent(entry.address);
if (parentEvent?.kind === indexKind) {
const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]);
const tagOrder = parentEvent.getMatchingTags("a").map((tag) => tag[1]);
const addressToOrdinal = new Map<string, number>();
// Build map of addresses to their ordinals from tag order
@ -271,8 +274,10 @@ export class TableOfContents { @@ -271,8 +274,10 @@ export class TableOfContents {
});
entry.children.sort((a, b) => {
const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
const aOrdinal =
addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal =
addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal;
});
}

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

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

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

@ -20,20 +20,42 @@ @@ -20,20 +20,42 @@
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe(val => user = val);
userStore.subscribe((val) => (user = val));
// Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? '');
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null);
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? '');
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null);
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? '');
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null);
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null);
let language = $derived(event.tags.find((t: string[]) => t[0] === 'language')?.[1] ?? null);
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null);
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null);
let title = $derived(
event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
);
let summary = $derived(
event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "",
);
let image = $derived(
event.tags.find((t: string[]) => t[0] === "image")?.[1] ?? null,
);
let author = $derived(
event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "",
);
let originalAuthor = $derived(
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null,
);
let version = $derived(
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "",
);
let source = $derived(
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null,
);
let type = $derived(
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null,
);
let language = $derived(
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null,
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state
let detailsModalOpen: boolean = $state(false);
@ -48,7 +70,7 @@ @@ -48,7 +70,7 @@
(() => {
const isUserFeed = user.signedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? user.relays.inbox : standardRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: user.signedIn,
@ -100,7 +122,7 @@ @@ -100,7 +122,7 @@
* Navigates to the event details page
*/
function viewEventDetails() {
const nevent = getIdentifier('nevent');
const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
</script>

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

@ -17,13 +17,19 @@ @@ -17,13 +17,19 @@
let lastEventId = $state<string | null>(null);
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;
error = null;
try {
containingIndexes = await findContainingIndexEvents(event);
console.log("[ContainingIndexes] Found containing indexes:", containingIndexes.length);
console.log(
"[ContainingIndexes] Found containing indexes:",
containingIndexes.length,
);
} catch (err) {
error =
err instanceof Error

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

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

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

@ -1,11 +1,15 @@ @@ -1,11 +1,15 @@
<script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logoutUser } from '$lib/stores/userStore';
import { ndkInstance } from '$lib/ndk';
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons";
import { logoutUser } from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import {
ArrowRightToBracketOutline,
UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from 'svelte/store';
import { get } from "svelte/store";
import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props();
@ -19,14 +23,13 @@ @@ -19,14 +23,13 @@
$effect(() => {
const ndk = get(ndkInstance);
if (!ndk) return;
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub;
user.fetchProfile()
.then((userProfile: NDKUserProfile | null) => {
profile = userProfile;
});
user.fetchProfile().then((userProfile: NDKUserProfile | null) => {
profile = userProfile;
});
});
async function handleSignOutClick() {

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

@ -40,26 +40,29 @@ @@ -40,26 +40,29 @@
return tag[1]; // Return the addressable event address
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event)
isAddressable: isAddressableEvent(event),
});
if (naddrAddress) {
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress);
console.log(
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
@ -77,4 +80,4 @@ @@ -77,4 +80,4 @@
>
View Publication
</button>
{/if}
{/if}

6
src/lib/consts.ts

@ -2,7 +2,11 @@ export const wikiKind = 30818; @@ -2,7 +2,11 @@ export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
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 = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",

209
src/lib/data_structures/publication_tree.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import type NDK from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { Lazy } from './lazy.ts';
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
enum PublicationTreeNodeType {
Branch,
@ -77,7 +77,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -77,7 +77,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
};
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.set(rootAddress, rootEvent);
@ -100,7 +103,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -100,7 +103,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
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> { @@ -131,7 +134,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
`PublicationTree: Parent node with address ${parentAddress} not found.`,
);
}
@ -154,22 +157,24 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -154,22 +157,24 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* Retrieves the addresses of the loaded children, if any, of the node with the given address.
*
*
* @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes.
*
*
* Note that this method resolves all children of the node.
*/
async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`[PublicationTree] Node with address ${address} not found.`);
throw new Error(
`[PublicationTree] Node with address ${address} not found.`,
);
}
return Promise.all(
node.children?.map(async child =>
(await child.value())?.address ?? null
) ?? []
node.children?.map(
async (child) => (await child.value())?.address ?? null,
) ?? [],
);
}
/**
@ -181,11 +186,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -181,11 +186,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value();
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)!];
while (node.parent) {
hierarchy.push(this.#events.get(node.parent.address)!);
node = node.parent;
@ -200,9 +207,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -200,9 +207,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
setBookmark(address: string) {
this.#bookmark = address;
this.#cursor.tryMoveTo(address).then(success => {
this.#cursor.tryMoveTo(address).then((success) => {
if (success) {
this.#bookmarkMovedObservers.forEach(observer => observer(address));
this.#bookmarkMovedObservers.forEach((observer) => observer(address));
}
});
}
@ -218,7 +225,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -218,7 +225,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* Registers an observer function that is invoked whenever a new node is resolved. Nodes are
* added lazily.
*
*
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
@ -227,7 +234,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -227,7 +234,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #region Iteration Cursor
#cursor = new class {
#cursor = new (class {
target: PublicationTreeNode | null | undefined;
#tree: PublicationTree;
@ -239,7 +246,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -239,7 +246,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) {
if (!address) {
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 {
this.target = await this.#tree.#nodes.get(address)?.value();
}
@ -253,7 +262,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -253,7 +262,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> {
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;
}
@ -264,32 +275,36 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -264,32 +275,36 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (this.target.children == null || this.target.children.length === 0) {
return false;
}
this.target = await this.target.children?.at(0)?.value();
return true;
}
async tryMoveToLastChild(): Promise<boolean> {
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;
}
if (this.target.type === PublicationTreeNodeType.Leaf) {
return false;
}
if (this.target.children == null || this.target.children.length === 0) {
return false;
}
this.target = await this.target.children?.at(-1)?.value();
return true;
}
async tryMoveToNextSibling(): Promise<boolean> {
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;
}
@ -300,7 +315,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -300,7 +315,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
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) {
@ -317,35 +333,40 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -317,35 +333,40 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> {
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;
}
const parent = this.target.parent;
const siblings = parent?.children;
if (!siblings) {
return false;
}
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) {
return false;
}
if (currentIndex <= 0) {
return false;
}
this.target = await siblings.at(currentIndex - 1)?.value();
return true;
}
tryMoveToParent(): boolean {
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;
}
@ -357,7 +378,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -357,7 +378,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent;
return true;
}
}(this);
})(this);
// #endregion
@ -369,13 +390,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -369,13 +390,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* Return the next event in the tree for the given traversal mode.
*
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The next event in the tree, or null if the tree is empty.
*/
async next(
mode: TreeTraversalMode = TreeTraversalMode.Leaves
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
@ -384,22 +405,22 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -384,22 +405,22 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
}
}
/**
* Return the previous event in the tree for the given traversal mode.
*
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The previous event in the tree, or null if the tree is empty.
*/
async previous(
mode: TreeTraversalMode = TreeTraversalMode.Leaves
mode: TreeTraversalMode = TreeTraversalMode.Leaves,
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
@ -409,37 +430,41 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -409,37 +430,41 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Backward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Backward);
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Backward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Backward);
}
}
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;
return { done, value };
}
/**
* Walks the tree in the given direction, yielding the event at each leaf.
*
*
* @param direction The direction to walk the tree.
* @returns The event at the leaf, or null if the tree is empty.
*
*
* Based on Raymond Chen's tree traversal algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
*/
async #walkLeaves(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
do {
if (await tryMoveToSibling()) {
while (await tryMoveToChild()) {
@ -464,23 +489,25 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -464,23 +489,25 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* Walks the tree in the given direction, yielding the event at each node.
*
*
* @param direction The direction to walk the tree.
* @returns The event at the node, or null if the tree is empty.
*
*
* Based on Raymond Chen's preorder walk algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304
*/
async #preorderWalkAll(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward
direction: TreeTraversalDirection = TreeTraversalDirection.Forward,
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
const tryMoveToSibling: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
if (await tryMoveToChild()) {
return this.#yieldEventAtCursor(false);
}
@ -517,17 +544,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -517,17 +544,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address];
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) {
const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value();
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!);
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.
@ -536,8 +569,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -536,8 +569,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a')
.map(tag => tag[1]);
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
@ -569,36 +602,38 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -569,36 +602,38 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#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);
this.#nodes.set(address, lazyNode);
this.#nodeAddedObservers.forEach(observer => observer(address));
this.#nodeAddedObservers.forEach((observer) => observer(address));
}
/**
* Resolves a node address into an event, and creates new nodes for its children.
*
*
* This method is intended for use as a {@link Lazy} resolver.
*
*
* @param address The address of the node to resolve.
* @param parentNode The parent node of the node to resolve.
* @returns The resolved node.
*/
async #resolveNode(
address: string,
parentNode: PublicationTreeNode
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':');
const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
'#d': [dTag],
"#d": [dTag],
});
if (!event) {
console.debug(
`[PublicationTree] Event with address ${address} not found.`
`[PublicationTree] Event with address ${address} not found.`,
);
return {
@ -612,8 +647,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -612,8 +647,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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 = {
type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
@ -626,13 +663,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -626,13 +663,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.addEventByAddress(address, event);
}
this.#nodeResolvedObservers.forEach(observer => observer(address));
this.#nodeResolvedObservers.forEach((observer) => observer(address));
return node;
}
#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;
}
@ -640,4 +677,4 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -640,4 +677,4 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
// #endregion
}
}

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

@ -168,11 +168,16 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -168,11 +168,16 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
debug("Processing tags for event", {
eventId: event.id,
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) => {
@ -295,7 +300,7 @@ export function processIndexEvent( @@ -295,7 +300,7 @@ export function processIndexEvent(
if (tags.length === 0) {
tags = getMatchingTags(indexEvent, "e");
}
const sequence = tags
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)

65
src/lib/ndk.ts

@ -5,12 +5,18 @@ import NDK, { @@ -5,12 +5,18 @@ import NDK, {
NDKRelaySet,
NDKUser,
NDKEvent,
} from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts';
import { feedType } from './stores';
import { userStore } from './stores/userStore';
import { userPubkey } from '$lib/stores/authStore.Svelte';
} from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store";
import {
fallbackRelays,
FeedType,
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 ndkSignedIn = writable(false);
@ -429,35 +435,37 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -429,35 +435,37 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
export function getActiveRelays(ndk: NDK): NDKRelaySet {
const user = get(userStore);
// Filter out problematic relays that are known to cause connection issues
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
return relays.filter((relay) => {
// 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}`);
return false;
}
return true;
});
};
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet(
new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk
new Set(
filterProblematicRelays(user.relays.inbox).map(
(relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk,
)
: new NDKRelaySet(
new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
))),
ndk
new Set(
filterProblematicRelays(standardRelays).map(
(relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk,
);
}
@ -490,9 +498,10 @@ export function initNdk(): NDK { @@ -490,9 +498,10 @@ export function initNdk(): NDK {
// Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
// Connect with better error handling
ndk.connect()
ndk
.connect()
.then(() => {
console.debug("[NDK.ts] NDK connected successfully");
})
@ -506,7 +515,7 @@ export function initNdk(): NDK { @@ -506,7 +515,7 @@ export function initNdk(): NDK {
});
}, 5000);
});
return ndk;
}
@ -604,8 +613,10 @@ export async function getUserPreferredRelays( @@ -604,8 +613,10 @@ export async function getUserPreferredRelays(
// Filter out problematic relays
const filterProblematicRelay = (url: string): boolean => {
if (url.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`);
if (url.includes("gitcitadel.nostr1.com")) {
console.warn(
`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`,
);
return false;
}
return true;

66
src/lib/services/publisher.ts

@ -1,9 +1,12 @@ @@ -1,9 +1,12 @@
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { getMimeTags } from '$lib/utils/mime';
import { parseAsciiDocSections, type ZettelSection } from '$lib/utils/ZettelParser';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { getMimeTags } from "$lib/utils/mime";
import {
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface PublishResult {
success: boolean;
@ -23,20 +26,22 @@ export interface PublishOptions { @@ -23,20 +26,22 @@ export interface PublishOptions {
* @param options - Publishing options
* @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;
if (!content.trim()) {
const error = 'Please enter some content';
const error = "Please enter some content";
onError?.(error);
return { success: false, error };
}
// Get the current NDK instance from the store
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
const error = 'Please log in first';
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
@ -44,9 +49,9 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -44,9 +49,9 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
try {
// Parse content into sections
const sections = parseAsciiDocSections(content, 2);
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
@ -55,19 +60,13 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -55,19 +60,13 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
const cleanContent = firstSection.content;
const sectionTags = firstSection.tags || [];
// Generate d-tag and create event
const dTag = generateDTag(title);
const [mTag, MTag] = getMimeTags(kind);
const tags: string[][] = [
['d', dTag],
mTag,
MTag,
['title', title]
];
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]];
if (sectionTags) {
tags.push(...sectionTags);
tags.push(...sectionTags);
}
// Create and sign NDK event
@ -81,25 +80,28 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -81,25 +80,28 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
await ndkEvent.sign();
// 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) {
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 publishedToRelays = await ndkEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
const result = { success: true, eventId: ndkEvent.id };
onSuccess?.(ndkEvent.id);
return result;
} else {
// Try fallback publishing logic here...
throw new Error('Failed to publish to any relays');
throw new Error("Failed to publish to any relays");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
@ -108,6 +110,6 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -108,6 +110,6 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
function generateDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}

46
src/lib/snippets/UserSnippets.svelte

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

13
src/lib/stores.ts

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

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

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

193
src/lib/stores/userStore.ts

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

103
src/lib/utils/ZettelParser.ts

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

52
src/lib/utils/community_checker.ts

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

252
src/lib/utils/event_input_utils.ts

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

38
src/lib/utils/indexEventCache.ts

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

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

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

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

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

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

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

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

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

19
src/lib/utils/mime.ts

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

246
src/lib/utils/nostrEventService.ts

@ -5,7 +5,7 @@ import { userRelays } from "$lib/stores/relayStore"; @@ -5,7 +5,7 @@ import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from "./search_constants";
export interface RootEventInfo {
rootId: string;
@ -44,16 +44,20 @@ function findTag(tags: string[][], tagName: string): string[] | undefined { @@ -44,16 +44,20 @@ function findTag(tags: string[][], tagName: string): string[] | undefined {
/**
* Helper function to get tag value safely
*/
function getTagValue(tags: string[][], tagName: string, index: number = 1): string {
function getTagValue(
tags: string[][],
tagName: string,
index: number = 1,
): string {
const tag = findTag(tags, tagName);
return tag?.[index] || '';
return tag?.[index] || "";
}
/**
* Helper function to create a tag array
*/
function createTag(name: string, ...values: (string | number)[]): string[] {
return [name, ...values.map(v => String(v))];
return [name, ...values.map((v) => String(v))];
}
/**
@ -72,36 +76,41 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -72,36 +76,41 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
rootPubkey: getPubkeyString(parent.pubkey),
rootRelay: getRelayString(parent.relay),
rootKind: parent.kind || 1,
rootAddress: '',
rootIValue: '',
rootIRelay: '',
rootAddress: "",
rootIValue: "",
rootIRelay: "",
isRootA: false,
isRootI: false,
};
if (!parent.tags) return rootInfo;
const rootE = findTag(parent.tags, 'E');
const rootA = findTag(parent.tags, 'A');
const rootI = findTag(parent.tags, 'I');
const rootE = findTag(parent.tags, "E");
const rootA = findTag(parent.tags, "A");
const rootI = findTag(parent.tags, "I");
rootInfo.isRootA = !!rootA;
rootInfo.isRootI = !!rootI;
if (rootE) {
rootInfo.rootId = rootE[1];
rootInfo.rootRelay = getRelayString(rootE[2]);
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootA) {
rootInfo.rootAddress = rootA[1];
rootInfo.rootRelay = getRelayString(rootA[2]);
rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootPubkey = getPubkeyString(
getTagValue(parent.tags, "P") || rootInfo.rootPubkey,
);
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
} else if (rootI) {
rootInfo.rootIValue = rootI[1];
rootInfo.rootIRelay = getRelayString(rootI[2]);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
rootInfo.rootKind =
Number(getTagValue(parent.tags, "K")) || rootInfo.rootKind;
}
return rootInfo;
@ -111,9 +120,11 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -111,9 +120,11 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
* Extract parent event information
*/
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
const dTag = getTagValue(parent.tags || [], 'd');
const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : '';
const dTag = getTagValue(parent.tags || [], "d");
const parentAddress = dTag
? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}`
: "";
return {
parentId: parent.id,
parentPubkey: getPubkeyString(parent.pubkey),
@ -126,45 +137,62 @@ export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo { @@ -126,45 +137,62 @@ export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
/**
* Build root scope tags for NIP-22 threading
*/
function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] {
function buildRootScopeTags(
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
): string[][] {
const tags: string[][] = [];
if (rootInfo.rootAddress) {
const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E';
addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay));
const tagType = rootInfo.isRootA ? "A" : rootInfo.isRootI ? "I" : "E";
addTags(
tags,
createTag(
tagType,
rootInfo.rootAddress || rootInfo.rootId,
rootInfo.rootRelay,
),
);
} else if (rootInfo.rootIValue) {
addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay));
addTags(tags, createTag("I", rootInfo.rootIValue, rootInfo.rootIRelay));
} else {
addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay));
addTags(tags, createTag("E", rootInfo.rootId, rootInfo.rootRelay));
}
addTags(tags, createTag('K', rootInfo.rootKind));
addTags(tags, createTag("K", rootInfo.rootKind));
if (rootInfo.rootPubkey && !rootInfo.rootIValue) {
addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay));
addTags(tags, createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay));
}
return tags;
}
/**
* Build parent scope tags for NIP-22 threading
*/
function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] {
function buildParentScopeTags(
parent: NDKEvent,
parentInfo: ParentEventInfo,
rootInfo: RootEventInfo,
): string[][] {
const tags: string[][] = [];
if (parentInfo.parentAddress) {
const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e';
addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay));
const tagType = rootInfo.isRootA ? "a" : rootInfo.isRootI ? "i" : "e";
addTags(
tags,
createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay),
);
}
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
return tags;
}
@ -175,63 +203,65 @@ export function buildReplyTags( @@ -175,63 +203,65 @@ export function buildReplyTags(
parent: NDKEvent,
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
kind: number
kind: number,
): string[][] {
const tags: string[][] = [];
const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentReplaceable =
parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN &&
parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
// Kind 1 replies use simple e/p tags
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay, 'root'),
createTag('p', parentInfo.parentPubkey)
createTag("e", parent.id, parentInfo.parentRelay, "root"),
createTag("p", parentInfo.parentPubkey),
);
// Add address for replaceable events
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
addTags(tags, createTag('a', parentAddress, '', 'root'));
addTags(tags, createTag("a", parentAddress, "", "root"));
}
}
} else {
// Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
const dTag = getTagValue(parent.tags || [], "d");
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
if (isReplyToComment) {
// Root scope (uppercase) - use the original article
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
);
// Parent scope (lowercase) - the comment we're replying to
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment - root and parent are the same
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('a', parentAddress, parentInfo.parentRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("A", parentAddress, parentInfo.parentRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("a", parentAddress, parentInfo.parentRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
} else {
@ -239,22 +269,22 @@ export function buildReplyTags( @@ -239,22 +269,22 @@ export function buildReplyTags(
if (isReplyToComment) {
addTags(
tags,
createTag('E', rootInfo.rootId, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("E", rootInfo.rootId, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
addTags(
tags,
createTag('E', parent.id, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("E", parent.id, rootInfo.rootRelay),
createTag("K", rootInfo.rootKind),
createTag("P", rootInfo.rootPubkey, rootInfo.rootRelay),
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
}
}
@ -265,9 +295,9 @@ export function buildReplyTags( @@ -265,9 +295,9 @@ export function buildReplyTags(
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
createTag("e", parent.id, parentInfo.parentRelay),
createTag("k", parentInfo.parentKind),
createTag("p", parentInfo.parentPubkey, parentInfo.parentRelay),
);
} else {
// Top-level comment or regular event
@ -287,23 +317,30 @@ export async function createSignedEvent( @@ -287,23 +317,30 @@ export async function createSignedEvent(
content: string,
pubkey: string,
kind: number,
tags: string[][]
tags: string[][],
): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content);
const eventToSign = {
kind: Number(kind),
created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
created_at: Number(
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR),
),
tags: tags.map((tag) => [
String(tag[0]),
String(tag[1]),
String(tag[2] || ""),
String(tag[3] || ""),
]),
content: String(prefixedContent),
pubkey: pubkey,
};
let sig, id;
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(eventToSign);
sig = signed.sig as string;
id = 'id' in signed ? signed.id as string : getEventHash(eventToSign);
id = "id" in signed ? (signed.id as string) : getEventHash(eventToSign);
} else {
id = getEventHash(eventToSign);
sig = await signEvent(eventToSign);
@ -316,16 +353,19 @@ export async function createSignedEvent( @@ -316,16 +353,19 @@ export async function createSignedEvent(
...eventToSign,
id,
sig,
}
},
};
}
/**
* 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);
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
@ -365,7 +405,7 @@ export async function publishEvent( @@ -365,7 +405,7 @@ export async function publishEvent(
signedEvent: any,
useOtherRelays = false,
useFallbackRelays = false,
userRelayPreference = false
userRelayPreference = false,
): Promise<EventPublishResult> {
// Determine which relays to use
let relays = userRelayPreference ? get(userRelays) : standardRelays;
@ -383,7 +423,7 @@ export async function publishEvent( @@ -383,7 +423,7 @@ export async function publishEvent(
return {
success: true,
relay: relayUrl,
eventId: signedEvent.id
eventId: signedEvent.id,
};
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
@ -392,7 +432,7 @@ export async function publishEvent( @@ -392,7 +432,7 @@ export async function publishEvent(
return {
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 { @@ -403,29 +443,29 @@ export function navigateToEvent(eventId: string): void {
try {
// Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn('Invalid event ID format:', eventId);
console.warn("Invalid event ID format:", eventId);
return;
}
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
} catch (error) {
console.error('Failed to encode event ID for navigation:', eventId, error);
console.error("Failed to encode event ID for navigation:", eventId, error);
}
}
// Helper functions to ensure relay and pubkey are always strings
function getRelayString(relay: any): string {
if (!relay) return '';
if (typeof relay === 'string') return relay;
if (typeof relay.url === 'string') return relay.url;
return '';
if (!relay) return "";
if (typeof relay === "string") return relay;
if (typeof relay.url === "string") return relay.url;
return "";
}
function getPubkeyString(pubkey: any): string {
if (!pubkey) return '';
if (typeof pubkey === 'string') return pubkey;
if (typeof pubkey.hex === 'function') return pubkey.hex();
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey;
return '';
}
if (!pubkey) return "";
if (typeof pubkey === "string") return pubkey;
if (typeof pubkey.hex === "function") return pubkey.hex();
if (typeof pubkey.pubkey === "string") return pubkey.pubkey;
return "";
}

136
src/lib/utils/nostrUtils.ts

@ -10,7 +10,7 @@ import { sha256 } from "@noble/hashes/sha256"; @@ -10,7 +10,7 @@ import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils";
import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants';
import { TIMEOUTS, VALIDATION } from "./search_constants";
const badgeCheckSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>';
@ -52,10 +52,13 @@ function escapeHtml(text: string): string { @@ -52,10 +52,13 @@ function escapeHtml(text: string): string {
/**
* 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
const cleanId = identifier.replace(/^nostr:/, '');
const cleanId = identifier.replace(/^nostr:/, "");
if (!force && npubCache.has(cleanId)) {
return npubCache.get(cleanId)!;
}
@ -125,7 +128,7 @@ export function createProfileLink( @@ -125,7 +128,7 @@ export function createProfileLink(
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
// Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
}
@ -156,24 +159,26 @@ export async function createProfileLinkWithVerification( @@ -156,24 +159,26 @@ export async function createProfileLinkWithVerification(
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
// Filter out problematic relays
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
if (relay.includes('gitcitadel.nostr1.com')) {
console.info(`[nostrUtils.ts] Filtering out problematic relay: ${relay}`);
return relays.filter((relay) => {
if (relay.includes("gitcitadel.nostr1.com")) {
console.info(
`[nostrUtils.ts] Filtering out problematic relay: ${relay}`,
);
return false;
}
return true;
});
};
const allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const filteredRelays = filterProblematicRelays(allRelays);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(filteredRelays, ndk);
const profileEvent = await ndk.fetchEvent(
@ -207,9 +212,9 @@ export async function createProfileLinkWithVerification( @@ -207,9 +212,9 @@ export async function createProfileLinkWithVerification(
// TODO: Make this work with an enum in case we add more types.
const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) {
case 'edu':
case "edu":
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>`;
}
}
@ -280,51 +285,59 @@ export async function processNostrIdentifiers( @@ -280,51 +285,59 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
// Parse the NIP-05 address
const [name, domain] = nip05.split('@');
const [name, domain] = nip05.split("@");
if (!name || !domain) {
console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05);
console.error("[getNpubFromNip05] Invalid NIP-05 format:", nip05);
return null;
}
// Fetch the well-known.json file with timeout and CORS handling
const url = wellKnownUrl(domain, name);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
try {
const response = await fetch(url, {
signal: controller.signal,
mode: 'cors',
mode: "cors",
headers: {
'Accept': 'application/json'
}
Accept: "application/json",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
console.error(
"[getNpubFromNip05] HTTP error:",
response.status,
response.statusText,
);
return null;
}
const data = await response.json();
// Try exact match first
let pubkey = data.names?.[name];
// If not found, try case-insensitive search
if (!pubkey && data.names) {
const names = Object.keys(data.names);
const matchingName = names.find(n => n.toLowerCase() === name.toLowerCase());
const matchingName = names.find(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (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) {
console.error('[getNpubFromNip05] No pubkey found for name:', name);
console.error("[getNpubFromNip05] No pubkey found for name:", name);
return null;
}
@ -333,10 +346,10 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -333,10 +346,10 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
return npub;
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.warn('[getNpubFromNip05] Request timeout for:', url);
if (fetchError instanceof Error && fetchError.name === "AbortError") {
console.warn("[getNpubFromNip05] Request timeout for:", url);
} else {
console.warn('[getNpubFromNip05] CORS or network error for:', url);
console.warn("[getNpubFromNip05] CORS or network error for:", url);
}
return null;
}
@ -414,7 +427,7 @@ export async function fetchEventWithFallback( @@ -414,7 +427,7 @@ export async function fetchEventWithFallback(
? Array.from(ndk.pool?.relays.values() || [])
.filter((r) => r.status === 1) // Only use connected relays
.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
@ -442,7 +455,7 @@ export async function fetchEventWithFallback( @@ -442,7 +455,7 @@ export async function fetchEventWithFallback(
if (
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
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
@ -512,7 +525,7 @@ export async function fetchEventWithFallback( @@ -512,7 +525,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(pubkey)) {
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith("npub1")) return pubkey;
@ -575,76 +588,89 @@ export async function signEvent(event: { @@ -575,76 +588,89 @@ export async function signEvent(event: {
}
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink
*/
export function prefixNostrAddresses(content: string): string {
// Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link
// Must be followed by at least 20 alphanumeric characters to be considered an address
const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
const nostrAddressPattern =
/\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url)
const beforeMatch = content.substring(0, offset);
const afterMatch = content.substring(offset + match.length);
// Check if it's part of a markdown link
const beforeBrackets = beforeMatch.lastIndexOf('[');
const afterParens = afterMatch.indexOf(')');
const beforeBrackets = beforeMatch.lastIndexOf("[");
const afterParens = afterMatch.indexOf(")");
if (beforeBrackets !== -1 && afterParens !== -1) {
const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
const lastOpenBracket = textBeforeBrackets.lastIndexOf('[');
const lastCloseBracket = textBeforeBrackets.lastIndexOf(']');
const lastOpenBracket = textBeforeBrackets.lastIndexOf("[");
const lastCloseBracket = textBeforeBrackets.lastIndexOf("]");
// If we have [text] before this, it might be a markdown link
if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
return match; // Don't prefix if it's part of a markdown link
}
}
// Check if it's part of an HTML link
const beforeHref = beforeMatch.lastIndexOf('href=');
const beforeHref = beforeMatch.lastIndexOf("href=");
if (beforeHref !== -1) {
const afterHref = afterMatch.indexOf('"');
if (afterHref !== -1) {
return match; // Don't prefix if it's part of an HTML link
}
}
// Check if it's already prefixed with "nostr:"
const beforeNostr = beforeMatch.lastIndexOf('nostr:');
const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(' ')) {
if (!textAfterNostr.includes(" ")) {
return match; // Already prefixed
}
}
// Additional check: ensure it's actually a valid Nostr address format
// The part after the prefix should be a valid bech32 string
const addressPart = match.substring(4); // Remove npub, nprofile, etc.
if (addressPart.length < 20) {
return match; // Too short to be a valid address
}
// Check if it looks like a valid bech32 string (alphanumeric, no special chars)
if (!/^[a-zA-Z0-9]+$/.test(addressPart)) {
return match; // Not a valid bech32 format
}
// Additional check: ensure the word before is not a common word that would indicate
// this is just a general reference, not an actual address
const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
if (wordBefore) {
const beforeWord = wordBefore[1].toLowerCase();
const commonWords = ['the', 'a', 'an', 'this', 'that', 'my', 'your', 'his', 'her', 'their', 'our'];
const commonWords = [
"the",
"a",
"an",
"this",
"that",
"my",
"your",
"his",
"her",
"their",
"our",
];
if (commonWords.includes(beforeWord)) {
return match; // Likely just a general reference, not an actual address
}
}
// Prefix with "nostr:"
return `nostr:${match}`;
});

331
src/lib/utils/profile_search.ts

@ -1,61 +1,79 @@ @@ -1,61 +1,79 @@
import { ndkInstance } from '$lib/ndk';
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { standardRelays, fallbackRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
import { checkCommunityStatus } from './community_checker';
import { TIMEOUTS } from './search_constants';
import { ndkInstance } from "$lib/ndk";
import { getUserMetadata, getNpubFromNip05 } from "$lib/utils/nostrUtils";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types";
import {
fieldMatches,
nip05Matches,
normalizeSearchTerm,
COMMON_DOMAINS,
createProfileFromEvent,
} from "./search_utils";
import { checkCommunityStatus } from "./community_checker";
import { TIMEOUTS } from "./search_constants";
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
export async function searchProfiles(
searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log('searchProfiles called with:', searchTerm, 'normalized:', normalizedSearchTerm);
console.log(
"searchProfiles called with:",
searchTerm,
"normalized:",
normalizedSearchTerm,
);
// Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm);
const cachedResult = searchCache.get("profile", normalizedSearchTerm);
if (cachedResult) {
console.log('Found cached result for:', normalizedSearchTerm);
const profiles = cachedResult.events.map(event => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
}).filter(Boolean) as NostrProfile[];
console.log('Cached profiles found:', profiles.length);
console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
})
.filter(Boolean) as NostrProfile[];
console.log("Cached profiles found:", profiles.length);
return { profiles, Status: {} };
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error('NDK not initialized');
throw new Error('NDK not initialized');
console.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[] = [];
try {
// Check if it's a valid npub/nprofile first
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) {
if (
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile")
) {
try {
const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) {
foundProfiles = [metadata];
}
} catch (error) {
console.error('Error fetching metadata for npub:', error);
console.error("Error fetching metadata for npub:", error);
}
} else if (normalizedSearchTerm.includes('@')) {
} else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try {
@ -64,53 +82,60 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -64,53 +82,60 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
pubkey: npub,
};
foundProfiles = [profile];
}
} catch (e) {
console.error('[Search] NIP-05 lookup failed:', e);
console.error("[Search] NIP-05 lookup failed:", e);
}
} else {
// 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);
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 (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);
console.log('Quick relay search completed, found:', foundProfiles.length, 'profiles');
console.log(
"Quick relay search completed, found:",
foundProfiles.length,
"profiles",
);
}
}
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => {
const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || '';
event.pubkey = profile.pubkey || "";
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'profile',
searchTerm: normalizedSearchTerm
searchType: "profile",
searchTerm: normalizedSearchTerm,
};
searchCache.set('profile', normalizedSearchTerm, result);
searchCache.set("profile", normalizedSearchTerm, result);
}
console.log('Search completed, found profiles:', foundProfiles.length);
console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
} catch (error) {
console.error('Error searching profiles:', error);
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
}
}
@ -118,84 +143,100 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -118,84 +143,100 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
async function searchNip05Domains(
searchTerm: string,
ndk: any,
): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups
// Prioritize gitcitadel.com since we know it has profiles
const commonDomains = [
'gitcitadel.com', // Prioritize this domain
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host',
'damus.io',
'snort.social',
'iris.to',
'coracle.social',
'nostr.band',
'nostr.wine',
'purplepag.es',
'relay.noswhere.com',
'aggr.nostr.land',
'nostr.sovbit.host',
'freelay.sovbit.host',
'nostr21.com',
'greensoul.space',
'relay.damus.io',
'relay.nostr.band'
"gitcitadel.com", // Prioritize this domain
"theforest.nostr1.com",
"nostr1.com",
"nostr.land",
"sovbit.host",
"damus.io",
"snort.social",
"iris.to",
"coracle.social",
"nostr.band",
"nostr.wine",
"purplepag.es",
"relay.noswhere.com",
"aggr.nostr.land",
"nostr.sovbit.host",
"freelay.sovbit.host",
"nostr21.com",
"greensoul.space",
"relay.damus.io",
"relay.nostr.band",
];
// Normalize the search term for NIP-05 lookup
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log('NIP-05 search: normalized search term:', normalizedSearchTerm);
console.log("NIP-05 search: normalized search term:", normalizedSearchTerm);
// Try gitcitadel.com first with extra debugging
const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`;
console.log('NIP-05 search: trying gitcitadel.com first:', gitcitadelAddress);
console.log("NIP-05 search: trying gitcitadel.com first:", gitcitadelAddress);
try {
const npub = await getNpubFromNip05(gitcitadelAddress);
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 profile: NostrProfile = {
...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);
return foundProfiles; // Return immediately if we found it on gitcitadel.com
} 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) {
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
console.log('NIP-05 search: gitcitadel.com failed, trying other domains...');
const otherDomains = commonDomains.filter(domain => domain !== 'gitcitadel.com');
console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
console.log('NIP-05 search: trying address:', nip05Address);
console.log("NIP-05 search: trying address:", nip05Address);
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
console.log('NIP-05 search: found npub for', nip05Address, ':', npub);
console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...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;
} else {
console.log('NIP-05 search: no npub found for', nip05Address);
console.log("NIP-05 search: no npub found for", nip05Address);
}
} catch (e) {
console.log('NIP-05 search: error for', nip05Address, ':', e);
console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain
}
return null;
@ -203,87 +244,109 @@ async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrPr @@ -203,87 +244,109 @@ async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrPr
// Wait for all searches with timeout
const results = await Promise.allSettled(searchPromises);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
if (result.status === "fulfilled" && 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;
}
/**
* Quick relay search with short timeout
*/
async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
console.log('quickRelaySearch called with:', searchTerm);
async function quickRelaySearch(
searchTerm: string,
ndk: any,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm);
const foundProfiles: NostrProfile[] = [];
// Normalize the search term for relay search
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
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
const relaySets = quickRelayUrls.map(url => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e);
return null;
}
}).filter(Boolean);
const relaySets = quickRelayUrls
.map((url) => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e);
return null;
}
})
.filter(Boolean);
// Search all relays in parallel with short timeout
const searchPromises = relaySets.map(async (relaySet, index) => {
if (!relaySet) return [];
return new Promise<NostrProfile[]>((resolve) => {
const foundInRelay: NostrProfile[] = [];
let eventCount = 0;
console.log(`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`);
console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`,
);
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true, relaySet }
{ closeOnEose: true, relaySet },
);
sub.on('event', (event: NDKEvent) => {
sub.on("event", (event: NDKEvent) => {
eventCount++;
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || '';
const display_name = profileData.display_name || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const about = profileData.about || '';
const displayName =
profileData.displayName || profileData.display_name || "";
const display_name = profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm);
const matchesDisplayName = fieldMatches(
displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) {
if (
matchesDisplayName ||
matchesDisplay_name ||
matchesName ||
matchesNip05 ||
matchesAbout
) {
console.log(`Found matching profile on relay ${index + 1}:`, {
name: profileData.name,
display_name: profileData.display_name,
nip05: profileData.nip05,
pubkey: event.pubkey,
searchTerm: normalizedSearchTerm
searchTerm: normalizedSearchTerm,
});
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile in this relay
const existingIndex = foundInRelay.findIndex(p => p.pubkey === event.pubkey);
const existingIndex = foundInRelay.findIndex(
(p) => p.pubkey === event.pubkey,
);
if (existingIndex === -1) {
foundInRelay.push(profile);
}
@ -293,14 +356,18 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -293,14 +356,18 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
}
});
sub.on('eose', () => {
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`);
sub.on("eose", () => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
resolve(foundInRelay);
});
// Short timeout for quick search
setTimeout(() => {
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`);
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
sub.stop();
resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay
@ -309,12 +376,12 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -309,12 +376,12 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
// Wait for all searches to complete
const results = await Promise.allSettled(searchPromises);
// Combine and deduplicate results
const allProfiles: Record<string, NostrProfile> = {};
for (const result of results) {
if (result.status === 'fulfilled') {
if (result.status === "fulfilled") {
for (const profile of result.value) {
if (profile.pubkey) {
allProfiles[profile.pubkey] = profile;
@ -322,7 +389,9 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -322,7 +389,9 @@ 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);
}
}

64
src/lib/utils/relayDiagnostics.ts

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

16
src/lib/utils/searchCache.ts

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

46
src/lib/utils/search_constants.ts

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

8
src/lib/utils/search_types.ts

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

32
src/lib/utils/search_utility.ts

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

37
src/lib/utils/search_utils.ts

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

553
src/lib/utils/subscription_search.ts

@ -1,13 +1,25 @@ @@ -1,13 +1,25 @@
import { ndkInstance } from '$lib/ndk';
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils';
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants';
import { ndkInstance } from "$lib/ndk";
import { getMatchingTags, getNpubFromNip05 } from "$lib/utils/nostrUtils";
import { nip19 } from "$lib/utils/nostrUtils";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache";
import { communityRelay, profileRelays } from "$lib/consts";
import { get } from "svelte/store";
import type {
SearchResult,
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)
@ -16,12 +28,16 @@ export async function searchBySubscription( @@ -16,12 +28,16 @@ export async function searchBySubscription(
searchType: SearchSubscriptionType,
searchTerm: string,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
): Promise<SearchResult> {
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
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
@ -32,7 +48,7 @@ export async function searchBySubscription( @@ -32,7 +48,7 @@ export async function searchBySubscription(
const ndk = get(ndkInstance);
if (!ndk) {
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");
@ -49,49 +65,98 @@ export async function searchBySubscription( @@ -49,49 +65,98 @@ export async function searchBySubscription(
if (abortSignal?.aborted) {
console.log("subscription_search: Search aborted");
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);
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
if (primaryRelaySet.relays.size > 0) {
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(
searchFilter.filter,
{ closeOnEose: true },
primaryRelaySet
primaryRelaySet,
);
console.log("subscription_search: Primary relay returned", primaryEvents.size, "events");
processPrimaryRelayResults(primaryEvents, searchType, searchFilter.subscriptionType, normalizedSearchTerm, searchState, abortSignal, cleanup);
console.log(
"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 (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from primary relay, returning immediately");
const immediateResult = createSearchResult(searchState, searchType, normalizedSearchTerm);
console.log(
"subscription_search: Found results from primary relay, returning immediately",
);
const immediateResult = createSearchResult(
searchState,
searchType,
normalizedSearchTerm,
);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
abortSignal,
cleanup,
);
return immediateResult;
} 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) {
console.error(`subscription_search: Error searching primary relay:`, error);
console.error(
`subscription_search: Error searching primary relay:`,
error,
);
}
} 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
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
return searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
abortSignal,
cleanup,
);
}
/**
@ -107,7 +172,7 @@ function createSearchState() { @@ -107,7 +172,7 @@ function createSearchState() {
eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[],
isCompleted: false,
currentSubscription: null as any
currentSubscription: null as any,
};
}
@ -124,7 +189,7 @@ function createCleanupFunction(searchState: any) { @@ -124,7 +189,7 @@ function createCleanupFunction(searchState: any) {
try {
searchState.currentSubscription.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
searchState.currentSubscription = null;
}
@ -134,25 +199,31 @@ function createCleanupFunction(searchState: any) { @@ -134,25 +199,31 @@ function createCleanupFunction(searchState: any) {
/**
* Create search filter based on search type
*/
async function createSearchFilter(searchType: SearchSubscriptionType, normalizedSearchTerm: string): Promise<SearchFilter> {
console.log("subscription_search: Creating search filter for:", { searchType, normalizedSearchTerm });
async function createSearchFilter(
searchType: SearchSubscriptionType,
normalizedSearchTerm: string,
): Promise<SearchFilter> {
console.log("subscription_search: Creating search filter for:", {
searchType,
normalizedSearchTerm,
});
switch (searchType) {
case 'd':
case "d":
const dFilter = {
filter: { "#d": [normalizedSearchTerm] },
subscriptionType: 'd-tag'
subscriptionType: "d-tag",
};
console.log("subscription_search: Created d-tag filter:", dFilter);
return dFilter;
case 't':
case "t":
const tFilter = {
filter: { "#t": [normalizedSearchTerm] },
subscriptionType: 't-tag'
subscriptionType: "t-tag",
};
console.log("subscription_search: Created t-tag filter:", tFilter);
return tFilter;
case 'n':
case "n":
const nFilter = await createProfileSearchFilter(normalizedSearchTerm);
console.log("subscription_search: Created profile filter:", nFilter);
return nFilter;
@ -164,14 +235,20 @@ async function createSearchFilter(searchType: SearchSubscriptionType, normalized @@ -164,14 +235,20 @@ async function createSearchFilter(searchType: SearchSubscriptionType, normalized
/**
* 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
try {
const decoded = nip19.decode(normalizedSearchTerm);
if (decoded && decoded.type === 'npub') {
if (decoded && decoded.type === "npub") {
return {
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'npub-specific'
filter: {
kinds: [0],
authors: [decoded.data],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
},
subscriptionType: "npub-specific",
};
}
} catch (e) {
@ -186,8 +263,12 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise< @@ -186,8 +263,12 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
return {
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'nip05-found'
filter: {
kinds: [0],
authors: [npub],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
},
subscriptionType: "nip05-found",
};
}
} catch (e) {
@ -200,24 +281,32 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise< @@ -200,24 +281,32 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<
return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: 'profile'
subscriptionType: "profile",
};
}
/**
* Create primary relay set based on search type
*/
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet {
if (searchType === 'n') {
function createPrimaryRelaySet(
searchType: SearchSubscriptionType,
ndk: any,
): NDKRelaySet {
if (searchType === "n") {
// For profile searches, use profile relays first
const profileRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/')
const profileRelaySet = Array.from(ndk.pool.relays.values()).filter(
(relay: any) =>
profileRelays.some(
(profileRelay) =>
relay.url === profileRelay || relay.url === profileRelay + "/",
),
);
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
} else {
// For other searches, use community relay first
const communityRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + '/'
const communityRelaySet = Array.from(ndk.pool.relays.values()).filter(
(relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + "/",
);
return new NDKRelaySet(new Set(communityRelaySet) as any, ndk);
}
@ -233,20 +322,29 @@ function processPrimaryRelayResults( @@ -233,20 +322,29 @@ function processPrimaryRelayResults(
normalizedSearchTerm: string,
searchState: any,
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) {
// Check for abort signal
if (abortSignal?.aborted) {
cleanup?.();
throw new Error('Search cancelled');
throw new Error("Search cancelled");
}
try {
if (searchType === 'n') {
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState);
if (searchType === "n") {
processProfileEvent(
event,
subscriptionType,
normalizedSearchTerm,
searchState,
);
} else {
processContentEvent(event, searchType, searchState);
}
@ -255,32 +353,47 @@ function processPrimaryRelayResults( @@ -255,32 +353,47 @@ function processPrimaryRelayResults(
// Invalid JSON or other error, skip
}
}
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
*/
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 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);
return;
}
// For general profile searches, filter by content
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const username = profileData.username || '';
const about = profileData.about || '';
const bio = profileData.bio || '';
const description = profileData.description || '';
const displayName = profileData.display_name || profileData.displayName || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const username = profileData.username || "";
const about = profileData.about || "";
const bio = profileData.bio || "";
const description = profileData.description || "";
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
@ -288,8 +401,16 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz @@ -288,8 +401,16 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
const matchesBio = fieldMatches(bio, 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);
}
}
@ -297,13 +418,21 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz @@ -297,13 +418,21 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz
/**
* 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 (searchType === 'd') {
console.log("subscription_search: Processing d-tag event:", { id: event.id, kind: event.kind, pubkey: event.pubkey });
if (searchType === "d") {
console.log("subscription_search: Processing d-tag event:", {
id: event.id,
kind: event.kind,
pubkey: event.pubkey,
});
searchState.firstOrderEvents.push(event);
// Collect event IDs and addresses for second-order search
if (event.id) {
searchState.eventIds.add(event.id);
@ -313,13 +442,13 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType @@ -313,13 +442,13 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
tags.forEach((tag: string[]) => {
if (tag[1]) {
searchState.eventAddresses.add(tag[1]);
}
});
} else if (searchType === 't') {
} else if (searchType === "t") {
searchState.tTagEvents.push(event);
}
}
@ -327,12 +456,15 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType @@ -327,12 +456,15 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
/**
* Check if search state has results
*/
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean {
if (searchType === 'n') {
function hasResults(
searchState: any,
searchType: SearchSubscriptionType,
): boolean {
if (searchType === "n") {
return searchState.foundProfiles.length > 0;
} else if (searchType === 'd') {
} else if (searchType === "d") {
return searchState.firstOrderEvents.length > 0;
} else if (searchType === 't') {
} else if (searchType === "t") {
return searchState.tTagEvents.length > 0;
}
return false;
@ -341,15 +473,24 @@ function hasResults(searchState: any, searchType: SearchSubscriptionType): boole @@ -341,15 +473,24 @@ function hasResults(searchState: any, searchType: SearchSubscriptionType): boole
/**
* Create search result from state
*/
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult {
function createSearchResult(
searchState: any,
searchType: SearchSubscriptionType,
normalizedSearchTerm: string,
): SearchResult {
return {
events: searchType === 'n' ? searchState.foundProfiles : searchType === 't' ? searchState.tTagEvents : searchState.firstOrderEvents,
events:
searchType === "n"
? searchState.foundProfiles
: searchType === "t"
? searchState.tTagEvents
: searchState.firstOrderEvents,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: normalizedSearchTerm
searchTerm: normalizedSearchTerm,
};
}
@ -362,42 +503,54 @@ async function searchOtherRelaysInBackground( @@ -362,42 +503,54 @@ async function searchOtherRelaysInBackground(
searchState: any,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal,
cleanup?: () => void
cleanup?: () => void,
): Promise<SearchResult> {
const ndk = get(ndkInstance);
const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === 'n') {
// For profile searches, exclude profile relays from fallback search
return !profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/');
} else {
// For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/';
}
})),
ndk
new Set(
Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === "n") {
// For profile searches, exclude profile relays from fallback search
return !profileRelays.some(
(profileRelay) =>
relay.url === profileRelay || relay.url === profileRelay + "/",
);
} else {
// For other searches, exclude community relay from fallback search
return (
relay.url !== communityRelay && relay.url !== communityRelay + "/"
);
}
}),
),
ndk,
);
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,
{ closeOnEose: true },
otherRelays
otherRelays,
);
// Store the subscription for cleanup
searchState.currentSubscription = sub;
// Notify the component about the subscription for cleanup
if (callbacks?.onSubscriptionCreated) {
callbacks.onSubscriptionCreated(sub);
}
sub.on('event', (event: NDKEvent) => {
sub.on("event", (event: NDKEvent) => {
try {
if (searchType === 'n') {
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState);
if (searchType === "n") {
processProfileEvent(
event,
searchFilter.subscriptionType,
searchState.normalizedSearchTerm,
searchState,
);
} else {
processContentEvent(event, searchType, searchState);
}
@ -407,8 +560,13 @@ async function searchOtherRelaysInBackground( @@ -407,8 +560,13 @@ async function searchOtherRelaysInBackground(
});
return new Promise<SearchResult>((resolve) => {
sub.on('eose', () => {
const result = processEoseResults(searchType, searchState, searchFilter, callbacks);
sub.on("eose", () => {
const result = processEoseResults(
searchType,
searchState,
searchFilter,
callbacks,
);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
@ -423,27 +581,31 @@ function processEoseResults( @@ -423,27 +581,31 @@ function processEoseResults(
searchType: SearchSubscriptionType,
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks
callbacks?: SearchCallbacks,
): SearchResult {
if (searchType === 'n') {
if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === 'd') {
} else if (searchType === "d") {
return processContentEoseResults(searchState, searchType);
} else if (searchType === 't') {
} else if (searchType === "t") {
return processTTagEoseResults(searchState);
}
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
/**
* 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) {
return createEmptySearchResult('n', searchState.normalizedSearchTerm);
return createEmptySearchResult("n", searchState.normalizedSearchTerm);
}
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.foundProfiles) {
@ -453,63 +615,91 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, @@ -453,63 +615,91 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter,
deduped[pubkey] = { event, created_at };
}
}
// Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at)
.map(x => x.event);
.map((x) => x.event);
// 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;
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 (const profile of dedupedProfiles) {
if (profile.pubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks);
performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
new Set(),
new Set(),
profile.pubkey,
callbacks,
);
}
}
}
return {
events: dedupedProfiles,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(dedupedProfiles.map(p => p.id)),
eventIds: new Set(dedupedProfiles.map((p) => p.id)),
addresses: new Set(),
searchType: 'n',
searchTerm: searchState.normalizedSearchTerm
searchType: "n",
searchTerm: searchState.normalizedSearchTerm,
};
}
/**
* Process content EOSE results
*/
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult {
function processContentEoseResults(
searchState: any,
searchType: SearchSubscriptionType,
): SearchResult {
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
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
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 created_at = event.created_at || 0;
if (!deduped[key] || deduped[key].created_at < 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
if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses);
performSecondOrderSearchInBackground(
"d",
dedupedEvents,
searchState.eventIds,
searchState.eventAddresses,
);
}
return {
events: dedupedEvents,
secondOrder: [],
@ -517,7 +707,7 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript @@ -517,7 +707,7 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: searchState.normalizedSearchTerm
searchTerm: searchState.normalizedSearchTerm,
};
}
@ -526,24 +716,27 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript @@ -526,24 +716,27 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript
*/
function processTTagEoseResults(searchState: any): SearchResult {
if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult('t', searchState.normalizedSearchTerm);
return createEmptySearchResult("t", searchState.normalizedSearchTerm);
}
return {
events: searchState.tTagEvents,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: 't',
searchTerm: searchState.normalizedSearchTerm
searchType: "t",
searchTerm: searchState.normalizedSearchTerm,
};
}
/**
* Create empty search result
*/
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult {
function createEmptySearchResult(
searchType: SearchSubscriptionType,
searchTerm: string,
): SearchResult {
return {
events: [],
secondOrder: [],
@ -551,7 +744,7 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: @@ -551,7 +744,7 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm:
eventIds: new Set(),
addresses: new Set(),
searchType: searchType,
searchTerm: searchTerm
searchTerm: searchTerm,
};
}
@ -559,12 +752,12 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: @@ -559,12 +752,12 @@ function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm:
* Perform second-order search in background
*/
async function performSecondOrderSearchInBackground(
searchType: 'n' | 'd',
searchType: "n" | "d",
firstOrderEvents: NDKEvent[],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
targetPubkey?: string,
callbacks?: SearchCallbacks
callbacks?: SearchCallbacks,
) {
try {
const ndk = get(ndkInstance);
@ -572,49 +765,65 @@ async function performSecondOrderSearchInBackground( @@ -572,49 +765,65 @@ async function performSecondOrderSearchInBackground(
// Set a timeout for second-order search
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 () => {
if (searchType === 'n' && targetPubkey) {
if (searchType === "n" && targetPubkey) {
// Search for events that mention this pubkey via p-tags
const pTagFilter = { '#p': [targetPubkey] };
const pTagFilter = { "#p": [targetPubkey] };
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// 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];
} else if (searchType === 'd') {
} else if (searchType === "d") {
// 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([
eventIds.size > 0
? ndk.fetchEvents(
{ '#e': Array.from(eventIds) },
{ "#e": Array.from(eventIds) },
{ closeOnEose: true },
relaySet
relaySet,
)
: Promise.resolve([]),
addresses.size > 0
? ndk.fetchEvents(
{ '#a': Array.from(addresses) },
{ "#a": Array.from(addresses) },
{ closeOnEose: true },
relaySet
relaySet,
)
: Promise.resolve([]),
]);
// Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event));
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents, ...filteredATagEvents];
const filteredETagEvents = Array.from(eTagEvents).filter(
(event) => !isEmojiReaction(event),
);
const filteredATagEvents = Array.from(aTagEvents).filter(
(event) => !isEmojiReaction(event),
);
allSecondOrderEvents = [
...allSecondOrderEvents,
...filteredETagEvents,
...filteredATagEvents,
];
}
// Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach(event => {
allSecondOrderEvents.forEach((event) => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
@ -623,8 +832,10 @@ async function performSecondOrderSearchInBackground( @@ -623,8 +832,10 @@ async function performSecondOrderSearchInBackground(
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Remove any events already in first order
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id));
const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(
(e) => !firstOrderIds.has(e.id),
);
// Sort by creation date (newest first) and limit to newest results
const sortedSecondOrder = deduplicatedSecondOrder
@ -636,10 +847,13 @@ async function performSecondOrderSearchInBackground( @@ -636,10 +847,13 @@ async function performSecondOrderSearchInBackground(
events: firstOrderEvents,
secondOrder: sortedSecondOrder,
tTagEvents: [],
eventIds: searchType === 'n' ? new Set(firstOrderEvents.map(p => p.id)) : eventIds,
addresses: searchType === 'n' ? new Set() : addresses,
eventIds:
searchType === "n"
? new Set(firstOrderEvents.map((p) => p.id))
: eventIds,
addresses: searchType === "n" ? new Set() : addresses,
searchType: searchType,
searchTerm: '' // This will be set by the caller
searchTerm: "", // This will be set by the caller
};
// Notify UI of updated results
@ -651,6 +865,9 @@ async function performSecondOrderSearchInBackground( @@ -651,6 +865,9 @@ async function performSecondOrderSearchInBackground(
// Race between search and timeout
await Promise.race([searchPromise, timeoutPromise]);
} catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err);
console.error(
`[Search] Error in second-order ${searchType}-tag search:`,
err,
);
}
}
}

141
src/routes/+layout.ts

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

26
src/routes/+page.svelte

@ -1,17 +1,14 @@ @@ -1,17 +1,14 @@
<script lang="ts">
import {
standardRelays,
fallbackRelays,
} from "$lib/consts";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { Alert, Input } from "flowbite-svelte";
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 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);
userStore.subscribe(val => user = val);
userStore.subscribe((val) => (user = val));
</script>
<Alert
@ -26,13 +23,20 @@ @@ -26,13 +23,20 @@
</span>
</Alert>
<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'>
<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"
>
<Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
</div>
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} userRelays={$ndkSignedIn ? $inboxRelays : []} />
<PublicationFeed
relays={standardRelays}
{fallbackRelays}
{searchQuery}
userRelays={$ndkSignedIn ? $inboxRelays : []}
/>
</main>

8
src/routes/about/+page.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from '$app/navigation';
import { goto } from "$app/navigation";
import RelayStatus from "$lib/components/RelayStatus.svelte";
// Get the git tag version from environment variables
@ -36,7 +36,11 @@ @@ -36,7 +36,11 @@
</P>
<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"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"

25
src/routes/contact/+page.svelte

@ -1,10 +1,19 @@ @@ -1,10 +1,19 @@
<script lang="ts">
import { Heading, P, A, Button, 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';
import {
Heading,
P,
A,
Button,
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
import LoginModal from "$lib/components/LoginModal.svelte";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
@ -44,10 +53,10 @@ @@ -44,10 +53,10 @@
subject: "",
content: "",
};
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe(val => user = val);
userStore.subscribe((val) => (user = val));
// Repository event address from the task
const repoAddress =

324
src/routes/events/+page.svelte

@ -3,23 +3,26 @@ @@ -3,23 +3,26 @@
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte';
import { userStore } from '$lib/stores/userStore';
import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte';
import { userPubkey, isLoggedIn } from '$lib/stores/authStore.Svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils';
import { standardRelays } from '$lib/consts';
import { getEventType } from '$lib/utils/mime';
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte';
import { checkCommunity } from '$lib/utils/search_utility';
import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import {
testAllRelays,
logRelayDiagnostics,
} from "$lib/utils/relayDiagnostics";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import { getEventType } from "$lib/utils/mime";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility";
let loading = $state(false);
let error = $state<string | null>(null);
@ -49,8 +52,8 @@ @@ -49,8 +52,8 @@
let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({});
userStore.subscribe(val => user = val);
userStore.subscribe((val) => (user = val));
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
@ -65,7 +68,7 @@ @@ -65,7 +68,7 @@
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
@ -80,21 +83,21 @@ @@ -80,21 +83,21 @@
// Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes
$effect(() => {
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
$effect(() => {
const url = $page.url.searchParams;
const tParam = url.get('t');
const nParam = url.get('n');
const tParam = url.get("t");
const nParam = url.get("n");
if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix
const decodedT = decodeURIComponent(tParam);
searchValue = `t:${decodedT}`;
}
if (nParam) {
// Decode the n parameter and set it as searchValue with n: prefix
const decodedN = decodeURIComponent(nParam);
@ -102,7 +105,15 @@ @@ -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;
secondOrderResults = secondOrder;
tTagResults = tTagEvents;
@ -110,19 +121,28 @@ @@ -110,19 +121,28 @@
originalAddresses = addresses;
searchType = searchTypeParam || null;
searchTerm = searchTermParam || null;
// Track search progress
searchInProgress = loading || (results.length > 0 && secondOrder.length === 0);
searchInProgress =
loading || (results.length > 0 && secondOrder.length === 0);
// Show second-order search message when we have first-order results but no second-order yet
if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'n') {
if (
results.length > 0 &&
secondOrder.length === 0 &&
searchTypeParam === "n"
) {
secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`;
} else if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'd') {
} else if (
results.length > 0 &&
secondOrder.length === 0 &&
searchTypeParam === "d"
) {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
}
// Check community status for all search results
if (results.length > 0) {
checkCommunityStatusForResults(results);
@ -133,7 +153,7 @@ @@ -133,7 +153,7 @@
if (tTagEvents.length > 0) {
checkCommunityStatusForResults(tTagEvents);
}
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
@ -153,7 +173,7 @@ @@ -153,7 +173,7 @@
searchInProgress = false;
secondOrderSearchMessage = null;
communityStatus = {};
goto('/events', { replaceState: true });
goto("/events", { replaceState: true });
}
function closeSidePanel() {
@ -177,7 +197,11 @@ @@ -177,7 +197,11 @@
return getMatchingTags(event, "deferral")[0]?.[1];
}
function getReferenceType(event: NDKEvent, originalEventIds: Set<string>, originalAddresses: Set<string>): string {
function getReferenceType(
event: NDKEvent,
originalEventIds: Set<string>,
originalAddresses: Set<string>,
): string {
// Check if this event has e-tags referencing original events
const eTags = getMatchingTags(event, "e");
for (const tag of eTags) {
@ -185,37 +209,40 @@ @@ -185,37 +209,40 @@
return "Reply/Reference (e-tag)";
}
}
// Check if this event has a-tags or e-tags referencing original events
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
for (const tag of tags) {
if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)";
}
}
// Check if this event has content references
if (event.content) {
for (const id of originalEventIds) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, 'i');
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, 'i');
if (neventPattern.test(event.content) || notePattern.test(event.content)) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i");
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i");
if (
neventPattern.test(event.content) ||
notePattern.test(event.content)
) {
return "Content Reference";
}
}
for (const address of originalAddresses) {
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, 'i');
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, "i");
if (naddrPattern.test(event.content)) {
return "Content Reference";
}
}
}
return "Reference";
}
@ -244,19 +271,20 @@ @@ -244,19 +271,20 @@
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function shortenAddress(addr: string, head = 10, tail = 10): string {
if (!addr || addr.length <= head + tail + 3) return addr;
return addr.slice(0, head) + '…' + addr.slice(-tail);
return addr.slice(0, head) + "…" + addr.slice(-tail);
}
function onLoadingChange(val: boolean) {
loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0);
searchInProgress =
val || (searchResults.length > 0 && secondOrderResults.length === 0);
}
/**
@ -264,20 +292,24 @@ @@ -264,20 +292,24 @@
*/
async function checkCommunityStatusForResults(events: NDKEvent[]) {
const newCommunityStatus: Record<string, boolean> = {};
for (const event of events) {
if (event.pubkey && !communityStatus[event.pubkey]) {
try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) {
console.error('Error checking community status for', event.pubkey, error);
console.error(
"Error checking community status for",
event.pubkey,
error,
);
newCommunityStatus[event.pubkey] = false;
}
} else if (event.pubkey) {
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey];
}
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
@ -287,10 +319,19 @@ @@ -287,10 +319,19 @@
const tParam = $page.url.searchParams.get("t");
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) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
console.log("ID changed, updating searchValue:", {
old: searchValue,
new: id,
});
searchValue = id;
dTagValue = null;
// Only close side panel if we're clearing the search
@ -302,7 +343,10 @@ @@ -302,7 +343,10 @@
}
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
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
@ -317,7 +361,10 @@ @@ -317,7 +361,10 @@
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
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;
dTagValue = null;
// For t-tag searches (which return multiple results), close side panel
@ -332,7 +379,10 @@ @@ -332,7 +379,10 @@
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
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;
dTagValue = null;
// For n-tag searches (which return multiple results), close side panel
@ -362,7 +412,14 @@ @@ -362,7 +412,14 @@
const tParam = $page.url.searchParams.get("t");
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
if (id !== searchValue) {
@ -391,7 +448,10 @@ @@ -391,7 +448,10 @@
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) {
console.log("t parameter changed:", { old: searchValue, new: tSearchValue });
console.log("t parameter changed:", {
old: searchValue,
new: tSearchValue,
});
searchValue = tSearchValue;
dTagValue = null;
showSidePanel = false;
@ -405,7 +465,10 @@ @@ -405,7 +465,10 @@
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) {
console.log("n parameter changed:", { old: searchValue, new: nSearchValue });
console.log("n parameter changed:", {
old: searchValue,
new: nSearchValue,
});
searchValue = nSearchValue;
dTagValue = null;
showSidePanel = false;
@ -436,8 +499,8 @@ @@ -436,8 +499,8 @@
});
onMount(() => {
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
userRelayPreference = localStorage.getItem("useUserRelays") === "true";
// Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error);
});
@ -475,11 +538,13 @@ @@ -475,11 +538,13 @@
onEventFound={handleEventFound}
onSearchResults={handleSearchResults}
onClear={handleClear}
onLoadingChange={onLoadingChange}
{onLoadingChange}
/>
{#if secondOrderSearchMessage}
<div class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg">
<div
class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg"
>
{secondOrderSearchMessage}
</div>
{/if}
@ -487,12 +552,14 @@ @@ -487,12 +552,14 @@
{#if searchResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
{#if searchType === 'n'}
{#if searchType === "n"}
Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
{:else if searchType === 't'}
Search Results for t-tag: "{searchTerm}" ({searchResults.length} events)
{:else if searchType === "t"}
Search Results for t-tag: "{searchTerm}" ({searchResults.length}
events)
{:else}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events)
Search Results for d-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
@ -504,15 +571,25 @@ @@ -504,15 +571,25 @@
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>{searchType === 'n' ? 'Profile' : 'Event'} {index + 1}</span
>{searchType === "n" ? "Profile" : "Event"}
{index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -528,7 +605,9 @@ @@ -528,7 +605,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
@ -548,13 +627,17 @@ @@ -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"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -565,7 +648,9 @@ @@ -565,7 +648,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -573,7 +658,8 @@ @@ -573,7 +658,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -591,13 +677,14 @@ @@ -591,13 +677,14 @@
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events)
</Heading>
{#if (searchType === 'n' || searchType === 'd') && secondOrderResults.length === 100}
{#if (searchType === "n" || searchType === "d") && secondOrderResults.length === 100}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing the 100 newest events. More results may be available.
</P>
{/if}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that reference, reply to, highlight, or quote the original events.
Events that reference, reply to, highlight, or quote the original
events.
</P>
<div class="space-y-4">
{#each secondOrderResults as result, index}
@ -614,9 +701,18 @@ @@ -614,9 +701,18 @@
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -632,12 +728,18 @@ @@ -632,12 +728,18 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
{getReferenceType(result, originalEventIds, originalAddresses)}
{getReferenceType(
result,
originalEventIds,
originalAddresses,
)}
</div>
{#if getSummary(result)}
<div
@ -655,13 +757,17 @@ @@ -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"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -672,7 +778,9 @@ @@ -672,7 +778,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -680,7 +788,8 @@ @@ -680,7 +788,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -695,7 +804,8 @@ @@ -695,7 +804,8 @@
{#if tTagResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({tTagResults.length} events)
Search Results for t-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({tTagResults.length} events)
</Heading>
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag.
@ -715,9 +825,18 @@ @@ -715,9 +825,18 @@
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else}
@ -733,7 +852,9 @@ @@ -733,7 +852,9 @@
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span>
</div>
@ -753,13 +874,17 @@ @@ -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"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
navigateToPublication(
getDeferralNaddr(result) || "",
);
}
}}
tabindex="0"
@ -770,7 +895,9 @@ @@ -770,7 +895,9 @@
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<div
class="text-xs text-blue-600 dark:text-blue-400 mb-1"
>
<ViewPublicationLink event={result} />
</div>
{/if}
@ -778,7 +905,8 @@ @@ -778,7 +905,8 @@
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
@ -831,10 +959,10 @@ @@ -831,10 +959,10 @@
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>

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

@ -1,15 +1,19 @@ @@ -1,15 +1,19 @@
<script lang='ts'>
<script lang="ts">
import { Heading, Button, Alert } from "flowbite-svelte";
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 { 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 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
function handleContentChange(newContent: string) {
@ -34,7 +38,7 @@ @@ -34,7 +38,7 @@
},
onError: (error) => {
publishResult = { success: false, error };
}
},
});
isPublishing = false;
@ -48,11 +52,14 @@ @@ -48,11 +52,14 @@
<!-- Main container with 75% width and centered -->
<div class="w-3/4 mx-auto">
<div class="flex flex-col space-y-4">
<Heading tag="h1" class="text-2xl font-bold text-gray-900 dark:text-gray-100">
<Heading
tag="h1"
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
Compose Notes
</Heading>
<ZettelEditor
<ZettelEditor
{content}
{showPreview}
onContentChange={handleContentChange}
@ -60,8 +67,8 @@ @@ -60,8 +67,8 @@
/>
<!-- Publish Button -->
<Button
on:click={handlePublish}
<Button
on:click={handlePublish}
disabled={isPublishing || !content.trim()}
class="w-full"
>
@ -69,7 +76,7 @@ @@ -69,7 +76,7 @@
Publishing...
{:else}
<PaperPlaneOutline class="w-4 h-4 mr-2" />
Publish
Publish
{/if}
</Button>
@ -77,12 +84,12 @@ @@ -77,12 +84,12 @@
{#if publishResult}
{#if publishResult.success}
<Alert color="green" dismissable>
<span class="font-medium">Success!</span>
<span class="font-medium">Success!</span>
Event published successfully. Event ID: {publishResult.eventId}
</Alert>
{:else}
<Alert color="red" dismissable>
<span class="font-medium">Error!</span>
<span class="font-medium">Error!</span>
{publishResult.error}
</Alert>
{/if}

46
src/routes/publication/+page.svelte

@ -13,7 +13,11 @@ @@ -13,7 +13,11 @@
let { data }: PageProps = $props();
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("toc", toc);
@ -22,10 +26,12 @@ @@ -22,10 +26,12 @@
// Get publication metadata for OpenGraph tags
let title = $derived(
data.indexEvent?.getMatchingTags("title")[0]?.[1] ||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"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
// If image unavailable, use the Alexandria default pic.
@ -35,24 +41,26 @@ @@ -35,24 +41,26 @@
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"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}`, {
replaceState: true,
});
// TODO: Extract IndexedDB interaction to a service layer.
// Store bookmark in IndexedDB
const db = indexedDB.open('alexandria', 1);
const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' });
const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
};
db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readwrite');
const store = transaction.objectStore('bookmarks');
const transaction = db.result.transaction(["bookmarks"], "readwrite");
const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
store.put({ key: bookmarkKey, address });
};
@ -61,22 +69,24 @@ @@ -61,22 +69,24 @@
onMount(() => {
// TODO: Extract IndexedDB interaction to a service layer.
// Read bookmark from IndexedDB
const db = indexedDB.open('alexandria', 1);
const db = indexedDB.open("alexandria", 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' });
const objectStore = db.result.createObjectStore("bookmarks", {
keyPath: "key",
});
};
db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readonly');
const store = transaction.objectStore('bookmarks');
const transaction = db.result.transaction(["bookmarks"], "readonly");
const store = transaction.objectStore("bookmarks");
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
const request = store.get(bookmarkKey);
request.onsuccess = () => {
if (request.result?.address) {
// Set the bookmark in the publication tree
publicationTree.setBookmark(request.result.address);
// Jump to the bookmarked element
goto(`#${request.result.address}`, {
replaceState: true,

70
src/routes/publication/+page.ts

@ -1,28 +1,28 @@ @@ -1,28 +1,28 @@
import { error } from '@sveltejs/kit';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { error } from "@sveltejs/kit";
import type { Load } from "@sveltejs/kit";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from "$lib/utils/nostrUtils";
/**
* Decodes an naddr identifier and returns a filter object
*/
function decodeNaddr(id: string) {
try {
if (!id.startsWith('naddr')) return {};
if (!id.startsWith("naddr")) return {};
const decoded = nip19.decode(id);
if (decoded.type !== 'naddr') return {};
if (decoded.type !== "naddr") return {};
const data = decoded.data;
return {
kinds: [data.kind],
authors: [data.pubkey],
'#d': [data.identifier]
"#d": [data.identifier],
};
} catch (e) {
console.error('Failed to decode naddr:', e);
console.error("Failed to decode naddr:", e);
return null;
}
}
@ -32,7 +32,7 @@ function decodeNaddr(id: string) { @@ -32,7 +32,7 @@ function decodeNaddr(id: string) {
*/
async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
const filter = decodeNaddr(id);
// Handle the case where filter is null (decoding error)
if (filter === null) {
// If we can't decode the naddr, try using the raw ID
@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
throw error(404, `Failed to fetch publication root event.\n${err}`);
}
}
const hasFilter = Object.keys(filter).length > 0;
try {
const event = await (hasFilter ?
ndk.fetchEvent(filter) :
ndk.fetchEvent(id));
const event = await (hasFilter
? ndk.fetchEvent(filter)
: ndk.fetchEvent(id));
if (!event) {
throw new Error(`Event not found for ID: ${id}`);
}
@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try {
const event = await ndk.fetchEvent(
{ '#d': [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk)
{ "#d": [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk),
);
if (!event) {
throw new Error(`Event not found for d tag: ${dTag}`);
}
@ -84,21 +84,27 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { @@ -84,21 +84,27 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
}
// TODO: Use path params instead of query params.
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
export const load: Load = async ({
url,
parent,
}: {
url: URL;
parent: () => Promise<any>;
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk } = await parent();
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
const indexEvent = id
const indexEvent = id
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
return {
publicationType,

25
src/routes/start/+page.svelte

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

6
src/routes/visualize/+page.svelte

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

2
src/styles/asciidoc.css

@ -50,4 +50,4 @@ @@ -50,4 +50,4 @@
.dark .asciidoc-content h5,
.dark .asciidoc-content h6 {
color: inherit;
}
}

22
test_data/LaTeXtestfile.json

File diff suppressed because one or more lines are too long

21
test_data/LaTeXtestfile.md

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

56
tests/unit/latexRendering.test.ts

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

Loading…
Cancel
Save