Browse Source

ran prettier

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

2
src/app.css

@ -3,7 +3,7 @@ @@ -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 {

201
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;
@ -83,10 +86,9 @@ @@ -83,10 +86,9 @@
$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) {
@ -257,38 +280,49 @@ @@ -257,38 +280,49 @@
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,7 +396,7 @@ @@ -362,7 +396,7 @@
bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) {
if (e.key === "Enter" && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
@ -389,24 +423,47 @@ @@ -389,24 +423,47 @@
{#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}

166
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 {
@ -55,7 +55,10 @@ @@ -55,7 +55,10 @@
}
// 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) {
@ -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,7 +300,7 @@ @@ -285,7 +300,7 @@
});
$effect(() => {
if(!event?.pubkey) {
if (!event?.pubkey) {
authorDisplayName = undefined;
return;
}
@ -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)}`);

290
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();
@ -120,14 +138,14 @@ @@ -120,14 +138,14 @@
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,7 +153,7 @@ @@ -135,7 +153,7 @@
// Validate before proceeding
const validation = validate();
if (!validation.valid) {
error = validation.reason || 'Validation failed.';
error = validation.reason || "Validation failed.";
loading = false;
return;
}
@ -143,38 +161,44 @@ @@ -143,38 +161,44 @@
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;
}
@ -183,16 +207,16 @@ @@ -183,16 +207,16 @@
// 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]];
}
}
}
@ -200,7 +224,7 @@ @@ -200,7 +224,7 @@
// 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
@ -224,11 +248,11 @@ @@ -224,11 +248,11 @@
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
@ -236,14 +260,20 @@ @@ -236,14 +260,20 @@
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 {
@ -258,7 +288,12 @@ @@ -258,7 +288,12 @@
};
// 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) {
@ -314,8 +349,8 @@ @@ -314,8 +349,8 @@
}
}
} 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;
}
@ -326,11 +361,11 @@ @@ -326,11 +361,11 @@
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,91 +393,134 @@ @@ -354,91 +393,134 @@
}
</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>

232
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);
@ -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;
@ -309,7 +400,7 @@ @@ -309,7 +400,7 @@
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
@ -338,7 +429,7 @@ @@ -338,7 +429,7 @@
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
@ -353,7 +444,7 @@ @@ -353,7 +444,7 @@
searching = false;
searchCompleted = true;
searchResultCount = 1;
searchResultType = 'event';
searchResultType = "event";
// Update last processed search value to prevent re-processing
if (searchValue) {
@ -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,7 +576,7 @@ @@ -471,7 +576,7 @@
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
@ -489,12 +594,12 @@ @@ -489,12 +594,12 @@
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,
@ -534,9 +639,13 @@ @@ -534,9 +639,13 @@
return "Search completed. No results found.";
}
const typeLabel = searchResultType === 'n' ? 'profile' :
searchResultType === 'nip05' ? 'NIP-05 address' : 'event';
const countLabel = searchResultType === 'n' ? 'profiles' : 'events';
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}.`
@ -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}
@ -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}

220
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 || ''}
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,7 +384,7 @@ @@ -334,7 +384,7 @@
</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>
@ -344,13 +394,21 @@ @@ -344,13 +394,21 @@
{/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"

16
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';
import { loginWithExtension } from "$lib/stores/userStore";
import { userStore } from "$lib/stores/userStore";
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{
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,7 +40,7 @@ @@ -36,7 +40,7 @@
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
errorMessage = "";
await loginWithExtension();
} catch (e: unknown) {

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.

76
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
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;
@ -34,8 +37,6 @@ Note content here... @@ -34,8 +37,6 @@ Note content here...
// Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Toggle preview panel
function togglePreview() {
const newShowPreview = !showPreview;
@ -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}`, {
<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',
doctype: "article",
attributes: {
'showtitle': true,
'sectids': true
}
})}
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>
@ -140,9 +161,14 @@ Note content here... @@ -140,9 +161,14 @@ Note content here...
{/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}

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

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

26
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,9 +44,11 @@ @@ -41,9 +44,11 @@
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => {
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
}).catch(() => {
})
.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}

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

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

@ -57,7 +57,9 @@ @@ -57,7 +57,9 @@
// 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;
@ -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;
}
@ -190,10 +194,10 @@ @@ -190,10 +194,10 @@
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,7 +256,9 @@ @@ -252,7 +256,9 @@
// 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();
@ -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" />

51
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,36 +14,43 @@ @@ -14,36 +14,43 @@
});
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}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
}
);
});
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
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
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)}
@ -51,13 +58,13 @@ @@ -51,13 +58,13 @@
{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>

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

@ -1,11 +1,14 @@ @@ -1,11 +1,14 @@
<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 {
@ -14,32 +17,38 @@ @@ -14,32 +17,38 @@
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,15 +66,20 @@ @@ -57,15 +66,20 @@
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
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][] = [];
@ -76,13 +90,17 @@ @@ -76,13 +90,17 @@
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()
leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
@ -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>

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

@ -1,22 +1,23 @@ @@ -1,22 +1,23 @@
<script lang='ts'>
<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';
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}

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

@ -91,7 +91,7 @@ export class SveltePublicationTree { @@ -91,7 +91,7 @@ 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.
@ -105,7 +105,7 @@ export class SveltePublicationTree { @@ -105,7 +105,7 @@ export class SveltePublicationTree {
for (const observer of this.#bookmarkMovedObservers) {
observer(address);
}
}
};
// #endregion
}

45
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;
@ -37,7 +37,11 @@ export class TableOfContents { @@ -37,7 +37,11 @@ export class TableOfContents {
* @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);
@ -71,10 +75,7 @@ export class TableOfContents { @@ -71,10 +75,7 @@ export class TableOfContents {
* 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) => {
@ -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) {
@ -262,7 +265,7 @@ export class TableOfContents { @@ -262,7 +265,7 @@ export class TableOfContents {
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;
});
}

90
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,18 +160,28 @@ @@ -144,18 +160,28 @@
<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>

48
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);
@ -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>

15
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();
@ -23,8 +27,7 @@ @@ -23,8 +27,7 @@
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub;
user.fetchProfile()
.then((userProfile: NDKUserProfile | null) => {
user.fetchProfile().then((userProfile: NDKUserProfile | null) => {
profile = userProfile;
});
});

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

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

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",

129
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.`,
);
}
@ -163,13 +166,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -163,13 +166,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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,7 +186,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -181,7 +186,9 @@ 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)!];
@ -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));
}
});
}
@ -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;
}
@ -271,7 +282,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -271,7 +282,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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;
}
@ -289,7 +302,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -289,7 +302,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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,7 +333,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -317,7 +333,9 @@ 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;
}
@ -328,7 +346,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -328,7 +346,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) {
@ -345,7 +364,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -345,7 +364,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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
@ -375,7 +396,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -375,7 +396,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* @returns The next event in the tree, or null if the tree is empty.
*/
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)) {
@ -399,7 +420,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -399,7 +420,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* @returns The previous event in the tree, or null if the tree is empty.
*/
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)) {
@ -416,7 +437,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -416,7 +437,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
}
async #yieldEventAtCursor(done: boolean): Promise<IteratorResult<NDKEvent | null>> {
async #yieldEventAtCursor(
done: boolean,
): Promise<IteratorResult<NDKEvent | null>> {
const value = (await this.getEvent(this.#cursor.target!.address)) ?? null;
return { done, value };
}
@ -431,12 +454,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -431,12 +454,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* 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
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
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
@ -472,12 +497,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -472,12 +497,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
* 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
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
const tryMoveToChild: () => Promise<boolean> =
direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
@ -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,11 +602,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -569,11 +602,13 @@ 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));
}
/**
@ -587,18 +622,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -587,18 +622,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
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,7 +647,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -612,7 +647,9 @@ 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),
@ -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;
}

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

@ -172,7 +172,12 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -172,7 +172,12 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
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) => {

53
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);
@ -432,9 +438,9 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -432,9 +438,9 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
// 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;
}
@ -444,20 +450,22 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -444,20 +450,22 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet(
new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
new Set(
filterProblematicRelays(user.relays.inbox).map(
(relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk,
))),
ndk
)
: new NDKRelaySet(
new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
new Set(
filterProblematicRelays(standardRelays).map(
(relay) =>
new NDKRelay(relay, NDKRelayAuthPolicies.signIn({ ndk }), ndk),
),
),
ndk,
))),
ndk
);
}
@ -492,7 +500,8 @@ export function initNdk(): NDK { @@ -492,7 +500,8 @@ export function initNdk(): NDK {
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
// Connect with better error handling
ndk.connect()
ndk
.connect()
.then(() => {
console.debug("[NDK.ts] NDK connected successfully");
})
@ -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;

48
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,11 +26,13 @@ export interface PublishOptions { @@ -23,11 +26,13 @@ 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 };
}
@ -36,7 +41,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -36,7 +41,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
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 };
}
@ -46,7 +51,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -46,7 +51,7 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
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,17 +60,11 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -55,17 +60,11 @@ 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);
}
@ -81,10 +80,12 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -81,10 +80,12 @@ 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);
@ -96,10 +97,11 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes @@ -96,10 +97,11 @@ export async function publishZettel(options: PublishOptions): Promise<PublishRes
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 }),
};
}

2
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.

165
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,7 +70,7 @@ function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { @@ -59,7 +70,7 @@ 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(
{
@ -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);
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,26 +263,26 @@ export async function loginWithNpub(pubkeyOrNpub: string) { @@ -229,26 +263,26 @@ 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
@ -258,27 +292,34 @@ export function logoutUser() { @@ -258,27 +292,34 @@ export function logoutUser() {
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.push("alexandria/login/pubkey");
keysToRemove.push("alexandria/login/method");
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
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');
localStorage.setItem("alexandria/logout/flag", "true");
console.log('Cleared all login data from localStorage');
console.log("Cleared all login data from localStorage");
userStore.set({
pubkey: null,
@ -297,5 +338,5 @@ export function logoutUser() { @@ -297,5 +338,5 @@ export function logoutUser() {
ndk.signer = undefined;
}
console.log('Logout complete');
console.log("Logout complete");
}

61
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,12 +79,12 @@ export function parseAsciiDocSections(content: string, level: number): ZettelSec @@ -75,12 +79,12 @@ 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');
const tags: string[][] = [];
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith(':')) {
if (trimmed.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
if (match) {
@ -88,11 +92,14 @@ export function extractTags(content: string): string[][] { @@ -88,11 +92,14 @@ export function extractTags(content: string): string[][] {
const tagValue = match[2].trim();
// Special handling for tags attribute
if (tagName === 'tags') {
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);
const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) {
tags.push(['t', value]);
tags.push(["t", value]);
}
} else {
// Regular attribute becomes a tag
@ -102,7 +109,7 @@ export function extractTags(content: string): string[][] { @@ -102,7 +109,7 @@ export function extractTags(content: string): string[][] {
}
}
console.log('Extracted tags:', tags);
console.log("Extracted tags:", tags);
return tags;
}
// You can add publishing logic here as needed, e.g.,

34
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>();
@ -17,21 +17,25 @@ export async function checkCommunity(pubkey: string): Promise<boolean> { @@ -17,21 +17,25 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
ws.send(
JSON.stringify([
"REQ",
RELAY_CONSTANTS.COMMUNITY_REQUEST_ID,
{
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK
}
]));
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,23 +56,25 @@ export async function checkCommunity(pubkey: string): Promise<boolean> { @@ -52,23 +56,25 @@ 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 };
}
});
@ -77,7 +83,7 @@ export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>) @@ -77,7 +83,7 @@ export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>)
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;
}
}

184
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,7 +76,10 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st @@ -61,7 +76,10 @@ 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) {
@ -71,19 +89,29 @@ export function validate30040EventSet(content: string): { valid: boolean; reason @@ -71,19 +89,29 @@ export function validate30040EventSet(content: string): { valid: boolean; reason
// 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[] = [];
@ -160,7 +192,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe @@ -160,7 +192,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join('\n').trim());
sections.push(current.join("\n").trim());
current = [];
}
@ -176,7 +208,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe @@ -176,7 +208,7 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== '') {
if (line.trim() !== "") {
preambleContent.push(line);
}
}
@ -184,13 +216,13 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe @@ -184,13 +216,13 @@ function splitAsciiDocSections(content: string): { sections: string[]; sectionHe
// 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;
}
@ -216,26 +248,27 @@ function getNdk() { @@ -216,26 +248,27 @@ 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);
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders);
const dTags = sectionHeaders.length === sections.length
const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log('D tags:', dTags);
console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
@ -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,8 +337,12 @@ export function getTitleTagForEvent(kind: number, content: string): string | nul @@ -303,8 +337,12 @@ 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();
}
@ -338,41 +376,47 @@ The content is split into sections, each published as a separate 30041 event.`; @@ -338,41 +376,47 @@ The content is split into sections, each published as a separate 30041 event.`;
* Analyzes a 30040 event to determine if it was created correctly.
* 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")`,
);
}
}

22
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("|");
}
/**
@ -40,7 +40,9 @@ class IndexEventCache { @@ -40,7 +40,9 @@ class IndexEventCache {
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;
}
@ -61,10 +63,12 @@ class IndexEventCache { @@ -61,10 +63,12 @@ class IndexEventCache {
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,7 +109,11 @@ class IndexEventCache { @@ -105,7 +109,11 @@ 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;
@ -119,7 +127,7 @@ class IndexEventCache { @@ -119,7 +127,7 @@ class IndexEventCache {
return {
size: this.cache.size,
totalEvents,
oldestEntry: oldestTimestamp
oldestEntry: oldestTimestamp,
};
}
}

6
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;
@ -139,7 +139,7 @@ function isLaTeXContent(content: string): boolean { @@ -139,7 +139,7 @@ function isLaTeXContent(content: string): boolean {
/\\mathscr\{/, // Script
];
return latexPatterns.some(pattern => pattern.test(trimmed));
return latexPatterns.some((pattern) => pattern.test(trimmed));
}
/**

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

@ -10,18 +10,19 @@ hljs.configure({ @@ -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;")
@ -685,7 +686,7 @@ function isLaTeXContent(content: string): boolean { @@ -685,7 +686,7 @@ function isLaTeXContent(content: string): boolean {
/\\\\mathscr\{/, // Script with double backslashes
];
return latexPatterns.some(pattern => pattern.test(trimmed));
return latexPatterns.some((pattern) => pattern.test(trimmed));
}
/**

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

@ -35,14 +35,11 @@ function replaceWikilinks(html: string): string { @@ -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) => {
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";
}

210
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,18 +76,18 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -72,18 +76,18 @@ 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;
@ -92,16 +96,21 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -92,16 +96,21 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
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,8 +120,10 @@ export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { @@ -111,8 +120,10 @@ 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,
@ -126,22 +137,32 @@ export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo { @@ -126,22 +137,32 @@ 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;
@ -150,19 +171,26 @@ function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo @@ -150,19 +171,26 @@ function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo
/**
* 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,11 +203,13 @@ export function buildReplyTags( @@ -175,11 +203,13 @@ 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;
@ -187,22 +217,22 @@ export function buildReplyTags( @@ -187,22 +217,22 @@ export function buildReplyTags(
// 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}`;
@ -210,28 +240,28 @@ export function buildReplyTags( @@ -210,28 +240,28 @@ export function buildReplyTags(
// 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,14 +353,17 @@ export async function createSignedEvent( @@ -316,14 +353,17 @@ 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) => {
@ -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 "";
}

90
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,9 +52,12 @@ function escapeHtml(text: string): string { @@ -52,9 +52,12 @@ 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)!;
@ -159,9 +162,11 @@ export async function createProfileLinkWithVerification( @@ -159,9 +162,11 @@ export async function createProfileLinkWithVerification(
// 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;
@ -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,9 +285,9 @@ export async function processNostrIdentifiers( @@ -280,9 +285,9 @@ 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;
}
@ -295,16 +300,20 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -295,16 +300,20 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
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;
}
@ -316,15 +325,19 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -316,15 +325,19 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
// 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;
@ -582,7 +595,8 @@ export function prefixNostrAddresses(content: string): string { @@ -582,7 +595,8 @@ 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)
@ -590,13 +604,13 @@ export function prefixNostrAddresses(content: string): string { @@ -590,13 +604,13 @@ export function prefixNostrAddresses(content: string): string {
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) {
@ -605,7 +619,7 @@ export function prefixNostrAddresses(content: string): string { @@ -605,7 +619,7 @@ export function prefixNostrAddresses(content: string): string {
}
// 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) {
@ -614,10 +628,10 @@ export function prefixNostrAddresses(content: string): string { @@ -614,10 +628,10 @@ export function prefixNostrAddresses(content: string): string {
}
// 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
}
}
@ -639,7 +653,19 @@ export function prefixNostrAddresses(content: string): string { @@ -639,7 +653,19 @@ export function prefixNostrAddresses(content: string): string {
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
}

267
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 => {
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[];
})
.filter(Boolean) as NostrProfile[];
console.log('Cached profiles found:', profiles.length);
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,33 +82,41 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -64,33 +82,41 @@ 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;
});
@ -100,17 +126,16 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -100,17 +126,16 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
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;
@ -205,39 +246,44 @@ async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrPr @@ -205,39 +246,44 @@ async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrPr
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 => {
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);
})
.filter(Boolean);
// Search all relays in parallel with short timeout
const searchPromises = relaySets.map(async (relaySet, index) => {
@ -247,43 +293,60 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -247,43 +293,60 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
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
@ -314,7 +381,7 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -314,7 +381,7 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
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;
@ -323,6 +390,8 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf @@ -323,6 +390,8 @@ async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProf
}
}
console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`);
console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles);
}

44
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;
@ -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])];
const allRelays = [
...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays]),
];
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...');
console.log("[RelayDiagnostics] Testing", allRelays.length, "relays...");
const results = await Promise.allSettled(
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,29 +113,29 @@ export async function testAllRelays(): Promise<RelayDiagnostic[]> { @@ -111,29 +113,29 @@ 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');
console.group("[RelayDiagnostics] Results");
const working = diagnostics.filter(d => d.connected);
const failed = diagnostics.filter(d => !d.connected);
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"}`);
});
}

10
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[];
@ -53,11 +53,15 @@ class SearchCache { @@ -53,11 +53,15 @@ class SearchCache {
/**
* 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(),
});
}

2
src/lib/utils/search_constants.ts

@ -87,7 +87,7 @@ export const EVENT_KINDS = { @@ -87,7 +87,7 @@ 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],

6
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>;

26
src/lib/utils/search_utility.ts

@ -1,17 +1,17 @@ @@ -1,17 +1,17 @@
// 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 { searchProfiles } from "./profile_search";
export { searchBySubscription } from "./subscription_search";
export { searchEvent, searchNip05 } from "./event_search";
export { checkCommunity } from "./community_checker";
export {
wellKnownUrl,
lnurlpWellKnownUrl,
@ -21,5 +21,5 @@ export { @@ -21,5 +21,5 @@ export {
nip05Matches,
COMMON_DOMAINS,
isEmojiReaction,
createProfileFromEvent
} from './search_utils';
createProfileFromEvent,
} from "./search_utils";

27
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,7 +32,7 @@ export function normalizeSearchTerm(term: string): string { @@ -32,7 +32,7 @@ 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);
@ -46,7 +46,7 @@ export function fieldMatches(field: string, searchTerm: string): boolean { @@ -46,7 +46,7 @@ export function fieldMatches(field: string, searchTerm: string): boolean {
// 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));
}
/**
@ -59,11 +59,14 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean { @@ -59,11 +59,14 @@ export function nip05Matches(nip05: string, searchTerm: string): boolean {
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,
};
}

489
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,11 +28,15 @@ export async function searchBySubscription( @@ -16,11 +28,15 @@ 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);
@ -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);
}
@ -256,30 +354,45 @@ function processPrimaryRelayResults( @@ -256,30 +354,45 @@ function processPrimaryRelayResults(
}
}
console.log("subscription_search: Processed events - firstOrder:", searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, "tTag:", searchState.tTagEvents.length);
console.log(
"subscription_search: Processed events - firstOrder:",
searchState.firstOrderEvents.length,
"profiles:",
searchState.foundProfiles.length,
"tTag:",
searchState.tTagEvents.length,
);
}
/**
* Process profile event
*/
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);
@ -289,7 +402,15 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz @@ -289,7 +402,15 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz
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,11 +418,19 @@ function processProfileEvent(event: NDKEvent, subscriptionType: string, normaliz @@ -297,11 +418,19 @@ 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
@ -319,7 +448,7 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType @@ -319,7 +448,7 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
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,28 +503,35 @@ async function searchOtherRelaysInBackground( @@ -362,28 +503,35 @@ 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') {
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 + '/');
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 + '/';
return (
relay.url !== communityRelay && relay.url !== communityRelay + "/"
);
}
})),
ndk
}),
),
ndk,
);
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,
{ closeOnEose: true },
otherRelays
otherRelays,
);
// Store the subscription for cleanup
@ -394,10 +542,15 @@ async function searchOtherRelaysInBackground( @@ -394,10 +542,15 @@ async function searchOtherRelaysInBackground(
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,13 +581,13 @@ function processEoseResults( @@ -423,13 +581,13 @@ 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);
}
@ -439,9 +597,13 @@ function processEoseResults( @@ -439,9 +597,13 @@ function processEoseResults(
/**
* 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
@ -457,19 +619,36 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, @@ -457,19 +619,36 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter,
// 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,
);
}
}
}
@ -478,36 +657,47 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, @@ -478,36 +657,47 @@ function processProfileEoseResults(searchState: any, searchFilter: SearchFilter,
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 {
@ -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,7 +716,7 @@ function processContentEoseResults(searchState: any, searchType: SearchSubscript @@ -526,7 +716,7 @@ 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 {
@ -535,15 +725,18 @@ function processTTagEoseResults(searchState: any): SearchResult { @@ -535,15 +725,18 @@ function processTTagEoseResults(searchState: any): SearchResult {
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,
);
}
}

107
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 }) => {
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',
});
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.');
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.');
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');
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 new Promise((resolve) => setTimeout(resolve, 100));
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,
);
}
}
});
},
);
} 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"

23
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";
@ -47,7 +56,7 @@ @@ -47,7 +56,7 @@
// 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 =

286
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);
@ -50,7 +53,7 @@ @@ -50,7 +53,7 @@
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;
@ -80,14 +83,14 @@ @@ -80,14 +83,14 @@
// 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
@ -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;
@ -112,12 +123,21 @@ @@ -112,12 +123,21 @@
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;
@ -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) {
@ -201,15 +225,18 @@ @@ -201,15 +225,18 @@
// 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";
}
@ -251,12 +278,13 @@ @@ -251,12 +278,13 @@
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);
}
/**
@ -270,7 +298,11 @@ @@ -270,7 +298,11 @@
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) {
@ -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,7 +499,7 @@ @@ -436,7 +499,7 @@
});
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>

21
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,7 +52,10 @@ @@ -48,7 +52,10 @@
<!-- 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>

32
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);
@ -25,7 +29,9 @@ @@ -25,7 +29,9 @@
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.
@ -38,21 +44,23 @@ @@ -38,21 +44,23 @@
"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,14 +69,16 @@ @@ -61,14 +69,16 @@
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);

46
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;
}
}
@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
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,9 +69,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -69,9 +69,9 @@ 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] },
{ "#d": [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk)
getActiveRelays(ndk),
);
if (!event) {
@ -84,13 +84,19 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { @@ -84,13 +84,19 @@ 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
@ -98,7 +104,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -98,7 +104,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? 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">

4
src/routes/visualize/+page.svelte

@ -72,7 +72,9 @@ @@ -72,7 +72,9 @@
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];

20
test_data/LaTeXtestfile.json

File diff suppressed because one or more lines are too long

13
test_data/LaTeXtestfile.md

@ -27,17 +27,23 @@ f(x)= @@ -27,17 +27,23 @@ f(x)=
1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\
0 & \quad \text{otherwise}
\end{cases}
$$`
$$
`
And a matrix:
`$$
`
$$
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.
@ -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